대시보드 — 충전기별 에러코드 누적 순위 차트 추가

- /api/stats/charger-error-codes 엔드포인트 추가
  (Top 10 충전기 × Top 6 에러코드 stacked bar, 나머지 기타로 합산)
- dashboard.html: 에러코드 누적 순위 가로 스택 바 차트 카드 추가
  (클릭 시 해당 충전기 신고 목록으로 이동)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byun
2026-06-01 08:23:59 +09:00
parent 7ab8a5065e
commit 585cacfa13
2 changed files with 138 additions and 0 deletions

View File

@@ -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()

View File

@@ -227,6 +227,17 @@
</div>
</div>
<!-- 충전기별 에러코드 누적 순위 -->
<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 class="card-title" style="margin:0">⚡ 충전기별 에러코드 누적 순위 Top 10</div>
<span style="font-size:11px;color:var(--gray4)">에러코드 입력된 신고 기준</span>
</div>
<div id="errorCodesChartWrap" style="position:relative">
<canvas id="errorCodesChart"></canvas>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="card">
<div class="card-title">🔴 접수 대기 현황 <span id="pendingSort" style="font-size:11px;font-weight:400;color:var(--gray4)">(오래된 순)</span></div>
@@ -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 = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
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;