기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, 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:
byun
2026-05-31 06:52:56 +09:00
parent 05b478372a
commit 2e8751ea6c
35 changed files with 5541 additions and 353 deletions

View File

@@ -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>

View File

@@ -0,0 +1,144 @@
<!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">
<style>
.history-card {
border: 1px solid var(--gray2);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 10px;
background: white;
cursor: pointer;
transition: box-shadow .15s;
}
.history-card:hover { box-shadow: 0 3px 12px rgba(0,0,0,.1); }
.history-card.approved { border-left: 4px solid var(--green); }
.history-card.pending { border-left: 4px solid var(--orange); }
.hc-top { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; margin-bottom:8px; }
.hc-title { font-size:14px; font-weight:700; color:var(--navy); }
.hc-meta { font-size:12px; color:var(--gray4); margin-top:3px; }
.hc-tags { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; }
.hc-tag { font-size:11px; padding:2px 8px; border-radius:10px; background:var(--gray1); color:var(--text2); border:1px solid var(--gray2); }
.badge-approved { background:#D1FAE5; color:#065F46; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
.badge-pending { background:#FEF3C7; color:#92400E; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
</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">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html" class="active">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html" class="active">🗂 처리 이력</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">내 처리 이력</h2>
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체</option>
<option value="approved">승인 완료</option>
<option value="pending">승인 대기</option>
</select>
<select id="fResult" style="width:auto">
<option value="">전체 처리상태</option>
<option value="done">완료</option>
<option value="in_progress">진행중</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
</select>
</div>
<div id="loading" class="alert alert-info">이력을 불러오는 중...</div>
<div id="error" class="alert alert-danger" style="display:none"></div>
<div id="list"></div>
<div id="empty" class="alert alert-info" style="display:none">처리 이력이 없습니다.</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const RESULT_LABEL = {
done: '✅ 완료',
in_progress: '🔧 진행중',
waiting: '⏳ 부품대기',
revisit: '🔄 재방문',
};
let allRepairs = [];
async function load() {
document.getElementById('loading').style.display = 'block';
document.getElementById('error').style.display = 'none';
try {
allRepairs = await API.get('/repairs/my');
render();
} catch(e) {
document.getElementById('error').textContent = '이력을 불러오지 못했습니다: ' + e.message;
document.getElementById('error').style.display = 'block';
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function render() {
const fStatus = document.getElementById('fStatus').value;
const fResult = document.getElementById('fResult').value;
let list = allRepairs;
if (fStatus === 'approved') list = list.filter(r => r.approved_at);
if (fStatus === 'pending') list = list.filter(r => !r.approved_at);
if (fResult) list = list.filter(r => r.result_status === fResult);
document.getElementById('empty').style.display = list.length ? 'none' : 'block';
document.getElementById('list').innerHTML = list.map(r => {
const isApproved = !!r.approved_at;
const dt = r.completed_at
? new Date(r.completed_at).toLocaleDateString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit'})
: '';
return `
<div class="history-card ${isApproved ? 'approved' : 'pending'}"
onclick="location.href='/pages/mechanic/repair.html?repair_id=${r.id}'">
<div class="hc-top">
<div>
<div class="hc-title">
${r.station_name || '-'} · ${r.charger_id || '-'}
</div>
<div class="hc-meta">${r.charger_name || ''} · 신고 ${r.report_count}건 · ${dt}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;">
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
</span>
<span style="font-size:11px;color:var(--gray4)">${RESULT_LABEL[r.result_status] || r.result_status}</span>
</div>
</div>
<div class="hc-tags">
${(r.repair_types||[]).map(t => `<span class="hc-tag">${t}</span>`).join('')}
</div>
</div>`;
}).join('');
}
document.getElementById('fStatus').onchange = render;
document.getElementById('fResult').onchange = render;
load();
</script>
</body>
</html>

View File

@@ -17,7 +17,20 @@
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="main" style="max-width:640px;margin:0 auto;">
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<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">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="max-width:640px;margin:0 auto;">
<div style="margin-bottom:14px;">
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
</div>
@@ -34,10 +47,7 @@
<div class="form-group">
<label>조치 유형 <span class="req">*</span></label>
<div class="check-group" id="repairTypes">
<label class="check-item"><input type="checkbox" value="부품교체"> 부품 교체</label>
<label class="check-item"><input type="checkbox" value="재시작"> 재시작</label>
<label class="check-item"><input type="checkbox" value="설정변경"> 설정 변경</label>
<label class="check-item"><input type="checkbox" value="기타"> 기타</label>
<div style="color:var(--gray4);font-size:12px">불러오는 중...</div>
</div>
</div>
@@ -46,6 +56,13 @@
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
</div>
<!-- 사진 안내 -->
<div style="background:#FFF8E6;border:1px solid #FFD600;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;line-height:1.7;">
📌 <strong>촬영 필수 항목</strong><br>
· 충전기 <strong>명판(제조사·모델명)</strong><strong>충전기 식별 ID</strong>가 선명하게 보이도록 촬영해 주세요.<br>
· 조치 전·후 상태를 각각 촬영하면 검증에 도움이 됩니다.
</div>
<!-- 조치 전 사진 -->
<div class="form-row">
<div class="form-group">
@@ -64,23 +81,50 @@
</div>
</div>
<div class="form-group">
<label>처리 상태 <span class="req">*</span></label>
<select id="resultStatus">
<option value="done">✅ 처리 완료</option>
<option value="waiting">⏳ 부품 대기</option>
<option value="revisit">🔄 재방문 필요</option>
</select>
</div>
<div class="alert alert-info" style="margin-bottom:14px;">
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록)
</div>
<div id="gpsStatus" class="alert alert-info" style="margin-bottom:14px;">
📍 위치 정보 수집 중...
</div>
<input type="hidden" id="mechanicLat">
<input type="hidden" id="mechanicLng">
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">조치 완료 저장</button>
<!-- 저장 버튼 영역 -->
<div style="background:var(--gray1);border:1px solid var(--gray2);border-radius:10px;padding:16px;margin-top:4px;">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:10px;">💾 저장 방식 선택</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="btn btn-outline btn-lg" id="saveBtn" style="flex:1;min-width:140px;" onclick="submitForm(false)">
💾 상태 저장
</button>
<button class="btn btn-primary btn-lg" id="doneBtn" style="flex:1;min-width:140px;" onclick="submitForm(true)">
✅ 조치 완료 저장
</button>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;">
<div style="flex:1;min-width:140px;">
<label style="font-size:11px;color:var(--gray4)">저장 상태 선택</label>
<select id="resultStatus" style="width:100%;margin-top:4px;font-size:13px;">
<option value="in_progress">🔧 계속 진행 중</option>
<option value="waiting">⏳ 부품 대기</option>
<option value="revisit">🔄 재방문 필요</option>
</select>
</div>
<div style="flex:1;min-width:140px;display:flex;align-items:flex-end;">
<div style="font-size:11px;color:var(--gray4);padding-bottom:6px;line-height:1.6;">
<strong>조치 완료 저장</strong>은 처리 완료로 확정됩니다.<br>
💾 <strong>상태 저장</strong>은 왼쪽 상태로 임시 저장됩니다.
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- max-width wrapper -->
</div><!-- .main -->
</div><!-- .layout -->
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
@@ -89,16 +133,20 @@
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search);
const chargerId = params.get('charger_id');
const params = new URLSearchParams(location.search);
const repairId = params.get('repair_id'); // 편집 모드
const chargerId = params.get('charger_id'); // 신규 모드
const initReportId = params.get('report_id');
const startTime = new Date();
const isEditMode = !!repairId;
const startTime = new Date();
document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR');
const selectedReports = new Set();
if (initReportId) selectedReports.add(parseInt(initReportId));
async function load() {
// ── 신규 모드 ──
async function loadCreate() {
const charger = await API.get('/chargers/' + chargerId);
document.getElementById('chargerCard').innerHTML = `
<div class="card-title">⚡ 충전기 정보</div>
@@ -106,79 +154,219 @@ async function load() {
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${charger.id}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${charger.name}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${charger.station_name}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name || '-'}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name||'-'}</strong></div>
</div>`;
const reports = await API.get('/repairs/charger/' + chargerId + '/open');
const list = document.getElementById('reportList');
if (!reports.length) {
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
return;
}
const list = document.getElementById('reportList');
if (!reports.length) { list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>'; return; }
list.innerHTML = reports.map(r => `
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:${selectedReports.has(r.id)?'#E3EDFF':'white'}">
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''}
value="${r.id}"
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}"
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
onchange="toggleReport(${r.id}, this.checked, this.closest('label'))">
onchange="toggleReport(${r.id},this.checked,this.closest('label'))">
<div>
<div><strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
<div style="font-size:11px;color:var(--gray4)">${Auth.fmtDt(r.reported_at)}</div>
${r.photos.length
? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>`
: ''}
${r.photos.length ? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>` : ''}
</div>
</label>`).join('');
}
function toggleReport(id, checked, label) {
if (checked) { selectedReports.add(id); label.style.background = '#E3EDFF'; }
else { selectedReports.delete(id); label.style.background = 'white'; }
if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; }
else { selectedReports.delete(id); label.style.background='white'; }
}
// ── 편집 모드 ──
async function loadEdit() {
let repair;
try { repair = await API.get('/repairs/' + repairId); }
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
// 헤더 업데이트
document.querySelector('h2, .main h2') && (document.querySelector('.main > div > h2') || document.querySelector('h2'))?.remove?.();
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
// 충전기 카드
document.getElementById('chargerCard').innerHTML = `
<div class="card-title">⚡ 충전기 정보</div>
<div class="form-row">
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${repair.charger_id||'-'}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${repair.charger_name||'-'}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${repair.station_name||'-'}</strong></div>
</div>`;
// 연결된 신고 (읽기 전용)
document.getElementById('reportList').innerHTML = (repair.reports||[]).length
? (repair.reports||[]).map(r => `
<div style="padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;background:#F8FAFF;">
<strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
</div>`).join('')
: '<div class="alert alert-info">연결된 신고 없음</div>';
// 승인 완료 → 잠금
if (repair.approved_at) {
const dt = new Date(repair.approved_at).toLocaleString('ko-KR');
document.querySelector('.card:last-child').innerHTML = `
<div class="alert alert-success" style="margin-bottom:0">
✅ <strong>관리자 승인 완료</strong> (${repair.approved_by_name||''} · ${dt})<br>
<span style="font-size:12px;">승인된 조치는 수정할 수 없습니다.</span>
</div>
${renderRepairView(repair)}`;
return;
}
// 폼 미리채우기 — 조치유형 동적 로드 후 체크 복원
await loadRepairTypes(repair.repair_types || []);
document.getElementById('description').value = repair.description || '';
const sel = document.getElementById('resultStatus');
if (repair.result_status && sel.querySelector(`option[value="${repair.result_status}"]`))
sel.value = repair.result_status;
// 기존 사진 표시
renderExistingPhotos(repair);
}
function renderRepairView(r) {
const LABEL = {done:'✅ 완료',in_progress:'🔧 진행중',waiting:'⏳ 부품대기',revisit:'🔄 재방문'};
const photoHtml = (type, list) => (list||[]).length
? `<div style="margin-top:8px"><label style="font-size:11px;font-weight:700;color:var(--navy2)">${type}</label>
<div class="photo-preview">${(list||[]).map(p=>`<img src="${p.path||p}" onclick="window.open('${p.path||p}')" style="cursor:zoom-in">`).join('')}</div></div>`
: '';
return `<div style="padding:14px 0">
<table style="font-size:13px;width:100%">
<tr><td style="color:var(--gray4);width:90px">조치유형</td><td>${(r.repair_types||[]).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">조치내용</td><td>${r.description||'-'}</td></tr>
<tr><td style="color:var(--gray4)">처리결과</td><td>${LABEL[r.result_status]||r.result_status}</td></tr>
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(r.completed_at)}</td></tr>
</table>
${photoHtml('조치 전 사진', r.photos_before)}
${photoHtml('조치 후 사진', r.photos_after)}
</div>`;
}
function renderExistingPhotos(repair) {
const mkGrid = (list, type) => {
if (!list || !list.length) return '';
return `<div style="display:flex;flex-wrap:wrap;gap:7px;margin-bottom:8px;">
${list.map(p => `
<div style="position:relative;">
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
<button onclick="deleteRepairPhoto(${repair.id},${p.id},'${type}')"
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
</div>`).join('')}
</div>`;
};
const bWrap = document.getElementById('previewBefore');
const aWrap = document.getElementById('previewAfter');
if (repair.photos_before?.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
if (repair.photos_after?.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
}
async function deleteRepairPhoto(rId, pId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
await API.delete(`/repairs/${rId}/photos/${pId}`);
location.reload();
} catch(e) { alert(e.message); }
}
// GPS 수집
navigator.geolocation?.getCurrentPosition(
pos => {
document.getElementById('mechanicLat').value = pos.coords.latitude;
document.getElementById('mechanicLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').className = 'alert alert-success';
document.getElementById('gpsStatus').innerHTML =
`📍 위치 수집 완료 <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
},
() => {
document.getElementById('gpsStatus').className = 'alert alert-warn';
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다.';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
// 이미지 압축 + 다중 선택 프리뷰
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
document.getElementById('submitBtn').addEventListener('click', async () => {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
async function submitForm(isDone) {
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; }
const desc = document.getElementById('description').value.trim();
if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; }
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '저장 중...';
if (!isEditMode) {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
}
const saveBtn = document.getElementById('saveBtn');
const doneBtn = document.getElementById('doneBtn');
saveBtn.disabled = doneBtn.disabled = true;
(isDone ? doneBtn : saveBtn).textContent = '저장 중...';
const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value;
const lat = document.getElementById('mechanicLat').value;
const lng = document.getElementById('mechanicLng').value;
const fd = new FormData();
fd.append('report_ids', JSON.stringify(rids));
fd.append('repair_types', JSON.stringify(types));
fd.append('description', desc);
fd.append('result_status', document.getElementById('resultStatus').value);
fd.append('result_status', resultStatus);
if (lat) fd.append('mechanic_lat', lat);
if (lng) fd.append('mechanic_lng', lng);
Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f));
Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f));
try {
await API.post('/repairs', fd);
alert('✅ 조치 완료 저장되었습니다.');
location.href = '/pages/mechanic/dashboard.html';
if (isEditMode) {
await API.put('/repairs/' + repairId, fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
} else {
fd.append('report_ids', JSON.stringify([...selectedReports]));
await API.post('/repairs', fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
}
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '조치 완료 저장';
saveBtn.disabled = doneBtn.disabled = false;
saveBtn.textContent = '💾 상태 저장';
doneBtn.textContent = '✅ 조치 완료 저장';
}
});
}
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
}
load();
async function loadRepairTypes(preChecked = []) {
try {
const types = await API.get('/settings/repair-types');
document.getElementById('repairTypes').innerHTML = types.map(t => `
<label class="check-item">
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
${t.label}
</label>`).join('');
} catch(e) {
document.getElementById('repairTypes').innerHTML =
'<div class="alert alert-danger" style="margin:0">조치유형을 불러오지 못했습니다.</div>';
}
}
if (isEditMode) {
loadEdit();
} else {
loadRepairTypes();
loadCreate();
}
</script>
</body>
</html>

View File

@@ -6,24 +6,39 @@
<link rel="stylesheet" href="/css/style.css">
<style>
#reader{width:100%;max-width:400px;margin:0 auto;border-radius:10px;overflow:hidden;}
.scan-wrap{max-width:480px;margin:0 auto;padding:20px;}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">QR 스캔</span>
<span class="nav-brand">EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="scan-wrap">
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
<div id="reader"></div>
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
<div style="margin-top:16px;">
<div class="form-group">
<label>충전기 ID 직접 입력</label>
<div style="display:flex;gap:8px;">
<input type="text" id="manualId" placeholder="예: CG-003">
<button class="btn btn-primary" onclick="goManual()">이동</button>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html" class="active">📷<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">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="max-width:480px;margin:0 auto;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:16px">📷 QR 스캔</h2>
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
<div id="reader"></div>
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
<div style="margin-top:16px;">
<div class="form-group">
<label>충전기 ID 직접 입력</label>
<div style="display:flex;gap:8px;">
<input type="text" id="manualId" placeholder="예: CG-003">
<button class="btn btn-primary" onclick="goManual()">이동</button>
</div>
</div>
</div>
</div>
</div>