기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, UI 개선
## 처리시간 지표 - 업무시간 기준(09-18 평일) / 공휴일 제외 24h / 달력 기준 3가지 모드 선택 - 공휴일 DB 관리 (holidays 테이블, 수동 등록·삭제·일괄 추가) - 2026년 공휴일 등록 지원 - 설정 페이지에서 라디오 버튼으로 모드 선택 ## 대시보드 차트 - 월별 평균 처리시간 막대 차트 추가 - 월별 신고 접수 건수 누적 막대 차트 추가 - 월별 → 일별 드릴다운 (막대 클릭 시 해당 월의 일별 차트로 전환) - 일별 막대 클릭 시 처리 완료/신고 접수 상세 내역 모달 - 충전기별 누적 고장 건수 Top 10 수평 막대 차트 추가 ## 신고 목록 - # 컬럼을 DB PK 대신 현재 목록 순서(1, 2, 3…)로 표시 - 엑셀 export 접수번호도 순차번호로 변경 ## 모바일 네비게이션 버그 수정 - 모바일에서 가로 오버플로우 시 nav가 body 넓이로 늘어나 햄버거 버튼이 화면 밖으로 밀리는 문제 수정 - nav를 position:fixed + body padding-top:54px 로 변경 (전체 페이지 적용) - 충전기 관리·신고 목록 페이지 지도 컨테이너에 isolation:isolate 적용 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,40 @@
|
||||
<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>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected { background:var(--light-gray,#f0f4ff); }
|
||||
#btnDelete { display:none; }
|
||||
|
||||
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
|
||||
.view-btn { padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white; cursor:pointer; color:var(--gray4); transition:all .15s; }
|
||||
.view-btn.active { background:var(--navy); color:white; }
|
||||
|
||||
#mapWrap {
|
||||
display:none;
|
||||
height: calc(100vh - 230px);
|
||||
min-height: 420px;
|
||||
border-radius:10px;
|
||||
overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
isolation: isolate;
|
||||
}
|
||||
#reportMap { width:100%; height:100%; }
|
||||
|
||||
.rp-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);
|
||||
}
|
||||
.rp-pin.pending { background:#EF4444; }
|
||||
.rp-pin.in_progress { background:#F59E0B; }
|
||||
.rp-pin.waiting { background:#3B82F6; }
|
||||
.rp-pin.revisit { background:#8B5CF6; }
|
||||
.rp-pin.done { background:#9CA3AF; }
|
||||
.rp-pin.multi { background:#7C3AED; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
@@ -17,6 +51,7 @@
|
||||
<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>
|
||||
@@ -24,10 +59,19 @@
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 신고 목록</h2>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/reports','AS신고목록.xlsx')">📥 엑셀 다운로드</button>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/reports','AS신고목록.xlsx')">📥 엑셀 다운로드</button>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
|
||||
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card" style="padding:12px 16px;margin-bottom:12px">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<select id="fStatus" style="width:auto">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending_approval">승인대기</option>
|
||||
@@ -39,42 +83,256 @@
|
||||
</select>
|
||||
<input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px">
|
||||
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
|
||||
<span id="resultCount" style="font-size:13px;color:var(--gray4);margin-left:4px"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 뷰 -->
|
||||
<div id="listWrap" class="card" style="padding:0">
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>충전기ID</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>상태</th><th>정비사</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>#</th><th>충전기ID</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>신고자</th><th>상태</th><th>정비사</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">조회된 신고가 없습니다.</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none;margin:14px">조회된 신고가 없습니다.</div>
|
||||
</div>
|
||||
|
||||
<!-- 지도 뷰 -->
|
||||
<div id="mapWrap"><div id="reportMap"></div></div>
|
||||
<div id="mapMeta" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:14px;flex-wrap:wrap;align-items:center">
|
||||
<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:#3B82F6;margin-right:4px"></span>부품대기</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8B5CF6;margin-right:4px"></span>재방문</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#9CA3AF;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="margin-left:auto"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let allRows = [];
|
||||
let curView = 'list';
|
||||
let reportMap = null;
|
||||
let mapMarkers = [];
|
||||
|
||||
// ── URL 파라미터 초기값 ──
|
||||
const _p = new URLSearchParams(location.search);
|
||||
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
|
||||
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
function setView(v) {
|
||||
sessionStorage.setItem('reportsView', v);
|
||||
curView = v;
|
||||
document.getElementById('btnList').classList.toggle('active', v === 'list');
|
||||
document.getElementById('btnMap').classList.toggle('active', v === 'map');
|
||||
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
|
||||
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
|
||||
document.getElementById('mapMeta').style.display = v === 'map' ? 'flex' : 'none';
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(v === 'list' && document.querySelectorAll('.row-chk:checked').length > 0) ? 'inline-flex' : 'none';
|
||||
if (v === 'map') {
|
||||
initReportMap();
|
||||
renderReportMap();
|
||||
setTimeout(() => reportMap && reportMap.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 체크박스 ──
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 신고 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/reports/bulk', ids); await load(); }
|
||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
function maskPhone(p) {
|
||||
const d = (p||'').replace(/\D/g,'');
|
||||
if (d.length >= 10) return d.slice(0,3) + '-****-' + d.slice(-4);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
async function load() {
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
let url = '/reports?';
|
||||
const s = document.getElementById('fStatus').value;
|
||||
const c = document.getElementById('fCharger').value.trim();
|
||||
if (s) url += 'status='+s+'&';
|
||||
if (c) url += 'charger_id='+c+'&';
|
||||
const rows = await API.get(url);
|
||||
if (s) url += 'status=' + s + '&';
|
||||
if (c) url += 'charger_id=' + c + '&';
|
||||
allRows = await API.get(url);
|
||||
|
||||
document.getElementById('resultCount').textContent = allRows.length + '건';
|
||||
renderTable();
|
||||
if (curView === 'map') renderReportMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
document.getElementById('empty').style.display = rows.length ? 'none' : 'block';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'">
|
||||
<td>#${r.id}</td>
|
||||
<td><strong>${r.charger_id}</strong></td>
|
||||
<td>${r.station_name||'-'}</td>
|
||||
<td>${r.charger_type||'-'}</td>
|
||||
<td style="max-width:200px">${(r.issue_types||[]).join(', ')}</td>
|
||||
<td>${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td>${Auth.statusBadge(r.status)}</td>
|
||||
<td>${r.repair?.mechanic_name||'-'}</td>
|
||||
document.getElementById('empty').style.display = allRows.length ? 'none' : 'block';
|
||||
tbody.innerHTML = allRows.map((r, i) => `
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${r.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
<span style="font-weight:700">${i+1}</span>
|
||||
<span style="display:block;font-size:10px;color:var(--gray4);font-weight:400">#${r.id}</span>
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"><strong>${r.charger_id}</strong></td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.station_name||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.charger_type||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer;max-width:200px">${(r.issue_types||[]).join(', ')}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
${r.source === 'dashboard'
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:#7C3AED">🖥 대시보드</div>`
|
||||
: r.source === 'admin'
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:var(--blue)">⚙️ 관리자</div>`
|
||||
: `<div style="font-size:12px;color:var(--text)">${r.contact ? maskPhone(r.contact) : '익명'}</div><div style="font-size:11px;color:#166534">📱 QR</div>`}
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.statusBadge(r.status)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
${r.mechanic_name
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.mechanic_name}</div>${r.mechanic_company ? `<div style="font-size:11px;color:var(--gray4)">${r.mechanic_company}</div>` : ''}`
|
||||
: '<span style="color:var(--gray4)">-</span>'}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
load();
|
||||
|
||||
// ── 지도 초기화 ──
|
||||
function initReportMap() {
|
||||
if (reportMap) return;
|
||||
reportMap = L.map('reportMap', { 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(reportMap);
|
||||
}
|
||||
|
||||
// 상태별 마커 색상
|
||||
const STATUS_CLASS = {
|
||||
pending: 'pending', pending_approval: 'pending',
|
||||
in_progress: 'in_progress',
|
||||
waiting: 'waiting', revisit: 'revisit',
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
// ── 지도 마커 렌더 ──
|
||||
function renderReportMap() {
|
||||
if (!reportMap) return;
|
||||
mapMarkers.forEach(m => m.remove());
|
||||
mapMarkers = [];
|
||||
|
||||
// 충전기별 그룹핑 (charger GPS 우선, 없으면 신고 GPS)
|
||||
const grouped = {};
|
||||
allRows.forEach(r => {
|
||||
const lat = r.charger_lat || r.gps_lat;
|
||||
const lng = r.charger_lng || r.gps_lng;
|
||||
if (!lat || !lng) return;
|
||||
if (!grouped[r.charger_id]) {
|
||||
grouped[r.charger_id] = {
|
||||
charger_id: r.charger_id, charger_name: r.charger_name,
|
||||
station_name: r.station_name, location_detail: r.location_detail,
|
||||
lat, lng, reports: [],
|
||||
};
|
||||
}
|
||||
grouped[r.charger_id].reports.push(r);
|
||||
});
|
||||
|
||||
const groups = Object.values(grouped);
|
||||
const noGps = allRows.filter(r => !r.charger_lat && !r.gps_lat).length;
|
||||
document.getElementById('mapNoGps').textContent = noGps ? `📍 GPS 미등록 ${noGps}건 미표시` : '';
|
||||
|
||||
if (!groups.length) {
|
||||
reportMap.setView([36.5, 127.8], 7);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.forEach(g => {
|
||||
// 대표 상태 결정 (우선순위: pending > in_progress > waiting > revisit > done)
|
||||
const priority = ['pending','pending_approval','in_progress','waiting','revisit','done'];
|
||||
const topStatus = priority.find(s => g.reports.some(r => r.status === s)) || 'pending';
|
||||
const pinClass = g.reports.length > 1 ? 'multi' : (STATUS_CLASS[topStatus] || 'pending');
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="rp-pin ${pinClass}"></div>`,
|
||||
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
|
||||
});
|
||||
|
||||
const m = L.marker([g.lat, g.lng], { icon }).addTo(reportMap);
|
||||
|
||||
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 rowsHtml = g.reports.map(r => {
|
||||
const h = (Date.now() - new Date(r.reported_at)) / 3600000;
|
||||
const age = h < 1 ? Math.round(h*60)+'분' : h < 24 ? Math.round(h)+'h' : (h/24).toFixed(1)+'일';
|
||||
return `<a href="/pages/admin/report-detail.html?id=${r.id}"
|
||||
style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:6px 8px;border-radius:6px;font-size:12px;text-decoration:none;
|
||||
color:inherit;background:#f9fafb;border:1px solid #e5e7eb;margin-bottom:5px">
|
||||
<span><strong>#${r.id}</strong> ${(r.issue_types||[]).join(', ')}</span>
|
||||
<span style="margin-left:8px;white-space:nowrap">${Auth.statusBadge(r.status)}</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
const popup = `
|
||||
<div style="min-width:230px">
|
||||
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">
|
||||
⚡ ${g.charger_id}
|
||||
<span style="font-size:12px;color:#7C3AED;font-weight:600">${g.reports.length}건</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:10px;line-height:1.5">
|
||||
📍 ${g.station_name||'-'}${g.location_detail ? '<br>'+g.location_detail : ''}
|
||||
</div>
|
||||
${rowsHtml}
|
||||
</div>`;
|
||||
m.bindPopup(popup, { maxWidth: 300 });
|
||||
}
|
||||
|
||||
mapMarkers.push(m);
|
||||
});
|
||||
|
||||
const bounds = L.latLngBounds(groups.map(g => [g.lat, g.lng]));
|
||||
reportMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
if (groups.length === 1) reportMap.setZoom(14);
|
||||
}
|
||||
|
||||
load().then(() => {
|
||||
if (sessionStorage.getItem('reportsView') === 'map') setView('map');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user