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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:23:59 +09:00

1257 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>관리자 대시보드</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
/* ── stat 카드 클릭 ── */
.stat-link {
cursor: pointer;
transition: transform .12s, box-shadow .12s;
user-select: none;
}
.stat-link:hover { transform: translateY(-2px); box-shadow: 0 4px 14px rgba(0,0,0,.10); }
.stat-link:active { transform: translateY(0); box-shadow: none; }
/* ── 신고 모달 ── */
.modal-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.5); z-index: 1000;
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal-box {
background: white; border-radius: 12px;
width: 520px; max-width: calc(100vw - 32px);
max-height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.modal-head {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--gray2);
display: flex; justify-content: space-between; align-items: center;
}
.modal-head h3 { font-size: 16px; font-weight: 700; color: var(--navy); }
.modal-close {
width: 28px; height: 28px; border-radius: 50%; border: none;
background: var(--gray2); cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.modal-body { padding: 18px 20px; overflow-y: auto; flex: 1; }
.modal-foot {
padding: 14px 20px;
border-top: 1px solid var(--gray2);
display: flex; justify-content: flex-end; gap: 10px;
}
/* ── 충전기 검색 드롭다운 ── */
.charger-search-wrap { position: relative; }
.charger-search-input {
width: 100%; padding: 9px 12px; border: 1px solid var(--gray3);
border-radius: 7px; font-size: 13px; box-sizing: border-box;
outline: none;
}
.charger-search-input:focus { border-color: var(--accent); }
.charger-dropdown {
display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: white; border: 1px solid var(--gray3); border-radius: 7px;
max-height: 220px; overflow-y: auto; z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,.12);
}
.charger-dropdown.open { display: block; }
.charger-option {
padding: 9px 12px; cursor: pointer; font-size: 13px;
border-bottom: 1px solid var(--gray1);
}
.charger-option:last-child { border-bottom: none; }
.charger-option:hover { background: var(--gray1); }
.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);
}
.charger-selected-badge.show { display: flex; justify-content: space-between; align-items: center; }
/* ── 증상 체크박스 ── */
.issue-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 4px; }
.issue-chk { display: none; }
.issue-label {
display: block; padding: 9px 10px; border: 1px solid var(--gray3);
border-radius: 7px; font-size: 13px; cursor: pointer; text-align: center;
transition: all .15s;
}
.issue-chk:checked + .issue-label {
background: var(--accent); color: white; border-color: var(--accent);
font-weight: 600;
}
/* ── 폼 공통 ── */
.form-row { margin-bottom: 14px; }
.form-label {
display: block; font-size: 12px; font-weight: 600;
color: var(--navy2); margin-bottom: 5px;
}
.form-label .req { color: var(--orange); margin-left: 2px; }
.form-input, .form-textarea {
width: 100%; padding: 9px 12px; border: 1px solid var(--gray3);
border-radius: 7px; font-size: 13px; box-sizing: border-box; outline: none;
font-family: inherit;
}
.form-input:focus, .form-textarea:focus { border-color: var(--accent); }
.form-textarea { resize: vertical; min-height: 72px; }
/* ── 관리자 지도 ── */
#adminMapWrap {
height: 420px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
}
#adminMap { width: 100%; height: 100%; }
.adm-pin {
width: 28px; height: 28px; border-radius: 50% 50% 50% 0;
transform: rotate(-45deg); border: 3px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,.35);
}
.adm-pin.pending { background: #EF4444; }
.adm-pin.in_progress { background: #F59E0B; }
.adm-pin.multi { background: #7C3AED; }
/* ── 신고하기 버튼 */
.btn-report-new {
background: var(--accent); color: white; border: none;
padding: 8px 16px; border-radius: 7px; font-size: 13px;
font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px;
}
.btn-report-new:hover { background: var(--navy); }
/* ── 일별 상세 탭 ── */
.detail-tab {
padding: 10px 16px; background: none; border: none; cursor: pointer;
font-size: 13px; font-weight: 600; color: var(--gray4);
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color .15s;
}
.detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.detail-tab:hover:not(.active) { color: var(--navy2); }
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<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/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin:0">대시보드</h2>
<button class="btn-report-new" onclick="openReportModal()">+ 신고 접수</button>
</div>
<div class="stats" id="stats"></div>
<!-- 처리 시간 지표 카드 -->
<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>
<!-- 드릴다운 뒤로가기 -->
<div id="chartDrillBack" style="display:none;margin-bottom:10px">
<button onclick="drillBack()" style="display:flex;align-items:center;gap:6px;padding:7px 14px;border:1px solid var(--gray3);border-radius:7px;background:white;cursor:pointer;font-size:13px;color:var(--navy2);font-weight:600">
← 월별 보기
</button>
</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" 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>
<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:#22C55E;margin-right:3px"></span>24h 이내</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#F59E0B;margin-right:3px"></span>2472h</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#EF4444;margin-right:3px"></span>72h 초과</span>
</div>
</div>
</div>
<div style="position:relative;height:220px">
<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="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">
<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="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="topChargersWrap">
<canvas id="topChargersChart"></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="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>
<div id="recentReports"></div>
</div>
<div class="card">
<div class="card-title">💰 출장비 미처리 현황</div>
<div id="costPending"></div>
</div>
</div>
<!-- 신고 위치 지도 -->
<div class="card" style="margin-top:20px;padding-bottom:0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px">
<div class="card-title" style="margin:0">🗺 신고 접수 위치 현황</div>
<div style="display:flex;gap:12px;font-size:12px;align-items:center;flex-wrap:wrap">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>접수 대기</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#F59E0B;margin-right:4px"></span>처리중</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#7C3AED;margin-right:4px"></span>복수 신고</span>
<span id="mapNoGps" style="color:var(--gray4)"></span>
</div>
</div>
<div id="adminMapWrap"><div id="adminMap"></div></div>
</div>
</div>
</div>
<!-- ── 신고 접수 모달 ── -->
<div class="modal-overlay" id="reportModal">
<div class="modal-box">
<div class="modal-head">
<h3>신고 접수</h3>
<button class="modal-close" onclick="closeReportModal()"></button>
</div>
<div class="modal-body">
<!-- 충전기 선택 -->
<div class="form-row">
<label class="form-label">충전기 선택 <span class="req">*</span></label>
<div class="charger-search-wrap">
<input type="text" class="charger-search-input" id="chargerSearchInput"
placeholder="충전소명 또는 충전기 ID 검색..."
oninput="filterChargers(this.value)"
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>
<!-- 발생 일시 -->
<div class="form-row">
<label class="form-label">발생 일시</label>
<input type="datetime-local" class="form-input" id="occurredAt">
</div>
<!-- 증상 선택 -->
<div class="form-row">
<label class="form-label">증상 <span class="req">*</span></label>
<div class="issue-grid" id="issueGrid"></div>
</div>
<!-- 에러 코드 -->
<div class="form-row">
<label class="form-label">에러 코드</label>
<div id="errorCodeWrap">
<input type="text" class="form-input" id="errorCodeText" placeholder="예) E101, Fault_0x02 ...">
</div>
</div>
<!-- 신고 범위 -->
<div class="form-row">
<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">
<input type="radio" name="scope" value="single" checked style="width:auto;accent-color:var(--accent)">
<span><strong>이 충전기만</strong></span>
</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>
</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>
</label>
</div>
</div>
<!-- 상세 내용 -->
<div class="form-row">
<label class="form-label">상세 내용</label>
<textarea class="form-textarea" id="issueDetail" placeholder="고장 상세 내용을 입력하세요"></textarea>
</div>
<!-- 연락처 -->
<div class="form-row">
<label class="form-label">신고자 연락처 <span style="color:var(--gray4);font-weight:400">(선택)</span></label>
<input type="text" class="form-input" id="contact" placeholder="010-0000-0000">
</div>
<!-- OCPP 로그 -->
<div class="form-row">
<label class="form-label">OCPP 로그 <span style="color:var(--gray4);font-weight:400">(선택 · 붙여넣기 또는 파일)</span></label>
<textarea class="form-textarea" id="ocppLog" rows="4"
placeholder="OCPP 통신 로그를 여기에 붙여넣거나, 아래에서 파일을 선택하세요.&#10;예) [2,&quot;...&quot;,&quot;StatusNotification&quot;,{...}]"></textarea>
<label style="display:flex;align-items:center;gap:8px;margin-top:6px;cursor:pointer;font-size:12px;color:var(--blue);">
<input type="file" id="ocppLogFile" accept=".txt,.csv,.log" style="display:none" onchange="readOcppFile(this)">
📄 .txt / .csv / .log 파일 선택
<span id="ocppFileName" style="color:var(--gray4);font-weight:400"></span>
</label>
</div>
<!-- 사진 첨부 -->
<div class="form-row" style="margin-bottom:0">
<label class="form-label">사진 첨부 <span style="color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span></label>
<label class="upload-area" for="modalPhoto" style="padding:12px;font-size:13px;">📷 탭하여 촬영하거나 앨범에서 선택</label>
<input type="file" id="modalPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="modalPhotoPreview"></div>
<div class="photo-info" id="modalPhotoInfo" style="color:var(--gray4)"></div>
</div>
</div>
<div class="modal-foot">
<button onclick="closeReportModal()" style="padding:8px 18px;border:1px solid var(--gray3);border-radius:7px;background:white;cursor:pointer;font-size:13px">취소</button>
<button onclick="submitReport()" id="submitBtn"
style="padding:8px 20px;border:none;border-radius:7px;background:var(--accent);color:white;font-size:13px;font-weight:600;cursor:pointer">
접수하기
</button>
</div>
</div>
</div>
<!-- ── 일별 상세 모달 ── -->
<div class="modal-overlay" id="dailyDetailModal">
<div class="modal-box" style="width:620px">
<div class="modal-head">
<h3 id="dailyDetailTitle"></h3>
<button class="modal-close" onclick="closeDailyDetail()"></button>
</div>
<div style="display:flex;border-bottom:1px solid var(--gray2);padding:0 20px;flex-shrink:0">
<button id="tabRepairs" class="detail-tab active" onclick="switchDailyTab('repairs')">처리 완료</button>
<button id="tabReports" class="detail-tab" onclick="switchDailyTab('reports')">신고 접수</button>
</div>
<div class="modal-body" id="dailyDetailBody" style="padding-top:8px"></div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script><script src="/js/imageCompress.js"></script>
<script>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
let allChargers = [];
let selectedChargerId = null;
let cachedIssueTypes = null;
let selectedChargerErrors = [];
/* ── 시간 포맷 헬퍼 ── */
function fmtHours(h) {
if (h === null || h === undefined) return '-';
if (h < 1) return Math.round(h * 60) + '분';
if (h < 24) return Math.round(h) + '시간';
const days = h / 24;
return days < 2 ? Math.round(h) + '시간' : days.toFixed(1) + '일';
}
function pendingAgeBadge(reportedAt) {
if (!reportedAt) return '';
const h = (Date.now() - new Date(reportedAt)) / 3600000;
if (h >= 72) return `<span style="background:#FEE2E2;color:#C0392B;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:700;white-space:nowrap">⚠ ${Math.floor(h)}h 대기</span>`;
if (h >= 24) return `<span style="background:#FEF3C7;color:#B45309;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:700;white-space:nowrap">⏰ ${Math.floor(h)}h 대기</span>`;
return `<span style="background:#F0FDF4;color:#166534;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:700;white-space:nowrap">${Math.floor(h)}h</span>`;
}
async function load() {
const [stats, reports, costs] = await Promise.all([
API.get('/stats'),
API.get('/reports?active_only=true'), // 미처리 전체 (오래된 순 정렬용)
API.get('/costs?cost_status=pending'),
]);
loadMonthlyChart();
loadTopChargersChart();
loadErrorCodesChart();
/* ── 통계 카드 ── */
const over72Class = stats.pending_over_72h > 0 ? 'danger' : 'good';
document.getElementById('stats').innerHTML = `
<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-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'">
<div class="stat-num">${stats.in_progress}</div><div class="stat-label">처리중</div>
</div>
<div class="stat good stat-link" onclick="location.href='/pages/admin/reports.html?status=done'">
<div class="stat-num">${stats.done}</div><div class="stat-label">완료</div>
</div>
<div class="stat danger stat-link" onclick="location.href='/pages/admin/costs.html'">
<div class="stat-num">${stats.cost_pending}</div><div class="stat-label">출장비 미처리</div>
</div>
<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-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-num">${stats.pending_over_72h}</div>
<div class="stat-label">72h+ 장기 대기</div>
</div>
`;
/* ── 처리 시간 지표 카드 ── */
const avgColor30 = stats.avg_resolution_hours_30d === null ? 'var(--gray4)'
: stats.avg_resolution_hours_30d > 72 ? 'var(--red)' : stats.avg_resolution_hours_30d > 24 ? 'var(--orange)' : 'var(--green)';
const avgColor7 = stats.avg_resolution_hours_7d === null ? 'var(--gray4)'
: stats.avg_resolution_hours_7d > 72 ? 'var(--red)' : stats.avg_resolution_hours_7d > 24 ? 'var(--orange)' : 'var(--green)';
const longestColor = stats.longest_pending_hours > 72 ? 'var(--red)' : stats.longest_pending_hours > 24 ? 'var(--orange)' : 'var(--green)';
const baseLabel = stats.time_metric_base === 'reported' ? '등록→완료' : '발생→완료';
const worktimeTag = stats.time_metric_worktime === 'worktime'
? '<span style="font-size:10px;background:#E0F2FE;color:#0369A1;padding:1px 6px;border-radius:8px;margin-left:4px;font-weight:700">업무시간</span>'
: stats.time_metric_worktime === 'holiday_24h'
? '<span style="font-size:10px;background:#FEF9C3;color:#713F12;padding:1px 6px;border-radius:8px;margin-left:4px;font-weight:700">공휴일제외</span>'
: '';
const timeLabel = baseLabel + ' 평균';
document.getElementById('timeMetricsBody').innerHTML = `
<div style="text-align:center;padding:14px;background:var(--gray1);border-radius:10px">
<div style="font-size:26px;font-weight:900;color:${avgColor30}">${fmtHours(stats.avg_resolution_hours_30d)}</div>
<div style="font-size:12px;color:var(--gray4);margin-top:4px">${timeLabel}${worktimeTag}<br><strong>최근 30일</strong></div>
</div>
<div style="text-align:center;padding:14px;background:var(--gray1);border-radius:10px">
<div style="font-size:26px;font-weight:900;color:${avgColor7}">${fmtHours(stats.avg_resolution_hours_7d)}</div>
<div style="font-size:12px;color:var(--gray4);margin-top:4px">${timeLabel}${worktimeTag}<br><strong>최근 7일</strong></div>
</div>
<div style="text-align:center;padding:14px;background:var(--gray1);border-radius:10px">
<div style="font-size:26px;font-weight:900;color:${longestColor}">${fmtHours(stats.longest_pending_hours)}</div>
<div style="font-size:12px;color:var(--gray4);margin-top:4px">현재 최장 대기${worktimeTag}<br><strong>(미처리 신고)</strong></div>
</div>
<div style="padding:14px;background:var(--gray1);border-radius:10px">
<div style="font-size:12px;color:var(--gray4);margin-bottom:8px;font-weight:600">대기 시간 분포${worktimeTag}</div>
<div style="display:flex;flex-direction:column;gap:5px;font-size:12px">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="color:#166534">● 24h 이내</span>
<strong>${stats.pending - stats.pending_over_24h}건</strong>
</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="color:#B45309">● 24~72h</span>
<strong>${stats.pending_over_24h - stats.pending_over_72h}건</strong>
</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="color:#C0392B">● 72h 초과</span>
<strong style="color:${stats.pending_over_72h>0?'var(--red)':'inherit'}">${stats.pending_over_72h}건</strong>
</div>
</div>
</div>
`;
/* ── 신고 위치 지도 ── */
const activeReports = [...reports].sort((a, b) => new Date(a.reported_at) - new Date(b.reported_at));
renderAdminMap(activeReports);
document.getElementById('recentReports').innerHTML = activeReports.slice(0, 10).map(r => `
<div onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'"
style="padding:9px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:8px">
<div style="min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<strong>#${r.id}</strong>
<small style="color:var(--gray4)">${r.charger_id}</small>
${pendingAgeBadge(r.reported_at)}
</div>
<div style="font-size:12px;color:var(--gray4);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${r.station_name||''} · ${(r.issue_types||[]).join(', ')}</div>
</div>
<div style="text-align:right;flex-shrink:0">
${Auth.statusBadge(r.status)}
<div style="font-size:11px;color:var(--gray4);margin-top:2px">${Auth.fmtDt(r.reported_at)}</div>
</div>
</div>`).join('') || '<div style="color:var(--gray4);font-size:13px">미처리 신고가 없습니다.</div>';
/* ── 출장비 미처리 ── */
document.getElementById('costPending').innerHTML = costs.slice(0,8).map(c => `
<div onclick="location.href='/pages/admin/report-detail.html?repair_id=${c.repair_id}'"
style="padding:9px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${c.charger_id||'-'}</strong> <small style="color:var(--gray4)">${c.station_name||''}</small>
<div style="font-size:12px;color:var(--text2)">${c.mechanic_name||''} (${c.mechanic_company||''})</div>
</div>
<div style="text-align:right">
${Auth.costStatusBadge(c.cost_status)}
<div style="font-size:12px;color:var(--orange);font-weight:700;margin-top:2px">${(c.cost_amount||0).toLocaleString()}원</div>
</div>
</div>`).join('') || '<div style="color:var(--gray4);font-size:13px">미처리 출장비가 없습니다.</div>';
}
/* ── Top 10 충전기 누적 고장 차트 ── */
let _topChargersChart = null;
async function loadTopChargersChart() {
const data = await API.get('/stats/top-chargers');
if (!data.length) return;
// 데이터는 내림차순 → 차트에서 위쪽이 1위가 되도록 역순
const rev = [...data].reverse();
const labels = rev.map(d => {
const s = d.station_name || d.charger_id;
const n = d.charger_name ? ` (${d.charger_name})` : '';
const full = s + n;
return full.length > 22 ? full.slice(0, 20) + '…' : full;
});
const rowH = 32;
const height = rev.length * rowH + 40;
document.getElementById('topChargersWrap').style.height = height + 'px';
if (_topChargersChart) _topChargersChart.destroy();
const ctx = document.getElementById('topChargersChart').getContext('2d');
_topChargersChart = 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?charger_id=${encodeURIComponent(d.charger_id)}`;
},
onHover: (evt, elems) => {
evt.native.target.style.cursor = elems.length ? 'pointer' : 'default';
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => {
const d = rev[items[0].dataIndex];
return (d.station_name || d.charger_id) + (d.charger_name ? ` · ${d.charger_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.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;
let _drillMonth = null;
let _monthlyData = null;
async function loadMonthlyChart() {
const res = await API.get('/stats/monthly');
_monthlyData = res;
_drillMonth = null;
_renderBothCharts(res.data, res.time_metric_worktime, false);
}
async function drillDown(month) {
_drillMonth = month;
const res = await API.get('/stats/daily?month=' + month);
_renderBothCharts(res.data, res.time_metric_worktime, true);
const [y, m] = month.split('-');
document.getElementById('chartDrillBack').style.display = 'block';
document.getElementById('monthlyChartTitle').textContent = `📈 ${y}${parseInt(m)}월 일별 평균 처리시간`;
document.getElementById('monthlyReportChartTitle').textContent = `📊 ${y}${parseInt(m)}월 일별 신고 접수 건수`;
}
function drillBack() {
_drillMonth = null;
_renderBothCharts(_monthlyData.data, _monthlyData.time_metric_worktime, false);
document.getElementById('chartDrillBack').style.display = 'none';
document.getElementById('monthlyChartTitle').textContent = '📈 월별 평균 처리시간';
document.getElementById('monthlyReportChartTitle').textContent = '📊 월별 신고 접수 건수';
}
/* ── 일별 상세 모달 ── */
let _dailyDetailData = null;
let _dailyDetailTab = 'repairs';
async function openDailyDetail(day, tab) {
_dailyDetailTab = tab;
const [y, m, d] = day.split('-');
document.getElementById('dailyDetailTitle').textContent =
`${y}${parseInt(m)}${parseInt(d)}일 상세 내역`;
document.getElementById('dailyDetailBody').innerHTML =
'<div style="text-align:center;padding:30px;color:var(--gray4)">불러오는 중...</div>';
document.getElementById('dailyDetailModal').classList.add('open');
try {
_dailyDetailData = await API.get('/stats/daily/detail?day=' + day);
_renderDailyDetailTab();
} catch(e) {
document.getElementById('dailyDetailBody').innerHTML =
`<div style="color:var(--red);padding:20px">오류: ${e.message}</div>`;
}
}
function closeDailyDetail() {
document.getElementById('dailyDetailModal').classList.remove('open');
}
function switchDailyTab(tab) {
_dailyDetailTab = tab;
_renderDailyDetailTab();
}
function _renderDailyDetailTab() {
const tab = _dailyDetailTab;
const repairs = _dailyDetailData.repairs;
const reports = _dailyDetailData.reports;
document.getElementById('tabRepairs').textContent = `처리 완료 (${repairs.length}건)`;
document.getElementById('tabReports').textContent = `신고 접수 (${reports.length}건)`;
document.getElementById('tabRepairs').classList.toggle('active', tab === 'repairs');
document.getElementById('tabReports').classList.toggle('active', tab === 'reports');
const body = document.getElementById('dailyDetailBody');
if (tab === 'repairs') {
if (!repairs.length) {
body.innerHTML = '<div style="color:var(--gray4);font-size:13px;padding:10px 0">처리 완료 내역이 없습니다.</div>';
return;
}
body.innerHTML = repairs.map(r => {
const hColor = r.processing_hours === null ? 'var(--gray4)'
: r.processing_hours <= 24 ? 'var(--green)'
: r.processing_hours <= 72 ? 'var(--orange)' : 'var(--red)';
return `
<div onclick="location.href='/pages/admin/report-detail.html?id=${r.report_id}'"
style="padding:10px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:8px">
<div style="min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<strong>${escHtml(r.charger_id)}</strong>
<small style="color:var(--gray4)">${escHtml(r.station_name)}</small>
</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${r.issue_types.length ? r.issue_types.join(', ') : '-'}
${r.mechanic_name ? ' · ' + escHtml(r.mechanic_name) : ''}
</div>
</div>
<div style="text-align:right;flex-shrink:0">
<div style="font-size:16px;font-weight:800;color:${hColor}">${r.processing_hours !== null ? fmtHours(r.processing_hours) : '-'}</div>
<div style="font-size:11px;color:var(--gray4)">처리시간</div>
</div>
</div>`;
}).join('');
} else {
if (!reports.length) {
body.innerHTML = '<div style="color:var(--gray4);font-size:13px;padding:10px 0">신고 접수 내역이 없습니다.</div>';
return;
}
body.innerHTML = reports.map(r => `
<div onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'"
style="padding:10px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:8px">
<div style="min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<strong>#${r.id}</strong>
<small style="color:var(--gray4)">${escHtml(r.charger_id)}</small>
<small style="color:var(--gray3)">|</small>
<small>${escHtml(r.station_name)}</small>
</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${r.issue_types.length ? r.issue_types.join(', ') : '-'}
</div>
</div>
<div style="text-align:right;flex-shrink:0">
${Auth.statusBadge(r.status)}
<div style="font-size:11px;color:var(--gray4);margin-top:3px">${Auth.fmtDt(r.reported_at)}</div>
</div>
</div>`).join('');
}
}
function _renderBothCharts(data, mode, isDrill) {
_renderTimeChart(data, mode, isDrill);
_renderReportChart(data, mode, isDrill);
}
function _getLabel(d, isDrill) {
if (isDrill) return parseInt(d.day.slice(8)) + '일';
const [y, m] = d.month.split('-');
return `${y.slice(2)}.${m}`;
}
function _getTitle(d, isDrill) {
if (isDrill) {
const [y, m, day] = d.day.split('-');
return `${y}${parseInt(m)}${parseInt(day)}`;
}
const [y, m] = d.month.split('-');
return `${y}${parseInt(m)}`;
}
function _renderTimeChart(data, mode, isDrill) {
const modeLabel = mode === 'worktime' ? '업무시간 기준'
: mode === 'holiday_24h' ? '공휴일 제외 24h 기준'
: '달력 기준';
document.getElementById('monthlyChartMode').textContent = modeLabel;
const bgColors = data.map(d => {
if (d.avg_hours === null || d.count === 0) return '#E2E8F0';
if (d.avg_hours <= 24) return '#22C55E';
if (d.avg_hours <= 72) return '#F59E0B';
return '#EF4444';
});
if (_monthlyChart) _monthlyChart.destroy();
const ctx = document.getElementById('monthlyChart').getContext('2d');
_monthlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => _getLabel(d, isDrill)),
datasets: [{
data: data.map(d => d.avg_hours),
backgroundColor: bgColors,
borderRadius: 4,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
onClick: (evt, elems) => {
if (!elems.length) return;
if (isDrill) openDailyDetail(data[elems[0].index].day, 'repairs');
else drillDown(data[elems[0].index].month);
},
onHover: (evt, elems) => {
evt.native.target.style.cursor = elems.length ? 'pointer' : 'default';
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => _getTitle(data[items[0].dataIndex], isDrill),
label: c => {
const d = data[c.dataIndex];
const hint = isDrill ? ' (클릭하면 상세 보기)' : ' (클릭하면 일별 보기)';
if (!d.count) return `처리 완료 없음${isDrill ? '' : hint}`;
const h = d.avg_hours;
const t = h < 1 ? `${Math.round(h * 60)}`
: h < 48 ? `${h.toFixed(1)}시간`
: `${(h / 24).toFixed(1)}일 (${h.toFixed(1)}h)`;
return [`평균 처리시간: ${t}`, `처리 완료: ${d.count}${hint}`];
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 11 }, color: '#64748B', maxRotation: isDrill ? 45 : 0 }
},
y: {
grid: { color: '#F1F5F9' },
border: { dash: [3, 3] },
beginAtZero: true,
ticks: {
font: { size: 11 }, color: '#64748B',
callback: v => v === 0 ? '0' : v >= 48 ? `${(v/24).toFixed(0)}` : `${v}h`
}
}
}
}
});
}
function _renderReportChart(data, mode, isDrill) {
if (_monthlyReportChart) _monthlyReportChart.destroy();
const rCtx = document.getElementById('monthlyReportChart').getContext('2d');
_monthlyReportChart = new Chart(rCtx, {
type: 'bar',
data: {
labels: data.map(d => _getLabel(d, isDrill)),
datasets: [
{ label: '처리 완료', data: data.map(d => d.report_done),
backgroundColor: '#3B82F6', borderRadius: 4, stack: 'report' },
{ label: '미처리', data: data.map(d => d.report_total - d.report_done),
backgroundColor: '#CBD5E1', borderRadius: 4, stack: 'report' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
onClick: (evt, elems) => {
if (!elems.length) return;
if (isDrill) openDailyDetail(data[elems[0].index].day, 'reports');
else drillDown(data[elems[0].index].month);
},
onHover: (evt, elems) => {
evt.native.target.style.cursor = elems.length ? 'pointer' : 'default';
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => _getTitle(data[items[0].dataIndex], isDrill),
footer: items => {
const d = data[items[0].dataIndex];
if (!d.report_total) return isDrill ? '' : '클릭하면 일별 보기';
const rate = Math.round(d.report_done / d.report_total * 100);
const hint = isDrill ? '\n클릭하면 상세 보기' : '\n클릭하면 일별 보기';
return `${d.report_total}건 (완료율 ${rate}%)${hint}`;
}
}
}
},
scales: {
x: { stacked: true, grid: { display: false },
ticks: { font: { size: 11 }, color: '#64748B', maxRotation: isDrill ? 45 : 0 } },
y: { 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 + '건' : '' } }
}
}
});
}
load();
/* ── 모달 ── */
async function openReportModal() {
if (!allChargers.length || !cachedIssueTypes) {
[allChargers, cachedIssueTypes] = await Promise.all([
allChargers.length ? Promise.resolve(allChargers) : API.get('/chargers'),
cachedIssueTypes ? Promise.resolve(cachedIssueTypes) : API.get('/settings/issue-types'),
]);
}
renderIssueGrid();
resetModal();
document.getElementById('reportModal').classList.add('open');
document.getElementById('chargerSearchInput').focus();
}
function renderIssueGrid() {
document.getElementById('issueGrid').innerHTML = cachedIssueTypes.map((t, i) => `
<div><input class="issue-chk" type="checkbox" id="ig${i}" value="${escHtml(t.key)}">
<label class="issue-label" for="ig${i}">${escHtml(t.label)}</label></div>`).join('');
}
function closeReportModal() {
document.getElementById('reportModal').classList.remove('open');
}
function resetModal() {
selectedChargerId = null;
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('chargerDropdown').classList.remove('open');
document.getElementById('selectedBadge').classList.remove('show');
document.getElementById('occurredAt').value = '';
document.getElementById('issueDetail').value = '';
document.getElementById('contact').value = '';
document.getElementById('ocppLog').value = '';
document.getElementById('ocppLogFile').value = '';
document.getElementById('ocppFileName').textContent = '';
document.getElementById('modalPhoto').value = '';
document.getElementById('modalPhotoPreview').innerHTML = '';
document.getElementById('modalPhotoInfo').textContent = '';
document.querySelectorAll('.issue-chk').forEach(c => c.checked = false);
document.querySelectorAll('input[name="scope"]')[0].checked = true;
renderErrorCodeUI(); // reset to plain text input
}
/* ── 충전기 검색 드롭다운 ── */
function filterChargers(q) {
const dd = document.getElementById('chargerDropdown');
q = q.trim().toLowerCase();
const filtered = q
? allChargers.filter(c =>
c.station_name.toLowerCase().includes(q) ||
c.name.toLowerCase().includes(q) ||
c.id.toLowerCase().includes(q) ||
(c.location_detail||'').toLowerCase().includes(q)
).slice(0, 50)
: allChargers.slice(0, 50);
dd.innerHTML = filtered.map(c => `
<div class="charger-option ${c.id === selectedChargerId ? '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>
</div>`).join('') || '<div style="padding:12px;font-size:13px;color:var(--gray4)">검색 결과 없음</div>';
dd.classList.add('open');
}
function openDropdown() {
if (!allChargers.length) return;
filterChargers(document.getElementById('chargerSearchInput').value);
}
async function selectCharger(id, station, name, region) {
selectedChargerId = id;
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();
}
function renderErrorCodeUI() {
const wrap = document.getElementById('errorCodeWrap');
if (selectedChargerErrors.length > 0) {
wrap.innerHTML = `
<select class="form-input" id="errorCodeSelect">
<option value="">-- 에러코드 선택 (선택사항) --</option>
${selectedChargerErrors.map(e =>
`<option value="${escHtml(e.error_code)}">${escHtml(e.error_code)}${escHtml(e.error_name)}${e.range_condition ? ' ('+escHtml(e.range_condition)+')' : ''}</option>`
).join('')}
<option value="__other__">기타 (직접 입력)</option>
</select>
<input type="text" class="form-input" id="errorCodeCustom" placeholder="에러코드 직접 입력" style="margin-top:6px;display:none">`;
document.getElementById('errorCodeSelect').onchange = function() {
document.getElementById('errorCodeCustom').style.display =
this.value === '__other__' ? 'block' : 'none';
};
} else {
wrap.innerHTML = `<input type="text" class="form-input" id="errorCodeText" placeholder="예) E101, Fault_0x02 ...">`;
}
}
function getModalErrorCode() {
const sel = document.getElementById('errorCodeSelect');
if (sel) {
if (sel.value === '__other__') return document.getElementById('errorCodeCustom')?.value || '';
return sel.value;
}
return document.getElementById('errorCodeText')?.value || '';
}
function clearCharger() {
selectedChargerId = null;
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('selectedBadge').classList.remove('show');
renderErrorCodeUI();
}
// 드롭다운 외부 클릭 시 닫기
document.addEventListener('click', e => {
const wrap = document.querySelector('.charger-search-wrap');
if (wrap && !wrap.contains(e.target)) {
document.getElementById('chargerDropdown').classList.remove('open');
}
});
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ── 신고 제출 ── */
async function submitReport() {
if (!selectedChargerId) { 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 fd = new FormData();
fd.append('charger_id', selectedChargerId);
fd.append('scope', scope);
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));
try {
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) {
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}`;
}
} catch(e) {
alert('오류: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = '접수하기';
}
}
/* ── 관리자 신고 위치 지도 ── */
let adminMap = null;
let adminMarkers = [];
function renderAdminMap(reports) {
if (!adminMap) {
adminMap = L.map('adminMap', { zoomControl: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(adminMap);
}
adminMarkers.forEach(m => m.remove());
adminMarkers = [];
// 처리완료 제외
reports = reports.filter(r => r.status !== 'done');
// 충전기 ID 기준으로 그룹핑 (charger GPS 우선, 없으면 report GPS)
const chargerMap = {};
reports.forEach(r => {
const lat = r.charger_lat || r.gps_lat;
const lng = r.charger_lng || r.gps_lng;
if (!lat || !lng) return;
const key = r.charger_id;
if (!chargerMap[key]) {
chargerMap[key] = {
charger_id: r.charger_id, charger_name: r.charger_name,
station_name: r.station_name, location_detail: r.location_detail,
lat, lng, reports: [],
};
}
chargerMap[key].reports.push(r);
});
const groups = Object.values(chargerMap);
const noGps = reports.filter(r => !r.charger_lat && !r.gps_lat).length;
const noGpsEl = document.getElementById('mapNoGps');
noGpsEl.textContent = noGps ? `📍 GPS 미등록 ${noGps}건 미표시` : '';
if (!groups.length) {
adminMap.setView([36.5, 127.8], 7);
setTimeout(() => adminMap.invalidateSize(), 50);
return;
}
groups.forEach(g => {
const hasInProgress = g.reports.some(r => r.status === 'in_progress');
const isMulti = g.reports.length > 1;
const statusClass = isMulti ? 'multi' : hasInProgress ? 'in_progress' : 'pending';
const icon = L.divIcon({
className: '',
html: `<div class="adm-pin ${statusClass}"></div>`,
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
});
const allIssues = [...new Set(g.reports.flatMap(r => r.issue_types || []))];
const m = L.marker([g.lat, g.lng], { icon }).addTo(adminMap);
if (g.reports.length === 1) {
// 단일 신고: 마커 클릭 → 바로 상세 이동
const r = g.reports[0];
m.on('click', () => { location.href = `/pages/admin/report-detail.html?id=${r.id}`; });
} else {
// 복수 신고: 마커 클릭 → 첫 번째(가장 오래된) 신고 상세로 바로 이동
const first = g.reports[0];
m.on('click', () => { location.href = `/pages/admin/report-detail.html?id=${first.id}`; });
}
adminMarkers.push(m);
});
const bounds = L.latLngBounds(groups.map(g => [g.lat, g.lng]));
adminMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
if (groups.length === 1) adminMap.setZoom(14);
setTimeout(() => adminMap.invalidateSize(), 50);
}
ImageCompressor.setupPreview('modalPhoto', 'modalPhotoPreview', 'modalPhotoInfo');
function readOcppFile(input) {
const file = input.files[0];
if (!file) return;
document.getElementById('ocppFileName').textContent = file.name;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('ocppLog').value = e.target.result;
};
reader.readAsText(file, 'UTF-8');
}
</script>
</body>
</html>