1차완료

This commit is contained in:
byun
2026-06-02 19:34:36 +09:00
parent 9f0f4326fe
commit b6863cd260
28 changed files with 1667 additions and 460 deletions

View File

@@ -70,12 +70,21 @@
.charger-option.selected { background: #EFF6FF; }
.charger-option .opt-name { font-weight: 600; color: var(--navy); }
.charger-option .opt-sub { font-size: 11px; color: var(--gray4); margin-top: 2px; }
.charger-selected-badge {
display: none; margin-top: 6px; padding: 7px 10px;
background: #EFF6FF; border: 1px solid #BFDBFE;
border-radius: 6px; font-size: 12px; color: var(--navy2);
.selected-chargers-list {
display: none; flex-wrap: wrap; gap: 5px;
margin-top: 8px;
}
.charger-selected-badge.show { display: flex; justify-content: space-between; align-items: center; }
.selected-chargers-list.show { display: flex; }
.sel-tag {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; background: #EFF6FF; border: 1px solid #BFDBFE;
border-radius: 20px; font-size: 12px; color: var(--navy2);
}
.sel-tag button {
background: none; border: none; cursor: pointer; color: var(--gray4);
font-size: 13px; line-height: 1; padding: 0 1px;
}
.sel-tag button:hover { color: var(--red); }
/* ── 증상 체크박스 ── */
.issue-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 4px; }
@@ -139,15 +148,31 @@
}
.detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.detail-tab:hover:not(.active) { color: var(--navy2); }
/* ── 대시보드 레이아웃 클래스 ── */
.dash-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:10px; }
.time-metrics-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; }
.dash-chart-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.dash-chart-wrap { position:relative; height:220px; }
.dash-bottom-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:768px) {
.time-metrics-grid { grid-template-columns:repeat(2,1fr); }
.dash-chart-wrap { height:170px; }
.dash-bottom-grid { grid-template-columns:1fr; }
#adminMapWrap { height:260px !important; }
.btn-report-new { font-size:12px; padding:7px 12px; }
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -159,10 +184,11 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<div class="dash-header">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin:0">대시보드</h2>
<button class="btn-report-new" onclick="openReportModal()">+ 신고 접수</button>
</div>
@@ -171,7 +197,7 @@
<!-- 처리 시간 지표 카드 -->
<div class="card" id="timeMetrics" style="margin-bottom:20px">
<div class="card-title">⏱ 처리 시간 지표</div>
<div id="timeMetricsBody" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:14px"></div>
<div id="timeMetricsBody" class="time-metrics-grid"></div>
</div>
<!-- 드릴다운 뒤로가기 -->
@@ -183,7 +209,7 @@
<!-- 월별 처리시간 차트 -->
<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="dash-chart-head">
<div class="card-title" style="margin:0" id="monthlyChartTitle">📈 월별 평균 처리시간</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span id="monthlyChartMode" style="font-size:11px;color:var(--gray4)"></span>
@@ -194,28 +220,28 @@
</div>
</div>
</div>
<div style="position:relative;height:220px">
<div class="dash-chart-wrap">
<canvas id="monthlyChart"></canvas>
</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="dash-chart-head">
<div class="card-title" style="margin:0" id="monthlyReportChartTitle">📊 월별 신고 접수 건수</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#CBD5E1;margin-right:3px"></span>미처리</span>
</div>
</div>
<div style="position:relative;height:220px">
<div class="dash-chart-wrap">
<canvas id="monthlyReportChart"></canvas>
</div>
</div>
<!-- 충전기별 누적 고장 Top 10 -->
<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="dash-chart-head">
<div class="card-title" style="margin:0">🏆 충전기별 누적 고장 건수 Top 10</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
@@ -227,18 +253,32 @@
</div>
</div>
<!-- 충전소별 누적 고장 Top 10 -->
<div class="card" style="margin-bottom:20px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0">🏢 충전소별 누적 고장 건수 Top 10</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#FCA5A5;margin-right:3px"></span>미처리</span>
</div>
</div>
<div style="position:relative" id="topStationsWrap">
<canvas id="topStationsChart"></canvas>
</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="dash-chart-head">
<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 id="errorCodesChartWrap" style="position:relative">
<canvas id="errorCodesChart"></canvas>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="dash-bottom-grid">
<div class="card">
<div class="card-title">🔴 접수 대기 현황 <span id="pendingSort" style="font-size:11px;font-weight:400;color:var(--gray4)">(오래된 순)</span></div>
<div id="recentReports"></div>
@@ -284,10 +324,7 @@
onfocus="openDropdown()" autocomplete="off">
<div class="charger-dropdown" id="chargerDropdown"></div>
</div>
<div class="charger-selected-badge" id="selectedBadge">
<span id="selectedBadgeText"></span>
<button onclick="clearCharger()" style="background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px"></button>
</div>
<div class="selected-chargers-list" id="selectedChargersList"></div>
</div>
<!-- 발생 일시 -->
@@ -311,7 +348,7 @@
</div>
<!-- 신고 범위 -->
<div class="form-row">
<div class="form-row" id="scopeRow">
<label class="form-label">신고 범위</label>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
@@ -320,11 +357,11 @@
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="scope" value="station" style="width:auto;accent-color:var(--accent)">
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">같은 충전소 모든 충전기</span></span>
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">신고 1건으로 충전소 모든 충전기 대상</span></span>
</label>
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
<input type="radio" name="scope" value="type" style="width:auto;accent-color:var(--accent)">
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">— 같은 충전기 모델 전체</span></span>
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">신고 1건으로 같은 모델 전체 대상</span></span>
</label>
</div>
</div>
@@ -396,7 +433,7 @@ Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
let allChargers = [];
let selectedChargerId = null;
let selectedChargers = [];
let cachedIssueTypes = null;
let selectedChargerErrors = [];
@@ -425,6 +462,7 @@ async function load() {
]);
loadMonthlyChart();
loadTopChargersChart();
loadTopStationsChart();
loadErrorCodesChart();
/* ── 통계 카드 ── */
@@ -433,7 +471,7 @@ async function load() {
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html'">
<div class="stat-num">${stats.total}</div><div class="stat-label">전체 신고</div>
</div>
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'">
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'">
<div class="stat-num">${stats.pending}</div><div class="stat-label">접수 대기</div>
</div>
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=in_progress'">
@@ -448,11 +486,11 @@ async function load() {
<div class="stat warn stat-link" onclick="location.href='/pages/admin/improvements.html'">
<div class="stat-num">${stats.improvement_open}</div><div class="stat-label">개선항목 진행중</div>
</div>
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'" style="border-top:3px solid var(--accent)">
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'" style="border-top:3px solid var(--accent)">
<div class="stat-num" style="font-size:22px">${fmtHours(stats.avg_resolution_hours_30d)}</div>
<div class="stat-label">평균 처리 시간<br><small>(최근 30일)</small></div>
</div>
<div class="stat ${over72Class} stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'" style="border-top:3px solid ${stats.pending_over_72h>0?'var(--red)':'var(--green)'}">
<div class="stat ${over72Class} stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'" style="border-top:3px solid ${stats.pending_over_72h>0?'var(--red)':'var(--green)'}">
<div class="stat-num">${stats.pending_over_72h}</div>
<div class="stat-label">72h+ 장기 대기</div>
</div>
@@ -636,13 +674,103 @@ async function loadTopChargersChart() {
});
}
/* ── Top 10 충전소별 누적 고장 차트 ── */
let _topStationsChart = null;
async function loadTopStationsChart() {
const data = await API.get('/stats/top-stations');
if (!data.length) return;
const rev = [...data].reverse();
const labels = rev.map(d => {
const s = d.station_name || '-';
return s.length > 22 ? s.slice(0, 20) + '…' : s;
});
const rowH = 32;
const height = rev.length * rowH + 40;
document.getElementById('topStationsWrap').style.height = height + 'px';
if (_topStationsChart) _topStationsChart.destroy();
const ctx = document.getElementById('topStationsChart').getContext('2d');
_topStationsChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{
label: '처리 완료',
data: rev.map(d => d.done),
backgroundColor: '#3B82F6',
borderRadius: 3,
stack: 'top',
},
{
label: '미처리',
data: rev.map(d => d.active),
backgroundColor: '#FCA5A5',
borderRadius: 3,
stack: 'top',
},
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
onClick: (evt, elems) => {
if (!elems.length) return;
const d = rev[elems[0].index];
location.href = `/pages/admin/reports.html?station_name=${encodeURIComponent(d.station_name)}`;
},
onHover: (evt, elems) => {
evt.native.target.style.cursor = elems.length ? 'pointer' : 'default';
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => rev[items[0].dataIndex].station_name,
label: c => {
const d = rev[c.dataIndex];
return [
`총 누적: ${d.total}`,
`처리 완료: ${d.done}`,
`미처리: ${d.active}`,
];
},
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 _errorCodesChart = null;
async function loadErrorCodesChart() {
const data = await API.get('/stats/charger-error-codes');
const wrap = document.getElementById('errorCodesChartWrap');
if (!data.error_codes || !data.error_codes.length) {
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;
}
const { error_codes } = data;
@@ -655,7 +783,7 @@ async function loadErrorCodesChart() {
labels: error_codes.map(e => e.error_code),
datasets: [{
data: error_codes.map(e => e.total),
backgroundColor: '#1565C0',
backgroundColor: error_codes.map(e => e.error_code === '에러코드 없음' ? '#94A3B8' : '#1565C0'),
borderRadius: 4,
}]
},
@@ -667,7 +795,9 @@ async function loadErrorCodesChart() {
legend: { display: false },
tooltip: {
callbacks: {
title: items => `에러코드: ${error_codes[items[0].dataIndex].error_code}`,
title: items => error_codes[items[0].dataIndex].error_code === '에러코드 없음'
? '에러코드 없음'
: `에러코드: ${error_codes[items[0].dataIndex].error_code}`,
label: c => `누적 ${c.raw}`,
}
}
@@ -983,11 +1113,12 @@ function closeReportModal() {
}
function resetModal() {
selectedChargerId = null;
selectedChargers = [];
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('chargerDropdown').classList.remove('open');
document.getElementById('selectedBadge').classList.remove('show');
renderSelectedChargers();
updateScopeVisibility();
document.getElementById('occurredAt').value = '';
document.getElementById('issueDetail').value = '';
document.getElementById('contact').value = '';
@@ -1016,7 +1147,7 @@ function filterChargers(q) {
: allChargers.slice(0, 50);
dd.innerHTML = filtered.map(c => `
<div class="charger-option ${c.id === selectedChargerId ? 'selected' : ''}"
<div class="charger-option ${selectedChargers.some(s => s.id === c.id) ? 'selected' : ''}"
onclick="selectCharger('${c.id}', '${escHtml(c.station_name)}', '${escHtml(c.name)}', '${escHtml(c.location_detail||'')}')">
<div class="opt-name">${escHtml(c.station_name)} · ${escHtml(c.name)}</div>
<div class="opt-sub">${c.id}${c.location_detail ? ' · ' + escHtml(c.location_detail) : ''}</div>
@@ -1030,18 +1161,59 @@ function openDropdown() {
}
async function selectCharger(id, station, name, region) {
selectedChargerId = id;
if (selectedChargers.some(c => c.id === id)) {
document.getElementById('chargerDropdown').classList.remove('open');
document.getElementById('chargerSearchInput').value = '';
return;
}
selectedChargers.push({ id, station, name, region });
document.getElementById('chargerSearchInput').value = '';
document.getElementById('chargerDropdown').classList.remove('open');
const badge = document.getElementById('selectedBadge');
document.getElementById('selectedBadgeText').textContent =
`${station} · ${name}${region ? ' (' + region + ')' : ''}`;
badge.classList.add('show');
// Load error codes for this charger type
try {
selectedChargerErrors = await API.get('/chargers/' + id + '/errors');
} catch { selectedChargerErrors = []; }
renderErrorCodeUI();
renderSelectedChargers();
updateScopeVisibility();
if (selectedChargers.length === 1) {
try {
selectedChargerErrors = await API.get('/chargers/' + id + '/errors');
} catch { selectedChargerErrors = []; }
renderErrorCodeUI();
} else {
selectedChargerErrors = [];
renderErrorCodeUI();
}
}
function renderSelectedChargers() {
const el = document.getElementById('selectedChargersList');
if (!selectedChargers.length) {
el.innerHTML = '';
el.classList.remove('show');
return;
}
el.classList.add('show');
el.innerHTML = selectedChargers.map(c => `
<div class="sel-tag">
<span>${escHtml(c.station)} · ${escHtml(c.name)}</span>
<button onclick="removeCharger('${escHtml(c.id)}')" title="제거">✕</button>
</div>`).join('');
}
function removeCharger(id) {
selectedChargers = selectedChargers.filter(c => c.id !== id);
renderSelectedChargers();
updateScopeVisibility();
if (selectedChargers.length === 0) {
selectedChargerErrors = [];
renderErrorCodeUI();
} else if (selectedChargers.length === 1) {
API.get('/chargers/' + selectedChargers[0].id + '/errors')
.then(e => { selectedChargerErrors = e; renderErrorCodeUI(); })
.catch(() => { selectedChargerErrors = []; renderErrorCodeUI(); });
}
}
function updateScopeVisibility() {
const row = document.getElementById('scopeRow');
if (row) row.style.display = selectedChargers.length > 1 ? 'none' : '';
}
function renderErrorCodeUI() {
@@ -1075,10 +1247,11 @@ function getModalErrorCode() {
}
function clearCharger() {
selectedChargerId = null;
selectedChargers = [];
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('selectedBadge').classList.remove('show');
renderSelectedChargers();
updateScopeVisibility();
renderErrorCodeUI();
}
@@ -1096,44 +1269,56 @@ function escHtml(s) {
/* ── 신고 제출 ── */
async function submitReport() {
if (!selectedChargerId) { alert('충전기를 선택해주세요.'); return; }
if (!selectedChargers.length) { alert('충전기를 선택해주세요.'); return; }
const issues = [...document.querySelectorAll('.issue-chk:checked')].map(c => c.value);
if (!issues.length) { alert('증상을 하나 이상 선택해주세요.'); return; }
const btn = document.getElementById('submitBtn');
btn.disabled = true; btn.textContent = '접수 중...';
const scope = document.querySelector('input[name="scope"]:checked')?.value || 'single';
const scope = selectedChargers.length === 1
? (document.querySelector('input[name="scope"]:checked')?.value || 'single')
: 'single';
const issueDetail = document.getElementById('issueDetail').value;
const errorCode = getModalErrorCode();
const contact = document.getElementById('contact').value;
const occurredAt = document.getElementById('occurredAt').value;
const ocppLog = document.getElementById('ocppLog').value.trim();
const photos = Array.from(document.getElementById('modalPhoto').files);
const isMulti = selectedChargers.length > 1;
const fd = new FormData();
fd.append('charger_id', selectedChargerId);
fd.append('scope', scope);
fd.append('charger_id', selectedChargers[0].id);
fd.append('scope', isMulti ? 'multi' : scope);
if (isMulti) fd.append('charger_ids', JSON.stringify(selectedChargers.map(c => c.id)));
fd.append('source', 'dashboard');
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('issueDetail').value);
fd.append('error_code', getModalErrorCode());
fd.append('contact', document.getElementById('contact').value);
fd.append('consent', 'false');
const occ = document.getElementById('occurredAt').value;
if (occ) fd.append('occurred_at', occ);
const ocppLogText = document.getElementById('ocppLog').value.trim();
if (ocppLogText) fd.append('ocpp_log', ocppLogText);
Array.from(document.getElementById('modalPhoto').files).forEach(f => fd.append('photos', f));
if (issueDetail) fd.append('issue_detail', issueDetail);
if (errorCode) fd.append('error_code', errorCode);
if (contact) fd.append('contact', contact);
if (occurredAt) fd.append('occurred_at', occurredAt);
if (ocppLog) fd.append('ocpp_log', ocppLog);
photos.forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports/batch', { method: 'POST', body: fd,
headers: { 'Authorization': 'Bearer ' + Auth.token() } });
const res = await fetch('/api/reports/batch', {
method: 'POST', body: fd,
headers: { 'Authorization': 'Bearer ' + Auth.token() },
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
closeReportModal();
load();
if (data.count > 1) {
if (isMulti) {
alert(`${selectedChargers.length}대 충전기 신고가 1건으로 접수되었습니다. (신고 #${data.primary_id})`);
} else if (data.count > 1) {
alert(`신고가 ${data.count}건 접수되었습니다. (첫 번째 신고 #${data.primary_id})`);
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
} else {
alert(`신고가 접수되었습니다. (신고 #${data.primary_id})`);
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
}
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
} catch(e) {
alert('오류: ' + e.message);
} finally {