- 기존: 충전기 Y축 + 에러코드별 스택 (충전기 중심) - 변경: 에러코드 Y축 + 충전기별 스택 (에러코드 순위 중심) - 어떤 에러코드가 가장 많이 발생했는지 + 어떤 충전기에서 발생했는지 한눈에 확인 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1251 lines
54 KiB
HTML
1251 lines
54 KiB
HTML
<!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>24–72h</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 통신 로그를 여기에 붙여넣거나, 아래에서 파일을 선택하세요. 예) [2,"...","StatusNotification",{...}]"></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.error_codes || !data.error_codes.length) {
|
||
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
|
||
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';
|
||
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',
|
||
}))
|
||
},
|
||
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 } },
|
||
tooltip: {
|
||
callbacks: {
|
||
title: items => {
|
||
const e = error_codes[items[0].dataIndex];
|
||
return `에러코드: ${e.error_code} (총 ${e.total}건)`;
|
||
},
|
||
label: c => `${c.dataset.label}: ${c.raw}건`,
|
||
}
|
||
}
|
||
},
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
/* ── 신고 제출 ── */
|
||
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>
|