Files
ev-charger-as/frontend/static/pages/mechanic/dashboard.html
2026-06-02 19:34:36 +09:00

286 lines
11 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>
.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 - 200px);
min-height: 420px;
border-radius:10px;
overflow:hidden;
border:1px solid var(--border);
}
#map { width:100%; height:100%; }
/* 마커 커스텀 */
.mk-pin {
width:32px; height:32px; border-radius:50% 50% 50% 0;
transform:rotate(-45deg); border:3px solid white;
box-shadow:0 2px 6px rgba(0,0,0,.35);
}
.mk-pin.pending { background:#EF4444; }
.mk-pin.in_progress{ background:#F59E0B; }
.leaflet-popup-content { min-width:200px; font-size:13px; }
.popup-title { font-size:14px; font-weight:700; color:var(--navy); margin-bottom:6px; }
.popup-meta { font-size:12px; color:var(--gray4); margin-bottom:8px; line-height:1.6; }
.popup-tags { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:10px; }
.popup-tag { font-size:11px; padding:2px 7px; background:var(--gray1); border-radius:8px; border:1px solid var(--gray2); }
.popup-count { font-size:12px; font-weight:700; color:#DC2626; margin-bottom:10px; }
.no-gps-notice {
font-size:12px; color:var(--gray4); padding:6px 10px;
background:var(--gray1); border-radius:6px; margin-bottom:10px;
}
</style>
</head>
<body>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html" class="active">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 처리 목록</h2>
<a href="/pages/mechanic/scan.html" class="btn btn-accent">📷 QR 스캔하여 조치 시작</a>
</div>
<!-- 필터 + 뷰 토글 -->
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center;">
<select id="filterStatus" style="width:auto" onchange="load()">
<option value="">전체 상태</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
</select>
<button class="btn btn-outline btn-sm" onclick="load()">새로고침</button>
<div style="margin-left:auto">
<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 id="listWrap" class="card" style="padding:0">
<div style="padding:14px 16px 0">
<div id="noGpsNotice" class="no-gps-notice" style="display:none"></div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>#</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;margin:14px">처리 대기 중인 AS가 없습니다.</div>
</div>
<!-- 지도 뷰 -->
<div id="mapWrap">
<div id="map"></div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/api.js?v=20260603"></script>
<script src="/js/auth.js?v=20260603"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
let allRows = [];
let mapObj = null;
let markers = [];
let curView = 'list';
// ── 뷰 전환 ──
function setView(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';
if (v === 'map') {
initMap();
renderMap();
// 컨테이너가 보인 직후 Leaflet에 크기 재계산 알림
setTimeout(() => mapObj && mapObj.invalidateSize(), 50);
}
}
// ── 데이터 로드 ──
async function load() {
const status = document.getElementById('filterStatus').value;
allRows = await API.get('/repairs/pending' + (status ? '?status=' + status : ''));
renderList();
if (curView === 'map') renderMap();
}
// ── 목록 렌더 ──
function renderList() {
const tbody = document.getElementById('tbody');
const empty = document.getElementById('empty');
if (!allRows.length) {
tbody.innerHTML = '';
empty.style.display = 'block';
document.getElementById('noGpsNotice').style.display = 'none';
return;
}
empty.style.display = 'none';
tbody.innerHTML = allRows.map(r => {
const href = r.repair_id
? `/pages/mechanic/repair.html?repair_id=${r.repair_id}`
: `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`;
return `
<tr onclick="location.href='${href}'">
<td>#${r.id}${r.re_dispatch_count > 0 ? ' <span style="font-size:10px;background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:8px;font-weight:700;vertical-align:middle;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : ''}</td>
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>
<td>${(r.issue_types||[]).join(', ')}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${Auth.statusBadge(r.status)}</td>
<td><a class="btn ${r.re_dispatch_count > 0 ? 'btn-accent' : 'btn-primary'} btn-sm" href="${href}" onclick="event.stopPropagation()">조치</a></td>
</tr>`;
}).join('');
const noGps = allRows.filter(r => !r.gps_lat || !r.gps_lng).length;
const noticeEl = document.getElementById('noGpsNotice');
if (noGps) {
noticeEl.textContent = `📍 GPS 미등록 충전기 ${noGps}건은 지도에 표시되지 않습니다.`;
noticeEl.style.display = 'block';
} else {
noticeEl.style.display = 'none';
}
}
// ── 지도 초기화 ──
function initMap() {
if (mapObj) return;
mapObj = L.map('map', { 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(mapObj);
}
// ── 지도 마커 렌더 ──
function renderMap() {
if (!mapObj) return;
// 기존 마커 제거
markers.forEach(m => m.remove());
markers = [];
// 충전기별로 그룹핑
const chargerMap = {};
allRows.forEach(r => {
if (!r.gps_lat || !r.gps_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,
gps_lat: r.gps_lat,
gps_lng: r.gps_lng,
reports: [],
};
}
chargerMap[key].reports.push(r);
});
const chargers = Object.values(chargerMap);
if (!chargers.length) {
// GPS 없는 경우 한국 중심으로
mapObj.setView([36.5, 127.8], 7);
return;
}
chargers.forEach(c => {
const hasInProgress = c.reports.some(r => r.status === 'in_progress');
const statusClass = hasInProgress ? 'in_progress' : 'pending';
const color = hasInProgress ? '#F59E0B' : '#EF4444';
const icon = L.divIcon({
className: '',
html: `<div class="mk-pin ${statusClass}"></div>`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -34],
});
// 팝업 내용
const allIssues = [...new Set(c.reports.flatMap(r => r.issue_types || []))];
const firstReport = c.reports[0];
const href = firstReport.repair_id
? `/pages/mechanic/repair.html?repair_id=${firstReport.repair_id}`
: `/pages/mechanic/repair.html?charger_id=${c.charger_id}&report_id=${firstReport.id}`;
const popup = `
<div class="popup-title">⚡ ${c.charger_id}</div>
<div class="popup-meta">
📍 ${c.station_name || '-'}${c.location_detail ? '<br>' + c.location_detail : ''}
${c.charger_name ? '<br>' + c.charger_name : ''}
</div>
${c.reports.length > 1
? `<div class="popup-count">📋 신고 ${c.reports.length}건</div>`
: ''}
<div class="popup-tags">
${allIssues.map(t => `<span class="popup-tag">${t}</span>`).join('')}
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<a href="${href}" class="btn btn-primary btn-sm" style="font-size:12px;text-decoration:none">🔧 조치 시작</a>
${c.reports.length > 1
? c.reports.map(r =>
`<a href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}"
style="font-size:11px;color:var(--blue);text-decoration:none;align-self:center">#${r.id}</a>`
).join('')
: ''}
</div>`;
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
.addTo(mapObj)
.bindPopup(popup, { maxWidth: 280 });
markers.push(m);
});
// 모든 마커가 보이도록 뷰 조정
const bounds = L.latLngBounds(chargers.map(c => [c.gps_lat, c.gps_lng]));
mapObj.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
// 마커 1개면 줌 고정
if (chargers.length === 1) mapObj.setZoom(15);
}
load();
</script>
</body>
</html>