UI 개선 — 모바일 사진 업로드 카메라/갤러리 버튼 분리, 에러코드 차트 단순화
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -543,77 +543,22 @@ def stats_top_chargers(limit: int = 10):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats/charger-error-codes")
|
@app.get("/api/stats/charger-error-codes")
|
||||||
def stats_charger_error_codes(code_limit: int = 10, charger_limit: int = 5):
|
def stats_charger_error_codes(code_limit: int = 10):
|
||||||
"""에러코드별 누적 건수 Top N (어느 충전기에서 발생했는지 함께 반환)."""
|
"""에러코드별 누적 건수 Top N (단순 순위)."""
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from collections import defaultdict
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
rows = db.execute(text("""
|
rows = db.execute(text("""
|
||||||
SELECT TRIM(rep.error_code) AS error_code,
|
SELECT TRIM(error_code) AS error_code, COUNT(*) AS cnt
|
||||||
rep.charger_id,
|
FROM reports
|
||||||
COALESCE(c.station_name, rep.charger_id) AS station_name,
|
WHERE error_code IS NOT NULL AND TRIM(error_code) != ''
|
||||||
COALESCE(c.name, '') AS charger_name,
|
GROUP BY TRIM(error_code)
|
||||||
COUNT(*) AS cnt
|
ORDER BY cnt DESC
|
||||||
FROM reports rep
|
LIMIT :limit
|
||||||
LEFT JOIN chargers c ON c.id = rep.charger_id
|
"""), {"limit": code_limit}).fetchall()
|
||||||
WHERE rep.error_code IS NOT NULL
|
# 역순: 차트 Y축에서 1위가 맨 위
|
||||||
AND TRIM(rep.error_code) != ''
|
result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)]
|
||||||
GROUP BY TRIM(rep.error_code), rep.charger_id, c.station_name, c.name
|
return {"error_codes": result}
|
||||||
""")).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {"error_codes": [], "charger_labels": {}}
|
|
||||||
|
|
||||||
# error_code → {total, chargers: {cid: cnt}}
|
|
||||||
code_info = {}
|
|
||||||
# charger_id → label, total across all codes
|
|
||||||
charger_info = {}
|
|
||||||
|
|
||||||
for ecode, cid, sname, cname, cnt in rows:
|
|
||||||
cnt = int(cnt)
|
|
||||||
if ecode not in code_info:
|
|
||||||
code_info[ecode] = {"total": 0, "chargers": {}}
|
|
||||||
code_info[ecode]["total"] += cnt
|
|
||||||
code_info[ecode]["chargers"][cid] = cnt
|
|
||||||
|
|
||||||
if cid not in charger_info:
|
|
||||||
label = sname + (f" ({cname})" if cname else "")
|
|
||||||
if len(label) > 20: label = label[:18] + "…"
|
|
||||||
charger_info[cid] = {"label": label, "total": 0}
|
|
||||||
charger_info[cid]["total"] += cnt
|
|
||||||
|
|
||||||
# Top N error codes by total
|
|
||||||
top_codes = sorted(code_info.items(), key=lambda x: -x[1]["total"])[:code_limit]
|
|
||||||
|
|
||||||
# Top M chargers across all top codes
|
|
||||||
top_cids = [cid for cid, _ in
|
|
||||||
sorted(charger_info.items(), key=lambda x: -x[1]["total"])[:charger_limit]]
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for ecode, info in reversed(top_codes): # 역순: 차트에서 1위가 위에
|
|
||||||
entry = {"error_code": ecode, "total": info["total"]}
|
|
||||||
other = 0
|
|
||||||
for cid, cnt in info["chargers"].items():
|
|
||||||
if cid in top_cids:
|
|
||||||
entry[cid] = cnt
|
|
||||||
else:
|
|
||||||
other += cnt
|
|
||||||
if other:
|
|
||||||
entry["__other__"] = other
|
|
||||||
result.append(entry)
|
|
||||||
|
|
||||||
has_other = any(r.get("__other__", 0) > 0 for r in result)
|
|
||||||
charger_labels = {cid: charger_info[cid]["label"] for cid in top_cids}
|
|
||||||
if has_other:
|
|
||||||
charger_labels["__other__"] = "기타"
|
|
||||||
|
|
||||||
dataset_keys = top_cids + (["__other__"] if has_other else [])
|
|
||||||
return {
|
|
||||||
"error_codes": result, # 에러코드별 집계 (역순)
|
|
||||||
"charger_labels": charger_labels, # charger_id → 표시명
|
|
||||||
"dataset_keys": dataset_keys, # 차트 dataset 순서
|
|
||||||
}
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -170,5 +170,26 @@ const ImageCompressor = (() => {
|
|||||||
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { compressAll, setupPreview, loadConfig };
|
/**
|
||||||
|
* 카메라 input(capture)이 찍은 사진을 갤러리 input에 병합 후 change 이벤트 발생
|
||||||
|
* → setupPreview는 갤러리 input 하나만 바라보면 됨
|
||||||
|
* @param {string} cameraId - capture="environment" input id
|
||||||
|
* @param {string} galleryId - 기존 multiple input id (setupPreview 대상)
|
||||||
|
*/
|
||||||
|
function setupCameraAppend(cameraId, galleryId) {
|
||||||
|
const cam = document.getElementById(cameraId);
|
||||||
|
const main = document.getElementById(galleryId);
|
||||||
|
if (!cam || !main) return;
|
||||||
|
cam.addEventListener('change', function () {
|
||||||
|
if (!this.files.length) return;
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
Array.from(main.files).forEach(f => dt.items.add(f)); // 기존 파일 유지
|
||||||
|
Array.from(this.files).forEach(f => dt.items.add(f)); // 새 사진 추가
|
||||||
|
main.files = dt.files;
|
||||||
|
main.dispatchEvent(new Event('change')); // setupPreview 재실행
|
||||||
|
this.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { compressAll, setupPreview, loadConfig, setupCameraAppend };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -231,7 +231,7 @@
|
|||||||
<div class="card" style="margin-bottom:20px">
|
<div class="card" style="margin-bottom:20px">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
||||||
<div class="card-title" style="margin:0">⚠️ 에러코드 누적 순위 Top 10</div>
|
<div class="card-title" style="margin:0">⚠️ 에러코드 누적 순위 Top 10</div>
|
||||||
<span style="font-size:11px;color:var(--gray4)">에러코드 입력된 신고 기준 · 색상 = 충전기</span>
|
<span style="font-size:11px;color:var(--gray4)">에러코드 입력된 신고 기준</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="errorCodesChartWrap" style="position:relative">
|
<div id="errorCodesChartWrap" style="position:relative">
|
||||||
<canvas id="errorCodesChart"></canvas>
|
<canvas id="errorCodesChart"></canvas>
|
||||||
@@ -645,48 +645,38 @@ async function loadErrorCodesChart() {
|
|||||||
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
|
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { error_codes, charger_labels, dataset_keys } = data;
|
const { error_codes } = data;
|
||||||
const CHARGER_COLORS = ['#3B82F6','#EF4444','#F59E0B','#10B981','#8B5CF6','#94A3B8'];
|
wrap.style.height = (error_codes.length * 34 + 40) + 'px';
|
||||||
|
|
||||||
wrap.style.height = (error_codes.length * 32 + 50) + 'px';
|
|
||||||
if (_errorCodesChart) _errorCodesChart.destroy();
|
if (_errorCodesChart) _errorCodesChart.destroy();
|
||||||
const ctx = document.getElementById('errorCodesChart').getContext('2d');
|
const ctx = document.getElementById('errorCodesChart').getContext('2d');
|
||||||
_errorCodesChart = new Chart(ctx, {
|
_errorCodesChart = new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: error_codes.map(e => e.error_code),
|
labels: error_codes.map(e => e.error_code),
|
||||||
datasets: dataset_keys.map((key, i) => ({
|
datasets: [{
|
||||||
label: charger_labels[key],
|
data: error_codes.map(e => e.total),
|
||||||
data: error_codes.map(e => e[key] || 0),
|
backgroundColor: '#1565C0',
|
||||||
backgroundColor: CHARGER_COLORS[i % CHARGER_COLORS.length],
|
borderRadius: 4,
|
||||||
borderRadius: 3,
|
}]
|
||||||
stack: 'err',
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
indexAxis: 'y',
|
indexAxis: 'y',
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
onHover: (evt, elems) => { evt.native.target.style.cursor = elems.length ? 'pointer' : 'default'; },
|
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: true, position: 'top',
|
legend: { display: false },
|
||||||
labels: { font: { size: 11 }, boxWidth: 12, boxHeight: 12, padding: 12 } },
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
title: items => {
|
title: items => `에러코드: ${error_codes[items[0].dataIndex].error_code}`,
|
||||||
const e = error_codes[items[0].dataIndex];
|
label: c => `누적 ${c.raw}건`,
|
||||||
return `에러코드: ${e.error_code} (총 ${e.total}건)`;
|
|
||||||
},
|
|
||||||
label: c => `${c.dataset.label}: ${c.raw}건`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { stacked: true, grid: { color: '#F1F5F9' }, border: { dash: [3,3] },
|
x: { beginAtZero: true, grid: { color: '#F1F5F9' }, border: { dash: [3,3] },
|
||||||
beginAtZero: true,
|
ticks: { font: { size: 11 }, color: '#64748B',
|
||||||
ticks: { font: { size: 11 }, color: '#64748B', stepSize: 1,
|
|
||||||
callback: v => Number.isInteger(v) ? v + '건' : '' } },
|
callback: v => Number.isInteger(v) ? v + '건' : '' } },
|
||||||
y: { stacked: true, grid: { display: false },
|
y: { grid: { display: false },
|
||||||
ticks: { font: { size: 12 }, color: '#334155' } }
|
ticks: { font: { size: 12 }, color: '#334155' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,14 +67,22 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
<label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
||||||
<label class="upload-area" for="photosBefore">📷 촬영 또는 앨범 선택</label>
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||||
|
<label class="upload-area" for="photosBeforeCamera" style="margin:0">📷 카메라 촬영</label>
|
||||||
|
<label class="upload-area" for="photosBefore" style="margin:0">🖼 갤러리 선택</label>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="photosBeforeCamera" accept="image/*" capture="environment" style="display:none">
|
||||||
<input type="file" id="photosBefore" accept="image/*" multiple style="display:none">
|
<input type="file" id="photosBefore" accept="image/*" multiple style="display:none">
|
||||||
<div class="photo-preview" id="previewBefore"></div>
|
<div class="photo-preview" id="previewBefore"></div>
|
||||||
<div class="photo-info" id="infoBefore"></div>
|
<div class="photo-info" id="infoBefore"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
<label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
||||||
<label class="upload-area" for="photosAfter">📷 촬영 또는 앨범 선택</label>
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||||
|
<label class="upload-area" for="photosAfterCamera" style="margin:0">📷 카메라 촬영</label>
|
||||||
|
<label class="upload-area" for="photosAfter" style="margin:0">🖼 갤러리 선택</label>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="photosAfterCamera" accept="image/*" capture="environment" style="display:none">
|
||||||
<input type="file" id="photosAfter" accept="image/*" multiple style="display:none">
|
<input type="file" id="photosAfter" accept="image/*" multiple style="display:none">
|
||||||
<div class="photo-preview" id="previewAfter"></div>
|
<div class="photo-preview" id="previewAfter"></div>
|
||||||
<div class="photo-info" id="infoAfter"></div>
|
<div class="photo-info" id="infoAfter"></div>
|
||||||
@@ -309,6 +317,8 @@ navigator.geolocation?.getCurrentPosition(
|
|||||||
// 이미지 압축 + 다중 선택 프리뷰
|
// 이미지 압축 + 다중 선택 프리뷰
|
||||||
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
|
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
|
||||||
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
|
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
|
||||||
|
ImageCompressor.setupCameraAppend('photosBeforeCamera', 'photosBefore');
|
||||||
|
ImageCompressor.setupCameraAppend('photosAfterCamera', 'photosAfter');
|
||||||
|
|
||||||
async function submitForm(isDone) {
|
async function submitForm(isDone) {
|
||||||
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
|
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
|
||||||
|
|||||||
@@ -242,10 +242,11 @@ body { background: var(--gray1); }
|
|||||||
충전기 사진 <span style="color:var(--red)">*필수</span>
|
충전기 사진 <span style="color:var(--red)">*필수</span>
|
||||||
<span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span>
|
<span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="upload-area" for="chargerPhoto">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||||
📷 탭하여 촬영하거나 앨범에서 선택<br>
|
<label class="upload-area" for="chargerPhotoCamera" style="margin:0">📷 카메라 촬영</label>
|
||||||
<span style="font-size:11px">여러 장 동시 선택 가능</span>
|
<label class="upload-area" for="chargerPhoto" style="margin:0">🖼 갤러리 선택</label>
|
||||||
</label>
|
</div>
|
||||||
|
<input type="file" id="chargerPhotoCamera" accept="image/*" capture="environment" style="display:none">
|
||||||
<input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none">
|
<input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none">
|
||||||
<div class="photo-preview" id="chargerPreview"></div>
|
<div class="photo-preview" id="chargerPreview"></div>
|
||||||
<div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div>
|
<div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div>
|
||||||
@@ -254,7 +255,11 @@ body { background: var(--gray1); }
|
|||||||
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
|
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
|
||||||
차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span>
|
차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="upload-area" for="carPhoto">📷 탭하여 촬영하거나 앨범에서 선택</label>
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||||
|
<label class="upload-area" for="carPhotoCamera" style="margin:0">📷 카메라 촬영</label>
|
||||||
|
<label class="upload-area" for="carPhoto" style="margin:0">🖼 갤러리 선택</label>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="carPhotoCamera" accept="image/*" capture="environment" style="display:none">
|
||||||
<input type="file" id="carPhoto" accept="image/*" multiple style="display:none">
|
<input type="file" id="carPhoto" accept="image/*" multiple style="display:none">
|
||||||
<div class="photo-preview" id="carPreview"></div>
|
<div class="photo-preview" id="carPreview"></div>
|
||||||
<div class="photo-info" id="carInfo" style="color:var(--gray4)"></div>
|
<div class="photo-info" id="carInfo" style="color:var(--gray4)"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user