From 585cacfa136cffb7f0ee7377146565b7e32b1f54 Mon Sep 17 00:00:00 2001 From: byun Date: Mon, 1 Jun 2026 08:23:59 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=B6=A9=EC=A0=84=EA=B8=B0=EB=B3=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EB=88=84=EC=A0=81=20=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EC=B0=A8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/stats/charger-error-codes 엔드포인트 추가 (Top 10 충전기 × Top 6 에러코드 stacked bar, 나머지 기타로 합산) - dashboard.html: 에러코드 누적 순위 가로 스택 바 차트 카드 추가 (클릭 시 해당 충전기 신고 목록으로 이동) Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 63 ++++++++++++++++++ frontend/static/pages/admin/dashboard.html | 75 ++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/backend/main.py b/backend/main.py index 18b6f45..8cd4e5d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -540,3 +540,66 @@ def stats_top_chargers(limit: int = 10): ] finally: db.close() + + +@app.get("/api/stats/charger-error-codes") +def stats_charger_error_codes(charger_limit: int = 10, code_limit: int = 6): + """충전기별 에러코드 누적 건수 Top N (에러코드 입력된 신고 기준).""" + from database import SessionLocal + from sqlalchemy import text + from collections import defaultdict + db = SessionLocal() + try: + rows = db.execute(text(""" + SELECT rep.charger_id, + COALESCE(c.station_name, rep.charger_id) AS station_name, + COALESCE(c.name, '') AS charger_name, + TRIM(rep.error_code) AS error_code, + 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 rep.charger_id, c.station_name, c.name, TRIM(rep.error_code) + """)).fetchall() + + if not rows: + return {"chargers": [], "error_codes": []} + + charger_info = {} + code_totals = defaultdict(int) + + for row in rows: + cid, sname, cname, ecode, cnt = row + cnt = int(cnt) + if cid not in charger_info: + charger_info[cid] = {"station_name": sname, "charger_name": cname, + "total": 0, "errors": {}} + charger_info[cid]["total"] += cnt + charger_info[cid]["errors"][ecode] = cnt + code_totals[ecode] += cnt + + top_chargers = sorted(charger_info.items(), key=lambda x: -x[1]["total"])[:charger_limit] + top_codes = [c for c, _ in sorted(code_totals.items(), key=lambda x: -x[1])[:code_limit]] + + result = [] + for cid, info in reversed(top_chargers): # 역순: 차트에서 1위가 위에 + label = info["station_name"] + if info["charger_name"]: + label += f" ({info['charger_name']})" + if len(label) > 22: + label = label[:20] + "…" + errors = info["errors"] + other = sum(cnt for code, cnt in errors.items() if code not in top_codes) + entry = {"charger_id": cid, "label": label, "total": info["total"]} + for code in top_codes: + entry[code] = errors.get(code, 0) + if other: + entry["기타"] = other + result.append(entry) + + has_other = any(r.get("기타", 0) > 0 for r in result) + all_codes = top_codes + (["기타"] if has_other else []) + return {"chargers": result, "error_codes": all_codes} + finally: + db.close() diff --git a/frontend/static/pages/admin/dashboard.html b/frontend/static/pages/admin/dashboard.html index 0a87fad..cbe9555 100644 --- a/frontend/static/pages/admin/dashboard.html +++ b/frontend/static/pages/admin/dashboard.html @@ -227,6 +227,17 @@ + +
+
+
⚡ 충전기별 에러코드 누적 순위 Top 10
+ 에러코드 입력된 신고 기준 +
+
+ +
+
+
🔴 접수 대기 현황 (오래된 순)
@@ -414,6 +425,7 @@ async function load() { ]); loadMonthlyChart(); loadTopChargersChart(); + loadErrorCodesChart(); /* ── 통계 카드 ── */ const over72Class = stats.pending_over_72h > 0 ? 'danger' : 'good'; @@ -624,6 +636,69 @@ async function loadTopChargersChart() { }); } +/* ── 충전기별 에러코드 차트 ── */ +let _errorCodesChart = null; +async function loadErrorCodesChart() { + const data = await API.get('/stats/charger-error-codes'); + const wrap = document.getElementById('errorCodesChartWrap'); + if (!data.chargers || !data.chargers.length) { + wrap.innerHTML = '
에러코드가 입력된 신고가 없습니다.
'; + return; + } + const { chargers, error_codes: codes } = data; + const CODE_COLORS = ['#EF4444','#F59E0B','#3B82F6','#10B981','#8B5CF6','#EC4899','#94A3B8']; + + wrap.style.height = (chargers.length * 32 + 50) + 'px'; + if (_errorCodesChart) _errorCodesChart.destroy(); + const ctx = document.getElementById('errorCodesChart').getContext('2d'); + _errorCodesChart = new Chart(ctx, { + type: 'bar', + data: { + labels: chargers.map(c => c.label), + datasets: codes.map((code, i) => ({ + label: code, + data: chargers.map(c => c[code] || 0), + backgroundColor: CODE_COLORS[i % CODE_COLORS.length], + borderRadius: 3, + stack: 'err', + })) + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + onClick: (evt, elems) => { + if (!elems.length) return; + const c = chargers[elems[0].index]; + location.href = `/pages/admin/reports.html?charger_id=${encodeURIComponent(c.charger_id)}`; + }, + 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 } }, + tooltip: { + callbacks: { + title: items => { + const c = chargers[items[0].dataIndex]; + return c.label + ` (총 ${c.total}건)`; + }, + label: c => `${c.dataset.label}: ${c.raw}건`, + afterLabel: () => '(클릭하면 신고 목록으로)', + } + } + }, + scales: { + x: { stacked: true, grid: { color: '#F1F5F9' }, border: { dash: [3,3] }, + beginAtZero: true, + ticks: { font: { size: 11 }, color: '#64748B', stepSize: 1, + callback: v => Number.isInteger(v) ? v + '건' : '' } }, + y: { stacked: true, grid: { display: false }, + ticks: { font: { size: 12 }, color: '#334155' } } + } + } + }); +} + /* ── 월별/일별 차트 (드릴다운 지원) ── */ let _monthlyChart = null; let _monthlyReportChart = null;