- 이미지 압축: 삼성/네이버 브라우저 호환, URL.createObjectURL 방식으로 메모리 절감, 대용량 PNG/HEIC 처리, blob 유효성 검증, 순차 압축으로 모바일 OOM 방지 - HEIC/HEIF 지원: pillow-heif 서버사이드 변환, Pillow 12.2.0 업그레이드 - 조치 페이지: '조치 완료 저장' 단일 버튼으로 단순화 - 재조치 흐름: 관리자 재조치 요청 시 이전 조치 이력을 번호 카드로 순차 표시 - 신고 순번: 전체 기준 ROW_NUMBER(oldest=1) 순번 표시, 삭제 gap 제거 - 모바일 탭바: position:fixed 적용으로 nav 하단 흰 여백 제거 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
285 lines
10 KiB
HTML
285 lines
10 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">
|
|
<span class="nav-brand">⚡ EV AS 관리</span>
|
|
<div id="navUser"></div>
|
|
</nav>
|
|
<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">
|
|
<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;">🔁재조치</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>
|