344 lines
16 KiB
HTML
344 lines
16 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>
|
|
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
|
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
|
tr.selected td { background:var(--gray2) !important; }
|
|
#btnDelete { display:none; }
|
|
|
|
.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 - 220px);
|
|
min-height: 420px;
|
|
border-radius:10px;
|
|
overflow:hidden;
|
|
border:1px solid var(--border);
|
|
margin-top:12px;
|
|
isolation: isolate;
|
|
}
|
|
#chargerMap { width:100%; height:100%; }
|
|
|
|
.ck-pin {
|
|
width:28px; height:28px; border-radius:50% 50% 50% 0;
|
|
transform:rotate(-45deg); border:3px solid white;
|
|
box-shadow:0 2px 6px rgba(0,0,0,.35);
|
|
}
|
|
.ck-pin.fault { background:#EF4444; }
|
|
.ck-pin.normal { background:#22C55E; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
|
<div class="layout">
|
|
<div class="sidebar" id="sidebar">
|
|
<div class="sidebar-section">AS 관리</div>
|
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
|
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
|
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
|
|
<div class="sidebar-section">시스템</div>
|
|
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
|
<a href="/pages/admin/chargers.html" class="active">⚡ 충전기 관리</a>
|
|
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
|
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
|
</div>
|
|
<div class="main">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
|
|
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">충전기 관리</h2>
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
|
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
|
<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>
|
|
<button class="btn btn-primary" onclick="openModal()">+ 충전기 등록</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 목록 뷰 -->
|
|
<div id="listWrap" class="card">
|
|
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap">
|
|
<input type="text" id="searchInput" placeholder="충전기ID / 충전소명 / CPO 검색..." style="flex:1;min-width:180px;padding:7px 10px;border:1px solid var(--gray3);border-radius:7px;font-size:13px;outline:none">
|
|
<select id="filterFault" onchange="renderTable()" style="width:auto">
|
|
<option value="">전체</option>
|
|
<option value="fault">미처리 있음</option>
|
|
<option value="ok">정상</option>
|
|
</select>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<table>
|
|
<thead><tr>
|
|
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
|
<th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th>
|
|
</tr></thead>
|
|
<tbody id="tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 지도 뷰 -->
|
|
<div id="mapWrap">
|
|
<div id="chargerMap"></div>
|
|
</div>
|
|
<div id="mapLegend" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:16px;flex-wrap:wrap">
|
|
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>미처리 신고 있음</span>
|
|
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#22C55E;margin-right:4px"></span>정상</span>
|
|
<span id="noGpsNote" style="color:var(--gray4)"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-bg hidden" id="modal">
|
|
<div class="modal">
|
|
<div class="modal-title" id="modalTitle">충전기 등록</div>
|
|
<input type="hidden" id="editId">
|
|
<div class="form-row">
|
|
<div class="form-group"><label>충전기 종류 <span class="req">*</span></label><select id="fTypeId"></select></div>
|
|
<div class="form-group"><label>충전기 ID <span class="req">*</span></label><input type="text" id="fId" placeholder="예: CG-003"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>충전기명 <span class="req">*</span></label><input type="text" id="fName" placeholder="예: 1호기"></div>
|
|
<div class="form-group"><label>충전소명 <span class="req">*</span></label><input type="text" id="fStation"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>CPO명</label><input type="text" id="fCpo"></div>
|
|
<div class="form-group"><label>설치일</label><input type="date" id="fInstalled"></div>
|
|
</div>
|
|
<div class="form-group"><label>위치 상세</label><input type="text" id="fLocation"></div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label>위도</label><input type="number" id="fLat" step="0.000001"></div>
|
|
<div class="form-group"><label>경도</label><input type="number" id="fLng" step="0.000001"></div>
|
|
</div>
|
|
<div id="modalErr" class="alert alert-danger" style="display:none"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" onclick="closeModal()">취소</button>
|
|
<button class="btn btn-primary" onclick="save()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
|
|
|
let allChargers = [];
|
|
let types = [];
|
|
let curView = 'list';
|
|
let chargerMap = null;
|
|
let mapMarkers = [];
|
|
|
|
// ── 뷰 전환 ──
|
|
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';
|
|
document.getElementById('mapLegend').style.display = v === 'map' ? 'flex' : 'none';
|
|
document.getElementById('btnDelete').style.display = v === 'map' ? 'none' :
|
|
(document.querySelectorAll('.row-chk:checked').length > 0 ? 'inline-flex' : 'none');
|
|
if (v === 'map') {
|
|
initChargerMap();
|
|
renderChargerMap();
|
|
setTimeout(() => chargerMap && chargerMap.invalidateSize(), 50);
|
|
}
|
|
}
|
|
|
|
function updateDeleteBtn() {
|
|
const checked = document.querySelectorAll('.row-chk:checked');
|
|
document.getElementById('selCount').textContent = checked.length;
|
|
document.getElementById('btnDelete').style.display =
|
|
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
|
|
}
|
|
function toggleAll(chkAll) {
|
|
document.querySelectorAll('.row-chk').forEach(c => {
|
|
c.checked = chkAll.checked;
|
|
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
|
});
|
|
updateDeleteBtn();
|
|
}
|
|
async function bulkDelete() {
|
|
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
|
if (!checked.length) return;
|
|
if (!confirm(`선택한 충전기 ${checked.length}대를 삭제합니다. 신고 내역이 있는 충전기는 삭제되지 않습니다. 계속하시겠습니까?`)) return;
|
|
const ids = checked.map(c => c.dataset.id);
|
|
try { await API.delete('/chargers/bulk', ids); load(); }
|
|
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
|
|
}
|
|
|
|
// ── 데이터 로드 ──
|
|
async function load() {
|
|
[types, allChargers] = await Promise.all([
|
|
API.get('/chargers/types'),
|
|
API.get('/chargers'),
|
|
]);
|
|
document.getElementById('chkAll').checked = false;
|
|
updateDeleteBtn();
|
|
document.getElementById('fTypeId').innerHTML = types.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
|
|
renderTable();
|
|
if (curView === 'map') renderChargerMap();
|
|
}
|
|
|
|
// ── 목록 렌더 ──
|
|
function renderTable() {
|
|
const q = document.getElementById('searchInput').value.trim().toLowerCase();
|
|
const fault = document.getElementById('filterFault').value;
|
|
const rows = allChargers.filter(c => {
|
|
if (q && !c.id.toLowerCase().includes(q) &&
|
|
!c.station_name.toLowerCase().includes(q) &&
|
|
!(c.cpo_name||'').toLowerCase().includes(q) &&
|
|
!c.name.toLowerCase().includes(q)) return false;
|
|
if (fault === 'fault' && c.pending_reports === 0) return false;
|
|
if (fault === 'ok' && c.pending_reports > 0) return false;
|
|
return true;
|
|
});
|
|
document.getElementById('tbody').innerHTML = rows.map(c => `
|
|
<tr>
|
|
<td class="cb-cell" onclick="event.stopPropagation()">
|
|
<input type="checkbox" class="row-chk" data-id="${c.id}"
|
|
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
|
</td>
|
|
<td><strong>${c.id}</strong></td>
|
|
<td>${c.charger_type||'-'}</td>
|
|
<td>${c.name}</td>
|
|
<td>${c.station_name}</td>
|
|
<td>${c.cpo_name||'-'}</td>
|
|
<td>${c.installed_at||'-'}</td>
|
|
<td><span class="badge ${c.pending_reports>0?'s-pending':'s-done'}">${c.pending_reports}건</span></td>
|
|
<td><a class="btn btn-outline btn-sm" href="/pages/admin/qr.html?id=${c.id}">QR</a></td>
|
|
<td><button class="btn btn-outline btn-sm" onclick="editCharger('${c.id}')">수정</button></td>
|
|
</tr>`).join('');
|
|
}
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.getElementById('searchInput').addEventListener('input', renderTable);
|
|
});
|
|
|
|
// ── 지도 초기화 ──
|
|
function initChargerMap() {
|
|
if (chargerMap) return;
|
|
chargerMap = L.map('chargerMap', { 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(chargerMap);
|
|
}
|
|
|
|
// ── 지도 마커 렌더 ──
|
|
function renderChargerMap() {
|
|
if (!chargerMap) return;
|
|
mapMarkers.forEach(m => m.remove());
|
|
mapMarkers = [];
|
|
|
|
const visible = allChargers.filter(c => c.gps_lat && c.gps_lng);
|
|
const noGps = allChargers.length - visible.length;
|
|
document.getElementById('noGpsNote').textContent =
|
|
noGps ? `📍 GPS 미등록 ${noGps}대 미표시` : '';
|
|
|
|
if (!visible.length) {
|
|
chargerMap.setView([36.5, 127.8], 7);
|
|
return;
|
|
}
|
|
|
|
visible.forEach(c => {
|
|
const hasFault = c.pending_reports > 0;
|
|
const icon = L.divIcon({
|
|
className: '',
|
|
html: `<div class="ck-pin ${hasFault ? 'fault' : 'normal'}"></div>`,
|
|
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
|
|
});
|
|
|
|
const popup = `
|
|
<div style="min-width:200px">
|
|
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">⚡ ${c.id}</div>
|
|
<div style="font-size:12px;color:#6b7280;margin-bottom:8px;line-height:1.6">
|
|
📍 ${c.station_name}${c.location_detail ? '<br>' + c.location_detail : ''}
|
|
${c.charger_type ? '<br>종류: ' + c.charger_type : ''}
|
|
${c.cpo_name ? '<br>CPO: ' + c.cpo_name : ''}
|
|
</div>
|
|
<div style="margin-bottom:10px">
|
|
<span class="badge ${hasFault ? 's-pending' : 's-done'}" style="font-size:12px">${hasFault ? '⚠ 미처리 ' + c.pending_reports + '건' : '✅ 정상'}</span>
|
|
</div>
|
|
<div style="display:flex;gap:6px">
|
|
${hasFault
|
|
? `<a href="/pages/admin/reports.html?charger_id=${c.id}" style="flex:1;text-align:center;background:#EF4444;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none">📋 신고 보기</a>`
|
|
: ''}
|
|
<button onclick="editCharger('${c.id}')" style="flex:1;background:#1e3a5f;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;border:none;cursor:pointer">✏ 수정</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
|
|
.addTo(chargerMap)
|
|
.bindPopup(popup, { maxWidth: 260 });
|
|
mapMarkers.push(m);
|
|
});
|
|
|
|
const bounds = L.latLngBounds(visible.map(c => [c.gps_lat, c.gps_lng]));
|
|
chargerMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
|
if (visible.length === 1) chargerMap.setZoom(14);
|
|
}
|
|
|
|
// ── 모달 ──
|
|
function openModal(id=null) {
|
|
document.getElementById('modal').classList.remove('hidden');
|
|
document.getElementById('modalTitle').textContent = id ? '충전기 수정' : '충전기 등록';
|
|
}
|
|
function closeModal() { document.getElementById('modal').classList.add('hidden'); clearForm(); }
|
|
function clearForm() {
|
|
['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id => document.getElementById(id).value = '');
|
|
document.getElementById('fId').disabled = false;
|
|
document.getElementById('modalErr').style.display = 'none';
|
|
}
|
|
|
|
async function editCharger(id) {
|
|
const c = await API.get('/chargers/'+id);
|
|
openModal(id);
|
|
document.getElementById('editId').value = id;
|
|
document.getElementById('fTypeId').value = c.charger_type_id;
|
|
document.getElementById('fId').value = c.id; document.getElementById('fId').disabled = true;
|
|
document.getElementById('fName').value = c.name;
|
|
document.getElementById('fStation').value = c.station_name;
|
|
document.getElementById('fCpo').value = c.cpo_name||'';
|
|
document.getElementById('fInstalled').value = c.installed_at||'';
|
|
document.getElementById('fLocation').value = c.location_detail||'';
|
|
document.getElementById('fLat').value = c.gps_lat||'';
|
|
document.getElementById('fLng').value = c.gps_lng||'';
|
|
}
|
|
|
|
async function save() {
|
|
const fd = new FormData();
|
|
const id = document.getElementById('editId').value;
|
|
fd.append('charger_type_id', document.getElementById('fTypeId').value);
|
|
fd.append('name', document.getElementById('fName').value.trim());
|
|
fd.append('station_name', document.getElementById('fStation').value.trim());
|
|
fd.append('cpo_name', document.getElementById('fCpo').value);
|
|
fd.append('installed_at', document.getElementById('fInstalled').value);
|
|
fd.append('location_detail', document.getElementById('fLocation').value);
|
|
fd.append('gps_lat', document.getElementById('fLat').value||'');
|
|
fd.append('gps_lng', document.getElementById('fLng').value||'');
|
|
if (!id) fd.append('id', document.getElementById('fId').value.trim());
|
|
try {
|
|
if (id) await API.put('/chargers/'+id, fd);
|
|
else await API.post('/chargers', fd);
|
|
closeModal(); load();
|
|
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
|
|
}
|
|
|
|
load();
|
|
</script>
|
|
</body>
|
|
</html>
|