기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, 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,12 +4,57 @@
|
||||
<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>
|
||||
@@ -18,18 +63,31 @@
|
||||
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
||||
<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 class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
<select id="filterStatus" style="width:auto">
|
||||
<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="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>
|
||||
@@ -37,23 +95,68 @@
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">처리 대기 중인 AS가 없습니다.</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="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
|
||||
<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(['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;
|
||||
const rows = await API.get('/repairs/pending' + (status ? '?status='+status : ''));
|
||||
allRows = await API.get('/repairs/pending' + (status ? '?status=' + status : ''));
|
||||
renderList();
|
||||
if (curView === 'map') renderMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderList() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
if (!rows.length) { tbody.innerHTML=''; document.getElementById('empty').style.display='block'; return; }
|
||||
document.getElementById('empty').style.display='none';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr onclick="location.href='/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}'">
|
||||
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}</td>
|
||||
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
|
||||
<td>${r.station_name||'-'}</td>
|
||||
@@ -61,9 +164,120 @@ async function load() {
|
||||
<td>${(r.issue_types||[]).join(', ')}</td>
|
||||
<td>${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td>${Auth.statusBadge(r.status)}</td>
|
||||
<td><a class="btn btn-primary btn-sm" href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}" onclick="event.stopPropagation()">조치</a></td>
|
||||
</tr>`).join('');
|
||||
<td><a class="btn 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>
|
||||
|
||||
Reference in New Issue
Block a user