From e52e916dc80792cf5b5e24ab06a69bd6e0557c3f Mon Sep 17 00:00:00 2001 From: byun Date: Mon, 1 Jun 2026 16:25:48 +0900 Subject: [PATCH] =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20=EB=AA=A8?= =?UTF-8?q?=EB=B0=94=EC=9D=BC=20=EC=82=AC=EC=A7=84=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B9=B4=EB=A9=94=EB=9D=BC/=EA=B0=A4=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EB=B2=84=ED=8A=BC=20=EB=B6=84=EB=A6=AC,=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 79 ++++------------------ frontend/static/js/imageCompress.js | 23 ++++++- frontend/static/pages/admin/dashboard.html | 38 ++++------- frontend/static/pages/mechanic/repair.html | 14 +++- frontend/static/pages/report.html | 15 ++-- 5 files changed, 70 insertions(+), 99 deletions(-) diff --git a/backend/main.py b/backend/main.py index 3f8cbbd..9cf11d2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -543,77 +543,22 @@ def stats_top_chargers(limit: int = 10): @app.get("/api/stats/charger-error-codes") -def stats_charger_error_codes(code_limit: int = 10, charger_limit: int = 5): - """에러코드별 누적 건수 Top N (어느 충전기에서 발생했는지 함께 반환).""" +def stats_charger_error_codes(code_limit: int = 10): + """에러코드별 누적 건수 Top N (단순 순위).""" from database import SessionLocal from sqlalchemy import text - from collections import defaultdict db = SessionLocal() try: rows = db.execute(text(""" - SELECT TRIM(rep.error_code) AS error_code, - rep.charger_id, - COALESCE(c.station_name, rep.charger_id) AS station_name, - COALESCE(c.name, '') AS charger_name, - COUNT(*) AS cnt - FROM reports rep - LEFT JOIN chargers c ON c.id = rep.charger_id - WHERE rep.error_code IS NOT NULL - AND TRIM(rep.error_code) != '' - GROUP BY TRIM(rep.error_code), rep.charger_id, c.station_name, c.name - """)).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 순서 - } + SELECT TRIM(error_code) AS error_code, COUNT(*) AS cnt + FROM reports + WHERE error_code IS NOT NULL AND TRIM(error_code) != '' + GROUP BY TRIM(error_code) + ORDER BY cnt DESC + LIMIT :limit + """), {"limit": code_limit}).fetchall() + # 역순: 차트 Y축에서 1위가 맨 위 + result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)] + return {"error_codes": result} finally: db.close() diff --git a/frontend/static/js/imageCompress.js b/frontend/static/js/imageCompress.js index 870539e..afe5c30 100755 --- a/frontend/static/js/imageCompress.js +++ b/frontend/static/js/imageCompress.js @@ -170,5 +170,26 @@ const ImageCompressor = (() => { : (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 }; })(); diff --git a/frontend/static/pages/admin/dashboard.html b/frontend/static/pages/admin/dashboard.html index 9574bd1..6a25a1e 100644 --- a/frontend/static/pages/admin/dashboard.html +++ b/frontend/static/pages/admin/dashboard.html @@ -231,7 +231,7 @@
⚠️ 에러코드 누적 순위 Top 10
- 에러코드 입력된 신고 기준 · 색상 = 충전기 + 에러코드 입력된 신고 기준
@@ -645,48 +645,38 @@ async function loadErrorCodesChart() { wrap.innerHTML = '
에러코드가 입력된 신고가 없습니다.
'; return; } - const { error_codes, charger_labels, dataset_keys } = data; - const CHARGER_COLORS = ['#3B82F6','#EF4444','#F59E0B','#10B981','#8B5CF6','#94A3B8']; - - wrap.style.height = (error_codes.length * 32 + 50) + 'px'; + const { error_codes } = data; + wrap.style.height = (error_codes.length * 34 + 40) + 'px'; if (_errorCodesChart) _errorCodesChart.destroy(); const ctx = document.getElementById('errorCodesChart').getContext('2d'); _errorCodesChart = new Chart(ctx, { type: 'bar', data: { labels: error_codes.map(e => e.error_code), - datasets: dataset_keys.map((key, i) => ({ - label: charger_labels[key], - data: error_codes.map(e => e[key] || 0), - backgroundColor: CHARGER_COLORS[i % CHARGER_COLORS.length], - borderRadius: 3, - stack: 'err', - })) + datasets: [{ + data: error_codes.map(e => e.total), + backgroundColor: '#1565C0', + borderRadius: 4, + }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, - onHover: (evt, elems) => { evt.native.target.style.cursor = elems.length ? 'pointer' : 'default'; }, plugins: { - legend: { display: true, position: 'top', - labels: { font: { size: 11 }, boxWidth: 12, boxHeight: 12, padding: 12 } }, + legend: { display: false }, tooltip: { callbacks: { - title: items => { - const e = error_codes[items[0].dataIndex]; - return `에러코드: ${e.error_code} (총 ${e.total}건)`; - }, - label: c => `${c.dataset.label}: ${c.raw}건`, + title: items => `에러코드: ${error_codes[items[0].dataIndex].error_code}`, + label: c => `누적 ${c.raw}건`, } } }, scales: { - x: { stacked: true, grid: { color: '#F1F5F9' }, border: { dash: [3,3] }, - beginAtZero: true, - ticks: { font: { size: 11 }, color: '#64748B', stepSize: 1, + x: { beginAtZero: true, grid: { color: '#F1F5F9' }, border: { dash: [3,3] }, + ticks: { font: { size: 11 }, color: '#64748B', callback: v => Number.isInteger(v) ? v + '건' : '' } }, - y: { stacked: true, grid: { display: false }, + y: { grid: { display: false }, ticks: { font: { size: 12 }, color: '#334155' } } } } diff --git a/frontend/static/pages/mechanic/repair.html b/frontend/static/pages/mechanic/repair.html index 78e7dab..15a7041 100644 --- a/frontend/static/pages/mechanic/repair.html +++ b/frontend/static/pages/mechanic/repair.html @@ -67,14 +67,22 @@
- +
+ + +
+
- +
+ + +
+
@@ -309,6 +317,8 @@ navigator.geolocation?.getCurrentPosition( // 이미지 압축 + 다중 선택 프리뷰 ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore'); ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter'); +ImageCompressor.setupCameraAppend('photosBeforeCamera', 'photosBefore'); +ImageCompressor.setupCameraAppend('photosAfterCamera', 'photosAfter'); async function submitForm(isDone) { const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value); diff --git a/frontend/static/pages/report.html b/frontend/static/pages/report.html index b5d6b53..cf9affd 100644 --- a/frontend/static/pages/report.html +++ b/frontend/static/pages/report.html @@ -242,10 +242,11 @@ body { background: var(--gray1); } 충전기 사진 *필수 (여러 장 선택 가능) - +
+ + +
+
@@ -254,7 +255,11 @@ body { background: var(--gray1); } - +
+ + +
+