기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, 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>
|
||||
|
||||
144
frontend/static/pages/mechanic/history.html
Normal file
144
frontend/static/pages/mechanic/history.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user