초기 커밋 - EV AS 관리 시스템

This commit is contained in:
root
2026-04-18 06:18:58 +09:00
commit 7a5c397983
52 changed files with 6044 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
<!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"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="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">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html" class="active">👥 계정 관리</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;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">계정 관리</h2>
<button class="btn btn-primary" onclick="openModal()">+ 계정 생성</button>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;">
<select id="fRole" onchange="load()" style="width:auto">
<option value="">전체</option><option value="mechanic">정비사</option>
<option value="manufacturer">제조사</option><option value="admin">관리자</option>
</select>
</div>
<div class="tbl-wrap"><table>
<thead><tr><th>ID</th><th>아이디</th><th>역할</th><th>이름</th><th>회사/제조사</th><th>전화번호</th><th>상태</th><th>수정</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
</div>
</div>
</div>
<div class="modal-bg hidden" id="modal">
<div class="modal" style="max-width:480px">
<div class="modal-title" id="modalTitle">계정 생성</div>
<input type="hidden" id="eId">
<div class="form-row">
<div class="form-group"><label>역할 <span class="req">*</span></label>
<select id="eRole" onchange="toggleFields()">
<option value="mechanic">정비사</option>
<option value="manufacturer">제조사</option>
<option value="admin">관리자</option>
</select>
</div>
<div class="form-group"><label>로그인 ID <span class="req">*</span></label><input type="text" id="eUsername"></div>
</div>
<div class="form-group"><label>비밀번호 <span class="req" id="pwReq">*</span></label><input type="password" id="ePassword" placeholder="수정 시 변경할 경우에만 입력"></div>
<div class="form-row">
<div class="form-group"><label>이름 <span class="req">*</span></label><input type="text" id="eName"></div>
<div class="form-group" id="companyField"><label>회사명</label><input type="text" id="eCompany"></div>
</div>
<div class="form-row">
<div class="form-group"><label>전화번호</label><input type="tel" id="ePhone"></div>
<div class="form-group" id="emailField"><label>이메일</label><input type="email" id="eEmail"></div>
</div>
<div class="form-group"><label>계정 활성화</label>
<select id="eActive"><option value="true">활성</option><option value="false">비활성</option></select>
</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="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사'};
async function load() {
const role = document.getElementById('fRole').value;
const users = await API.get('/accounts'+(role?'?role='+role:''));
document.getElementById('tbody').innerHTML = users.map(u=>`
<tr><td>${u.id}</td><td>${u.username}</td><td>${ROLE_LABEL[u.role]||u.role}</td>
<td>${u.name}</td><td>${u.company||'-'}</td><td>${u.phone||'-'}</td>
<td><span class="badge ${u.is_active?'s-done':'s-waiting'}">${u.is_active?'활성':'비활성'}</span></td>
<td><button class="btn btn-outline btn-sm" onclick="editUser(${u.id})">수정</button>
<button class="btn btn-danger btn-sm" onclick="delUser(${u.id})">삭제</button></td></tr>`).join('');
}
function openModal() { document.getElementById('modal').classList.remove('hidden'); document.getElementById('eId').value=''; document.getElementById('eUsername').disabled=false; document.getElementById('pwReq').style.display='inline'; }
function closeModal() { document.getElementById('modal').classList.add('hidden'); document.getElementById('modalErr').style.display='none'; ['eUsername','ePassword','eName','eCompany','ePhone','eEmail'].forEach(id=>document.getElementById(id).value=''); }
function toggleFields() {}
async function editUser(id) {
const users = await API.get('/accounts');
const u = users.find(x=>x.id===id);
openModal();
document.getElementById('eId').value=id;
document.getElementById('eRole').value=u.role;
document.getElementById('eUsername').value=u.username; document.getElementById('eUsername').disabled=true;
document.getElementById('eName').value=u.name;
document.getElementById('eCompany').value=u.company||'';
document.getElementById('ePhone').value=u.phone||'';
document.getElementById('eEmail').value=u.email||'';
document.getElementById('eActive').value=String(u.is_active);
document.getElementById('pwReq').style.display='none';
document.getElementById('modalTitle').textContent='계정 수정';
}
async function save() {
const id = document.getElementById('eId').value;
const fd = new FormData();
if (!id) { fd.append('username',document.getElementById('eUsername').value.trim()); fd.append('role',document.getElementById('eRole').value); }
const pw = document.getElementById('ePassword').value;
if (pw) fd.append('password', pw);
else if (!id) { alert('비밀번호를 입력하세요.'); return; }
fd.append('name',document.getElementById('eName').value.trim());
fd.append('company',document.getElementById('eCompany').value);
fd.append('phone',document.getElementById('ePhone').value);
fd.append('email',document.getElementById('eEmail').value);
fd.append('is_active',document.getElementById('eActive').value);
try {
if (id) await API.put('/accounts/'+id, fd);
else await API.post('/accounts', fd);
closeModal(); load();
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
}
async function delUser(id) { if(!confirm('비활성 처리하시겠습니까?')) return; await API.delete('/accounts/'+id); load(); }
load();
</script></body></html>

View File

@@ -0,0 +1,180 @@
<!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">
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="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">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html" class="active">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">충전기 종류 관리</h2>
<!-- 추가 / 수정 폼 -->
<div class="card" style="max-width:520px">
<div class="card-title" id="formTitle">종류 추가</div>
<input type="hidden" id="editId">
<div class="form-group">
<label>종류명 <span class="req">*</span></label>
<input type="text" id="typeName" placeholder="예: 급속충전기 100kW">
</div>
<div class="form-group">
<label>설명</label>
<input type="text" id="typeDesc" placeholder="설명 입력">
</div>
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<div style="display:flex;gap:10px;">
<button class="btn btn-primary" id="submitBtn" onclick="submitForm()">추가</button>
<button class="btn btn-outline" id="cancelBtn" onclick="cancelEdit()" style="display:none">취소</button>
</div>
</div>
<!-- 목록 -->
<div class="card">
<div class="card-title">등록된 충전기 종류</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>종류명</th>
<th>설명</th>
<th>충전기 수</th>
<th>수정</th>
<th>삭제</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none;margin-top:12px">
등록된 충전기 종류가 없습니다.
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
async function load() {
const types = await API.get('/chargers/types');
const tbody = document.getElementById('tbody');
document.getElementById('empty').style.display = types.length ? 'none' : 'block';
tbody.innerHTML = types.map(t => `
<tr>
<td>${t.id}</td>
<td><strong>${t.name}</strong></td>
<td>${t.description || '-'}</td>
<td>${t.charger_count}개</td>
<td>
<button class="btn btn-outline btn-sm" onclick="startEdit(${t.id}, '${escQ(t.name)}', '${escQ(t.description||'')}')">
수정
</button>
</td>
<td>
${t.charger_count === 0
? `<button class="btn btn-danger btn-sm" onclick="del(${t.id})">삭제</button>`
: `<span style="font-size:12px;color:var(--gray4)">사용중</span>`}
</td>
</tr>`).join('');
}
function escQ(str) {
return str.replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
/* ── 수정 모드 진입 ── */
function startEdit(id, name, desc) {
document.getElementById('editId').value = id;
document.getElementById('typeName').value = name;
document.getElementById('typeDesc').value = desc;
document.getElementById('formTitle').textContent = '종류 수정';
document.getElementById('submitBtn').textContent = '수정 저장';
document.getElementById('submitBtn').className = 'btn btn-accent';
document.getElementById('cancelBtn').style.display = 'inline-flex';
document.getElementById('formErr').style.display = 'none';
// 폼으로 스크롤
document.getElementById('typeName').focus();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/* ── 수정 취소 ── */
function cancelEdit() {
document.getElementById('editId').value = '';
document.getElementById('typeName').value = '';
document.getElementById('typeDesc').value = '';
document.getElementById('formTitle').textContent = '종류 추가';
document.getElementById('submitBtn').textContent = '추가';
document.getElementById('submitBtn').className = 'btn btn-primary';
document.getElementById('cancelBtn').style.display = 'none';
document.getElementById('formErr').style.display = 'none';
}
/* ── 추가 / 수정 공통 제출 ── */
async function submitForm() {
const id = document.getElementById('editId').value;
const name = document.getElementById('typeName').value.trim();
const desc = document.getElementById('typeDesc').value.trim();
const errEl = document.getElementById('formErr');
errEl.style.display = 'none';
if (!name) {
errEl.textContent = '종류명을 입력하세요.';
errEl.style.display = 'block';
return;
}
const fd = new FormData();
fd.append('name', name);
fd.append('description', desc);
try {
if (id) {
await API.put('/chargers/types/' + id, fd);
} else {
await API.post('/chargers/types', fd);
}
cancelEdit();
load();
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
/* ── 삭제 ── */
async function del(id) {
if (!confirm('삭제하시겠습니까?')) return;
try {
await API.delete('/chargers/types/' + id);
load();
} catch(e) {
alert(e.message);
}
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!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"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="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">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html" class="active">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">충전기 종류 관리</h2>
<div class="card" style="max-width:500px">
<div class="card-title">종류 추가</div>
<div class="form-group"><label>종류명 <span class="req">*</span></label><input type="text" id="newName" placeholder="예: 급속충전기 100kW"></div>
<div class="form-group"><label>설명</label><input type="text" id="newDesc" placeholder="설명 입력"></div>
<div id="addErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="addType()">추가</button>
</div>
<div class="card">
<div class="card-title">등록된 충전기 종류</div>
<div class="tbl-wrap"><table>
<thead><tr><th>ID</th><th>종류명</th><th>설명</th><th>충전기 수</th><th>삭제</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
async function load() {
const types = await API.get('/chargers/types');
document.getElementById('tbody').innerHTML = types.map(t=>`
<tr><td>${t.id}</td><td><strong>${t.name}</strong></td><td>${t.description||'-'}</td>
<td>${t.charger_count}개</td>
<td>${t.charger_count===0?`<button class="btn btn-danger btn-sm" onclick="del(${t.id})">삭제</button>`:'사용중'}</td></tr>`).join('');
}
async function addType() {
const name = document.getElementById('newName').value.trim();
if (!name) return;
const fd = new FormData(); fd.append('name',name); fd.append('description',document.getElementById('newDesc').value);
try { await API.post('/chargers/types',fd); document.getElementById('newName').value=''; document.getElementById('newDesc').value=''; load(); }
catch(e) { const el=document.getElementById('addErr'); el.textContent=e.message; el.style.display='block'; }
}
async function del(id) { if (!confirm('삭제하시겠습니까?')) return; await API.delete('/chargers/types/'+id); load(); }
load();
</script></body></html>

View File

@@ -0,0 +1,131 @@
<!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">
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="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/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.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;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">충전기 관리</h2>
<button class="btn btn-primary" onclick="openModal()">+ 충전기 등록</button>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead><tr><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>
</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="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
let types = [], isEdit = false;
async function load() {
[types] = await Promise.all([API.get('/chargers/types')]);
const chargers = await API.get('/chargers');
document.getElementById('fTypeId').innerHTML = types.map(t=>`<option value="${t.id}">${t.name}</option>`).join('');
document.getElementById('tbody').innerHTML = chargers.map(c => `
<tr>
<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('');
}
function openModal(id=null) { isEdit=!!id; 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('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>

View File

@@ -0,0 +1,86 @@
<!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">
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="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" class="active">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.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>
<button class="btn btn-success btn-sm" onclick="API.download('/export/costs','출장비목록.xlsx')">📥 엑셀 다운로드</button>
</div>
<div class="stats" id="stats"></div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending">미처리</option>
<option value="billed">청구완료</option>
<option value="waived">면제</option>
<option value="settled">정산완료</option>
</select>
<select id="fParty" style="width:auto">
<option value="">전체 부담주체</option>
<option value="cpo">CPO</option>
<option value="manufacturer">제조사</option>
<option value="self">자체</option>
<option value="user">사용자과실</option>
<option value="other">기타</option>
</select>
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none">조회된 출장비가 없습니다.</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
async function load() {
const [statsData, costs] = await Promise.all([API.get('/costs/stats'), API.get('/costs?cost_status='+document.getElementById('fStatus').value+'&cost_party_type='+document.getElementById('fParty').value)]);
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="stat-num">${statsData.monthly_total.toLocaleString()}</div><div class="stat-label">이달 출장비 합계(원)</div></div>
<div class="stat danger"><div class="stat-num">${statsData.pending_count}</div><div class="stat-label">미처리 건수</div></div>`;
const tbody = document.getElementById('tbody');
document.getElementById('empty').style.display = costs.length ? 'none' : 'block';
tbody.innerHTML = costs.map(c => `
<tr onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'">
<td>${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
<td>${c.charger_id||'-'}</td>
<td>${c.station_name||'-'}</td>
<td>${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
<td>${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td>
<td style="font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
<td>${Auth.costStatusBadge(c.cost_status)}</td>
<td>${Auth.fmtDt(c.reviewed_at)}</td>
</tr>`).join('');
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!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">
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</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">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:20px">대시보드</h2>
<div class="stats" id="stats"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="card">
<div class="card-title">🔴 최근 신고 (미처리)</div>
<div id="recentReports"></div>
</div>
<div class="card">
<div class="card-title">💰 출장비 미처리 현황</div>
<div id="costPending"></div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
async function load() {
const [stats, reports, costs] = await Promise.all([
API.get('/stats'),
API.get('/reports?status=pending'),
API.get('/costs?cost_status=pending'),
]);
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="stat-num">${stats.total}</div><div class="stat-label">전체 신고</div></div>
<div class="stat warn"><div class="stat-num">${stats.pending}</div><div class="stat-label">접수 대기</div></div>
<div class="stat warn"><div class="stat-num">${stats.in_progress}</div><div class="stat-label">처리중</div></div>
<div class="stat good"><div class="stat-num">${stats.done}</div><div class="stat-label">완료</div></div>
<div class="stat danger"><div class="stat-num">${stats.cost_pending}</div><div class="stat-label">출장비 미처리</div></div>
<div class="stat warn"><div class="stat-num">${stats.improvement_open}</div><div class="stat-label">개선항목 진행중</div></div>
`;
document.getElementById('recentReports').innerHTML = reports.slice(0,8).map(r => `
<div onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'"
style="padding:9px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>#${r.id}</strong> <small style="color:var(--gray4)">${r.charger_id}</small>
<div style="font-size:12px;color:var(--text2)">${(r.issue_types||[]).join(', ')}</div>
</div>
<div style="text-align:right">
${Auth.statusBadge(r.status)}
<div style="font-size:11px;color:var(--gray4);margin-top:2px">${Auth.fmtDt(r.reported_at)}</div>
</div>
</div>`).join('') || '<div style="color:var(--gray4);font-size:13px">미처리 신고가 없습니다.</div>';
document.getElementById('costPending').innerHTML = costs.slice(0,8).map(c => `
<div onclick="location.href='/pages/admin/report-detail.html?repair_id=${c.repair_id}'"
style="padding:9px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${c.charger_id||'-'}</strong> <small style="color:var(--gray4)">${c.station_name||''}</small>
<div style="font-size:12px;color:var(--text2)">${c.mechanic_name||''} (${c.mechanic_company||''})</div>
</div>
<div style="text-align:right">
${Auth.costStatusBadge(c.cost_status)}
<div style="font-size:12px;color:var(--orange);font-weight:700;margin-top:2px">${(c.cost_amount||0).toLocaleString()}원</div>
</div>
</div>`).join('') || '<div style="color:var(--gray4);font-size:13px">미처리 출장비가 없습니다.</div>';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!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"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="main" style="max-width:860px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">개선항목 상세</h2>
</div>
<div id="content"></div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
const id = new URLSearchParams(location.search).get('id');
const CAT={sw:'SW개선',hw:'HW개선',ui:'UI개선',firmware:'펌웨어',other:'기타'};
const STATUS_OPTIONS = ['registered','reviewing','developing','deployed','done'];
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
async function load() {
const imp = await API.get('/improvements/'+id);
document.getElementById('pageTitle').textContent = `개선항목 #${imp.id}`;
document.getElementById('content').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="card">
<div class="card-title">📋 기본 정보</div>
<table class="no-hover" style="font-size:13px">
<tr><td style="color:var(--gray4);width:90px">제목</td><td><strong>${imp.title}</strong></td></tr>
<tr><td style="color:var(--gray4)">분류</td><td>${CAT[imp.category]||imp.category}</td></tr>
<tr><td style="color:var(--gray4)">우선순위</td><td>${imp.priority}</td></tr>
<tr><td style="color:var(--gray4)">관련 부품</td><td>${imp.part_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">담당 제조사</td><td><strong>${imp.manufacturer_company||'-'}</strong><br>${imp.manufacturer_name||''}</td></tr>
<tr><td style="color:var(--gray4)">등록자</td><td>${imp.created_by_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
<tr><td style="color:var(--gray4)">실제 배포일</td><td>${imp.sw_deployed_at||'-'}</td></tr>
<tr><td style="color:var(--gray4)">현재 상태</td><td>${Auth.statusBadge(imp.status)}</td></tr>
</table>
<div style="margin-top:12px">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">개선 내용</div>
<div style="background:var(--gray1);padding:12px;border-radius:6px;font-size:13px;white-space:pre-wrap">${imp.description}</div>
</div>
${imp.manufacturer_memo?`<div style="margin-top:12px"><div style="font-size:12px;font-weight:700;color:var(--orange);margin-bottom:6px">제조사 메모</div><div style="background:#FFF5E6;padding:12px;border-radius:6px;font-size:13px">${imp.manufacturer_memo}</div></div>`:''}
</div>
<div class="card">
<div class="card-title">📎 연결된 AS 신고</div>
${imp.report_ids.length ? imp.report_ids.map(rid=>`
<div onclick="location.href='/pages/admin/report-detail.html?id=${rid}'"
style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;cursor:pointer;font-size:13px">
신고 #${rid}
</div>`).join('') : '<div class="alert alert-info">연결된 신고 없음</div>'}
<div class="card-title" style="margin-top:16px">📁 첨부 파일</div>
${imp.attachments.length ? imp.attachments.map(a=>`
<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-bottom:6px;display:block">
📄 ${a.name||a.path.split('/').pop()}
</a>`).join('') : '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
</div>
</div>
<!-- 상태 변경 -->
<div class="card" style="margin-top:0">
<div class="card-title">🔄 상태 변경</div>
<div class="form-row">
<div class="form-group">
<label>상태 변경</label>
<select id="newStatus">
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>SW 실제 배포일 (배포완료 시)</label>
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
</div>
</div>
<div class="form-group"><label>변경 메모</label><input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모"></div>
<button class="btn btn-primary" onclick="changeStatus()">상태 저장</button>
</div>
<!-- 이력 로그 -->
<div class="card" style="margin-top:0">
<div class="card-title">📜 변경 이력</div>
${imp.logs.length ? `<div class="timeline">${imp.logs.map(l=>`
<div class="tl-item">
<div class="tl-time">${Auth.fmtDt(l.changed_at)}${l.by||'시스템'}</div>
<div class="tl-text">${l.old_status?`${STATUS_LABEL[l.old_status]||l.old_status}`:''}${STATUS_LABEL[l.new_status]||l.new_status}${l.memo?` / ${l.memo}`:''}</div>
</div>`).join('')}</div>` : '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
</div>
`;
}
async function changeStatus() {
const status = document.getElementById('newStatus').value;
const memo = document.getElementById('changeMemo').value;
const date = document.getElementById('deployedAt').value;
const fd = new FormData();
fd.append('status', status); fd.append('memo', memo);
if (date) fd.append('sw_deployed_at', date);
await API.patch('/improvements/'+id+'/status', fd);
load();
}
load();
</script></body></html>

View File

@@ -0,0 +1,164 @@
<!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"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="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" class="active">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.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">
<button class="btn btn-success btn-sm" onclick="API.download('/export/improvements','개선항목목록.xlsx')">📥 엑셀</button>
<button class="btn btn-primary" onclick="openModal()">+ 개선항목 등록</button>
</div>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="registered">등록</option><option value="reviewing">검토중</option>
<option value="developing">개발중</option><option value="deployed">배포완료</option>
<option value="done">완료</option>
</select>
<select id="fMfr" style="width:auto"><option value="">전체 제조사</option></select>
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</div>
<div class="tbl-wrap"><table>
<thead><tr><th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
<div id="empty" class="alert alert-info" style="display:none">등록된 개선항목이 없습니다.</div>
</div>
</div>
</div>
<!-- 등록 모달 -->
<div class="modal-bg hidden" id="modal">
<div class="modal" style="max-width:680px">
<div class="modal-title">개선항목 등록</div>
<div class="form-row">
<div class="form-group">
<label>분류 <span class="req">*</span></label>
<select id="mCat">
<option value="sw">SW 개선</option><option value="hw">HW 개선</option>
<option value="ui">UI 개선</option><option value="firmware">펌웨어</option>
<option value="other">기타</option>
</select>
</div>
<div class="form-group">
<label>우선순위</label>
<select id="mPri">
<option value="urgent">🔴 긴급</option><option value="high">🟠 높음</option>
<option value="normal" selected>🟡 보통</option><option value="low">🟢 낮음</option>
</select>
</div>
</div>
<div class="form-group"><label>제목 <span class="req">*</span></label><input type="text" id="mTitle" placeholder="개선 항목 제목"></div>
<div class="form-group"><label>개선 내용 <span class="req">*</span></label><textarea id="mDesc" rows="4" placeholder="문제점 및 개선 요구 사항을 상세히 작성하세요."></textarea></div>
<div class="form-row">
<div class="form-group"><label>관련 부품명</label><input type="text" id="mPart" placeholder="예: 전력변환모듈"></div>
<div class="form-group"><label>담당 제조사 <span class="req">*</span></label><select id="mMfr"></select></div>
</div>
<div class="form-group">
<label>관련 AS 신고 연결 (복수 선택)</label>
<input type="text" id="mReportSearch" placeholder="신고번호 또는 충전기 ID로 검색" oninput="searchReports()" style="margin-bottom:8px">
<div id="mReportList" style="max-height:150px;overflow-y:auto;border:1px solid var(--gray3);border-radius:6px;padding:8px;font-size:12px"></div>
</div>
<div class="form-row">
<div class="form-group"><label>보고서 첨부</label><input type="file" id="mFiles" multiple></div>
<div class="form-group"><label>SW 배포 목표일</label><input type="date" id="mTarget"></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="saveImprovement()">등록</button>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
const selectedReports = new Set();
let allReports = [];
async function load() {
const [mfrs, imps] = await Promise.all([
API.get('/accounts?role=manufacturer'),
API.get('/improvements?status='+document.getElementById('fStatus').value+'&manufacturer_id='+document.getElementById('fMfr').value)
]);
// 제조사 필터 드롭다운
const mfrSel = document.getElementById('fMfr');
if (mfrSel.options.length <= 1)
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=`${m.company||''} / ${m.name}`; mfrSel.appendChild(o); });
document.getElementById('empty').style.display = imps.length ? 'none' : 'block';
document.getElementById('tbody').innerHTML = imps.map(i => `
<tr onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'">
<td>#${i.id}</td>
<td style="max-width:200px"><strong>${i.title}</strong></td>
<td>${CAT[i.category]||i.category}</td>
<td>${PRI[i.priority]||i.priority}</td>
<td>${i.manufacturer_company||'-'}<br><small>${i.manufacturer_name||''}</small></td>
<td><span class="badge s-pending">${i.report_count}건</span></td>
<td>${Auth.statusBadge(i.status)}</td>
<td>${Auth.fmtDt(i.created_at)}</td>
<td>${i.sw_deployed_at||'-'}</td>
</tr>`).join('');
}
async function openModal() {
document.getElementById('modal').classList.remove('hidden');
const mfrs = await API.get('/accounts?role=manufacturer');
document.getElementById('mMfr').innerHTML = '<option value="">제조사 선택</option>' +
mfrs.map(m=>`<option value="${m.id}">${m.company||''} / ${m.name}</option>`).join('');
allReports = await API.get('/reports');
renderReportList('');
}
function closeModal() { document.getElementById('modal').classList.add('hidden'); selectedReports.clear(); document.getElementById('modalErr').style.display='none'; }
function searchReports() { renderReportList(document.getElementById('mReportSearch').value.toLowerCase()); }
function renderReportList(q) {
const filtered = allReports.filter(r => !q || String(r.id).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
document.getElementById('mReportList').innerHTML = filtered.map(r => `
<label style="display:flex;gap:8px;align-items:center;padding:5px;cursor:pointer;${selectedReports.has(r.id)?'background:#E3EDFF;border-radius:4px':''}">
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}" style="accent-color:var(--accent);flex-shrink:0"
onchange="${selectedReports.has(r.id)?'selectedReports.delete':'selectedReports.add'}(${r.id}); renderReportList('${q}')">
<span><strong>#${r.id}</strong> ${r.charger_id||''}${(r.issue_types||[]).join(', ')}</span>
</label>`).join('') || '<div style="color:var(--gray4)">검색 결과 없음</div>';
}
async function saveImprovement() {
const title = document.getElementById('mTitle').value.trim();
const desc = document.getElementById('mDesc').value.trim();
const mfr = document.getElementById('mMfr').value;
if (!title) { showErr('제목을 입력하세요.'); return; }
if (!desc) { showErr('내용을 입력하세요.'); return; }
if (!mfr) { showErr('담당 제조사를 선택하세요.'); return; }
const fd = new FormData();
fd.append('title',title); fd.append('category',document.getElementById('mCat').value);
fd.append('description',desc); fd.append('priority',document.getElementById('mPri').value);
fd.append('part_name',document.getElementById('mPart').value);
fd.append('manufacturer_id',mfr);
fd.append('report_ids', JSON.stringify([...selectedReports]));
fd.append('sw_deploy_target',document.getElementById('mTarget').value);
Array.from(document.getElementById('mFiles').files).forEach(f => fd.append('attachments',f));
try { await API.post('/improvements',fd); closeModal(); load(); }
catch(e) { showErr(e.message); }
}
function showErr(m) { const el=document.getElementById('modalErr'); el.textContent=m; el.style.display='block'; }
load();
</script></body></html>

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>QR 생성</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="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">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">QR 코드 생성</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;max-width:800px">
<div class="card">
<div class="card-title">충전기 선택</div>
<div class="form-group"><label>등록된 충전기 선택</label>
<select id="chargerSel" onchange="onSelect()"><option value="">직접 입력</option></select>
</div>
<div class="form-group"><label>충전기 ID</label><input type="text" id="cId" placeholder="CG-003"></div>
<button class="btn btn-primary" onclick="genQR()">📷 QR 생성</button>
<div id="err" class="alert alert-danger" style="display:none;margin-top:10px"></div>
</div>
<div class="card" id="qrResult" style="display:none">
<div class="card-title">생성된 QR 코드</div>
<div style="text-align:center">
<img id="qrImg" style="width:200px;height:200px;border:1px solid var(--gray3);border-radius:8px">
<div id="qrInfo" style="margin-top:10px;font-size:13px;color:var(--text2)"></div>
<button class="btn btn-success" style="margin-top:12px" onclick="printQR()">🖨 인쇄</button>
<a id="qrDownload" class="btn btn-outline" style="margin-top:8px;display:block">⬇ 이미지 저장</a>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
let chargerMap = {};
async function load() {
const chargers = await API.get('/chargers');
chargers.forEach(c => chargerMap[c.id] = c);
document.getElementById('chargerSel').innerHTML = '<option value="">직접 입력</option>' +
chargers.map(c=>`<option value="${c.id}">${c.id}${c.name} (${c.station_name})</option>`).join('');
const urlId = new URLSearchParams(location.search).get('id');
if (urlId) { document.getElementById('chargerSel').value = urlId; onSelect(); genQR(); }
}
function onSelect() {
const id = document.getElementById('chargerSel').value;
document.getElementById('cId').value = id;
}
async function genQR() {
const id = document.getElementById('cId').value.trim();
if (!id) { const el=document.getElementById('err'); el.textContent='충전기 ID를 입력하세요.'; el.style.display='block'; return; }
document.getElementById('err').style.display='none';
try {
const res = await API.post('/chargers/'+id+'/qr');
const c = chargerMap[id] || {};
document.getElementById('qrResult').style.display='block';
document.getElementById('qrImg').src = res.qr_path + '?t=' + Date.now();
document.getElementById('qrInfo').innerHTML = `<strong>${id}</strong><br>${c.name||''} / ${c.station_name||''}`;
const dl = document.getElementById('qrDownload');
dl.href = res.qr_path; dl.download = id + '_QR.png';
} catch(e) { const el=document.getElementById('err'); el.textContent=e.message; el.style.display='block'; }
}
function printQR() {
const img = document.getElementById('qrImg').src;
const w = window.open(''); w.document.write(`<html><body style="text-align:center"><img src="${img}" style="width:300px"><br><script>window.print()<\/script></body></html>`); w.document.close();
}
load();
</script></body></html>

View File

@@ -0,0 +1,438 @@
<!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>
/* 출장비 요약 카드 */
.cost-summary {
border-radius: 10px;
padding: 18px 20px;
margin-bottom: 16px;
position: relative;
}
.cost-summary.s-pending { background: #FFF8E6; border: 2px solid #FFD600; }
.cost-summary.s-billed { background: #E3EDFF; border: 2px solid var(--blue); }
.cost-summary.s-waived { background: #F0F0F0; border: 2px solid #aaa; }
.cost-summary.s-settled { background: #E8F8F2; border: 2px solid var(--green); }
.cost-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.cost-summary-title {
font-size: 15px;
font-weight: 700;
color: var(--navy);
display: flex;
align-items: center;
gap: 8px;
}
.cost-status-badge {
display: inline-block;
padding: 3px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
}
.csb-pending { background: #FFF3CD; color: #856404; }
.csb-billed { background: #DBEAFE; color: #1565C0; }
.csb-waived { background: #F0F0F0; color: #555; }
.csb-settled { background: #D1FAE5; color: #065F46; }
.cost-summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.cost-summary-item label {
font-size: 10px;
letter-spacing: 1px;
color: var(--gray4);
text-transform: uppercase;
display: block;
margin-bottom: 3px;
}
.cost-summary-item span {
font-size: 13px;
color: var(--text);
font-weight: 500;
}
.cost-summary-item span.amount {
font-size: 18px;
font-weight: 900;
color: var(--orange);
}
.cost-summary-divider {
border: none;
border-top: 1px solid rgba(0,0,0,.08);
margin: 12px 0;
}
.cost-note-box {
background: rgba(0,0,0,.04);
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
color: var(--text2);
line-height: 1.7;
}
.edit-toggle-btn {
font-size: 12px;
color: var(--blue);
background: none;
border: 1px solid var(--blue);
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.edit-toggle-btn:hover { background: #E3EDFF; }
/* 수정 폼 슬라이드 */
.cost-edit-wrap {
overflow: hidden;
transition: max-height .3s ease;
}
.cost-edit-wrap.collapsed { max-height: 0 !important; }
.cost-edit-inner {
border-top: 1px dashed var(--gray3);
padding-top: 16px;
margin-top: 4px;
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="main" style="max-width:860px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">신고 상세</h2>
</div>
<div id="content"></div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search);
const reportId = params.get('id');
const PARTY_LABEL = {
cpo: 'CPO (운영사)',
manufacturer: '제조사',
self: '자체 부담',
user: '사용자 과실',
other: '기타',
};
const COST_STATUS_LABEL = {
pending: '미처리',
billed: '청구 완료',
waived: '면제',
settled: '정산 완료',
};
const COST_STATUS_ICON = {
pending: '🕐',
billed: '📨',
waived: '🔖',
settled: '✅',
};
let editOpen = false;
function toggleEdit() {
editOpen = !editOpen;
const wrap = document.getElementById('costEditWrap');
const btn = document.getElementById('editToggleBtn');
if (editOpen) {
wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
wrap.classList.remove('collapsed');
btn.innerHTML = '▲ 수정 접기';
} else {
wrap.style.maxHeight = '0';
wrap.classList.add('collapsed');
btn.innerHTML = '✏️ 수정하기';
}
}
async function load() {
const r = await API.get('/reports/' + reportId);
const repair = r.repair;
const cost = repair?.cost;
const manufacturers = await API.get('/accounts?role=manufacturer');
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`;
// ── 출장비 요약 HTML 생성 ──
let costHtml = '';
if (repair) {
const hasCost = cost && cost.cost_party_type;
const costStatus = cost?.cost_status || 'pending';
const statusLabel = COST_STATUS_LABEL[costStatus] || costStatus;
const statusIcon = COST_STATUS_ICON[costStatus] || '🕐';
// 부담 주체 텍스트
let partyText = '-';
if (cost?.cost_party_type) {
partyText = PARTY_LABEL[cost.cost_party_type] || cost.cost_party_type;
if (cost.cost_party_type === 'manufacturer' && cost.manufacturer_name) {
partyText += ` (${cost.manufacturer_name})`;
}
if (cost.cost_party_type === 'other' && cost.cost_party_custom) {
partyText += `${cost.cost_party_custom}`;
}
}
// 요약 카드 (처리 내역이 있을 때만 표시)
const summaryHtml = hasCost ? `
<div class="cost-summary s-${costStatus}">
<div class="cost-summary-header">
<div class="cost-summary-title">
${statusIcon} 출장비 처리 내역
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="cost-status-badge csb-${costStatus}">${statusLabel}</span>
<button class="edit-toggle-btn" id="editToggleBtn" onclick="toggleEdit()">✏️ 수정하기</button>
</div>
</div>
<div class="cost-summary-grid">
<div class="cost-summary-item">
<label>출장비 부담 주체</label>
<span>${partyText}</span>
</div>
<div class="cost-summary-item">
<label>출장비 금액</label>
<span class="amount">${(cost.cost_amount || 0).toLocaleString()}원</span>
</div>
<div class="cost-summary-item">
<label>처리 담당자</label>
<span>${cost.reviewed_by_name || '-'}</span>
</div>
<div class="cost-summary-item">
<label>처리 일시</label>
<span>${Auth.fmtDt(cost.reviewed_at)}</span>
</div>
</div>
${(cost.root_cause || cost.admin_note) ? `
<hr class="cost-summary-divider">
${cost.root_cause ? `
<div style="margin-bottom:8px;">
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">문제 원인</div>
<div class="cost-note-box">${cost.root_cause}</div>
</div>` : ''}
${cost.admin_note ? `
<div>
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">비고</div>
<div class="cost-note-box">${cost.admin_note}</div>
</div>` : ''}
` : ''}
</div>` : '';
// 수정 폼 (항상 존재, 기존 미처리면 바로 펼쳐져 있음)
const formCollapsed = hasCost ? 'collapsed' : '';
if (!hasCost) editOpen = true;
costHtml = `
<div class="card" style="margin-top:0">
<div class="card-title">💰 출장비 처리${hasCost ? '' : ' (관리자)'}</div>
${summaryHtml}
<!-- 입력 / 수정 폼 -->
<div class="cost-edit-wrap ${formCollapsed}" id="costEditWrap">
<div class="cost-edit-inner">
<div class="form-row">
<div class="form-group">
<label>문제 원인 파악 <span class="req">*</span></label>
<textarea id="rootCause" rows="3"
placeholder="조치 내용 검토 후 원인을 기재하세요.">${cost?.root_cause || ''}</textarea>
</div>
<div class="form-group">
<label>비고</label>
<textarea id="adminNote" rows="3"
placeholder="특이사항, 추가 메모 등">${cost?.admin_note || ''}</textarea>
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label>출장비 부담 주체 <span class="req">*</span></label>
<select id="partyType" onchange="toggleParty()">
<option value="">선택</option>
<option value="cpo" ${cost?.cost_party_type === 'cpo' ? 'selected' : ''}>CPO (운영사)</option>
<option value="manufacturer" ${cost?.cost_party_type === 'manufacturer' ? 'selected' : ''}>제조사</option>
<option value="self" ${cost?.cost_party_type === 'self' ? 'selected' : ''}>자체 부담</option>
<option value="user" ${cost?.cost_party_type === 'user' ? 'selected' : ''}>사용자 과실</option>
<option value="other" ${cost?.cost_party_type === 'other' ? 'selected' : ''}>기타</option>
</select>
</div>
<div class="form-group" id="mfrWrap"
style="display:${cost?.cost_party_type === 'manufacturer' ? 'block' : 'none'}">
<label>제조사 선택</label>
<select id="partyMfr">
<option value="">선택</option>
${manufacturers.map(m =>
`<option value="${m.id}"
${cost?.cost_party_manufacturer_id == m.id ? 'selected' : ''}>
${m.company || ''} / ${m.name}
</option>`).join('')}
</select>
</div>
<div class="form-group" id="customWrap"
style="display:${cost?.cost_party_type === 'other' ? 'block' : 'none'}">
<label>기타 직접 입력</label>
<input type="text" id="partyCustom" value="${cost?.cost_party_custom || ''}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>출장비 금액 (원)</label>
<input type="number" id="costAmount"
value="${cost?.cost_amount || 0}" min="0" step="1000">
</div>
<div class="form-group">
<label>처리 상태</label>
<select id="costStatus">
<option value="pending" ${(!cost || cost.cost_status === 'pending') ? 'selected' : ''}>미처리</option>
<option value="billed" ${cost?.cost_status === 'billed' ? 'selected' : ''}>청구완료</option>
<option value="waived" ${cost?.cost_status === 'waived' ? 'selected' : ''}>면제</option>
<option value="settled" ${cost?.cost_status === 'settled' ? 'selected' : ''}>정산완료</option>
</select>
</div>
</div>
<div id="costErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="saveCost(${repair.id})">
💾 출장비 처리 저장
</button>
</div>
</div>
</div>`;
}
document.getElementById('content').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<!-- 신고 정보 -->
<div class="card">
<div class="card-title">📋 신고 정보</div>
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong></td></tr>
<tr><td style="color:var(--gray4)">충전기명</td><td>${r.charger_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">종류</td><td>${r.charger_type || '-'}</td></tr>
<tr><td style="color:var(--gray4)">설치일</td><td>${r.installed_at || '-'}</td></tr>
<tr><td style="color:var(--gray4)">문제유형</td><td>${(r.issue_types || []).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">에러코드</td><td>${r.error_code || '-'}</td></tr>
<tr><td style="color:var(--gray4)">상세설명</td><td>${r.issue_detail || '-'}</td></tr>
<tr><td style="color:var(--gray4)">연락처</td><td>${r.contact || '-'}</td></tr>
<tr><td style="color:var(--gray4)">발생시각</td><td>${Auth.fmtDt(r.occurred_at)}</td></tr>
<tr><td style="color:var(--gray4)">신고일시</td><td>${Auth.fmtDt(r.reported_at)}</td></tr>
<tr><td style="color:var(--gray4)">상태</td><td>${Auth.statusBadge(r.status)}</td></tr>
</table>
${r.status === 'pending_approval' ? `
<button class="btn btn-success btn-sm" style="margin-top:12px"
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''}
<div style="margin-top:12px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 사진</label>
<div class="photo-preview">
${(r.photos || []).map(p =>
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
).join('') || '<span style="font-size:12px;color:var(--gray4)">첨부 없음</span>'}
</div>
</div>
</div>
<!-- 조치 정보 -->
<div class="card">
<div class="card-title">🔧 조치 정보</div>
${repair ? `
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${repair.mechanic_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">소속</td><td>${repair.mechanic_company || '-'}</td></tr>
<tr><td style="color:var(--gray4)">조치유형</td><td>${(repair.repair_types || []).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">조치내용</td><td>${repair.description || '-'}</td></tr>
<tr><td style="color:var(--gray4)">시작시각</td><td>${Auth.fmtDt(repair.started_at)}</td></tr>
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(repair.completed_at)}</td></tr>
<tr><td style="color:var(--gray4)">처리결과</td><td>${Auth.statusBadge(repair.result_status)}</td></tr>
</table>
<div style="margin-top:12px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">조치 전 사진</label>
<div class="photo-preview">
${(repair.photos_before || []).map(p =>
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
</div>
<label style="font-size:12px;font-weight:700;color:var(--navy2);margin-top:10px;display:block">조치 후 사진</label>
<div class="photo-preview">
${(repair.photos_after || []).map(p =>
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
</div>
</div>
` : '<div class="alert alert-info">아직 정비사가 조치를 입력하지 않았습니다.</div>'}
</div>
</div>
${costHtml}
`;
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
if (!editOpen) return;
const wrap = document.getElementById('costEditWrap');
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
}
function toggleParty() {
const v = document.getElementById('partyType').value;
document.getElementById('mfrWrap').style.display = v === 'manufacturer' ? 'block' : 'none';
document.getElementById('customWrap').style.display = v === 'other' ? 'block' : 'none';
}
async function approveReport(id) {
if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return;
await API.patch(`/reports/${id}/approve`);
alert('승인되었습니다.');
load();
}
async function saveCost(repairId) {
const partyType = document.getElementById('partyType').value;
if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; }
const fd = new FormData();
fd.append('root_cause', document.getElementById('rootCause').value);
fd.append('admin_note', document.getElementById('adminNote').value);
fd.append('cost_party_type', partyType);
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr')?.value || '');
fd.append('cost_party_custom', document.getElementById('partyCustom')?.value || '');
fd.append('cost_amount', document.getElementById('costAmount').value || 0);
fd.append('cost_status', document.getElementById('costStatus').value);
try {
await API.post(`/costs/repair/${repairId}`, fd);
alert('✅ 출장비 처리가 저장되었습니다.');
editOpen = false;
load();
} catch(e) { showCostErr(e.message); }
}
function showCostErr(msg) {
const el = document.getElementById('costErr');
el.textContent = msg;
el.style.display = 'block';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,80 @@
<!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">
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</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">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.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)">AS 신고 목록</h2>
<button class="btn btn-success btn-sm" onclick="API.download('/export/reports','AS신고목록.xlsx')">📥 엑셀 다운로드</button>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
<option value="done">완료</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
</select>
<input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px">
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>#</th><th>충전기ID</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>상태</th><th>정비사</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none">조회된 신고가 없습니다.</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
async function load() {
let url = '/reports?';
const s = document.getElementById('fStatus').value;
const c = document.getElementById('fCharger').value.trim();
if (s) url += 'status='+s+'&';
if (c) url += 'charger_id='+c+'&';
const rows = await API.get(url);
const tbody = document.getElementById('tbody');
document.getElementById('empty').style.display = rows.length ? 'none' : 'block';
tbody.innerHTML = rows.map(r => `
<tr onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>
<td style="max-width:200px">${(r.issue_types||[]).join(', ')}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${Auth.statusBadge(r.status)}</td>
<td>${r.repair?.mechanic_name||'-'}</td>
</tr>`).join('');
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,269 @@
<!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>
.range-wrap{display:flex;align-items:center;gap:12px;}
.range-wrap input[type=range]{flex:1;accent-color:var(--accent);}
.range-val{min-width:48px;text-align:center;font-weight:700;color:var(--navy);font-size:14px;}
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--gray2);}
.toggle-row:last-child{border-bottom:none;}
.toggle-label h4{font-size:14px;font-weight:700;color:var(--navy);}
.toggle-label p{font-size:12px;color:var(--gray4);margin-top:2px;}
.toggle{position:relative;width:44px;height:24px;flex-shrink:0;}
.toggle input{opacity:0;width:0;height:0;}
.toggle-slider{position:absolute;inset:0;background:var(--gray3);border-radius:24px;cursor:pointer;transition:.2s;}
.toggle-slider::before{content:'';position:absolute;width:18px;height:18px;left:3px;bottom:3px;background:white;border-radius:50%;transition:.2s;}
.toggle input:checked + .toggle-slider{background:var(--accent);}
.toggle input:checked + .toggle-slider::before{transform:translateX(20px);}
.preset-btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;}
.preset-btn{padding:5px 14px;border-radius:6px;border:1px solid var(--gray3);background:white;font-size:12px;cursor:pointer;transition:all .15s;font-family:'Noto Sans KR',sans-serif;}
.preset-btn:hover{border-color:var(--accent);color:var(--accent);}
.preset-btn.active{border-color:var(--accent);background:#E3EDFF;color:var(--blue);font-weight:700;}
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="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">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html" class="active">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">시스템 설정</h2>
<!-- 신고 공개 정책 -->
<div class="card" style="max-width:560px">
<div class="card-title">📋 신고 공개 정책</div>
<div class="alert alert-info" style="margin-bottom:14px">
신고 접수 시 정비사에게 공개하는 방식을 선택합니다.
</div>
<div class="form-group">
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-immediate">
<input type="radio" name="policy" value="immediate" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
<div>
<div style="font-weight:700">⚡ 즉시 공개 (기본 권장)</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">신고 접수 즉시 정비사 목록에 표시됩니다. 빠른 대응이 가능합니다.</div>
</div>
</label>
<label class="check-item" style="display:flex;gap:12px;padding:14px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-approval">
<input type="radio" name="policy" value="admin_approval" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
<div>
<div style="font-weight:700">🔒 관리자 승인 후 공개</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">관리자가 신고를 확인·승인한 후 정비사에게 공개됩니다. 중복·허위 신고 방지에 유리합니다.</div>
</div>
</label>
</div>
<div id="saveOk" class="alert alert-success" style="display:none">설정이 저장되었습니다.</div>
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:4px">전체 설정 저장</button>
</div>
<!-- 이미지 압축 설정 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🖼️ 사진 업로드 압축 설정</div>
<div class="alert alert-info" style="margin-bottom:16px">
신고·조치 사진 업로드 시 브라우저에서 자동으로 압축합니다.<br>
서버 저장 용량 절약 및 업로드 속도를 개선합니다.
</div>
<!-- 압축 ON/OFF -->
<div class="toggle-row">
<div class="toggle-label">
<h4>📸 자동 압축 사용</h4>
<p>업로드 전 이미지를 자동으로 리사이즈·압축합니다</p>
</div>
<label class="toggle">
<input type="checkbox" id="compressEnabled">
<span class="toggle-slider"></span>
</label>
</div>
<!-- 최대 해상도 -->
<div class="toggle-row" style="flex-direction:column;align-items:flex-start;gap:12px;">
<div class="toggle-label">
<h4>📐 최대 해상도 (긴 변 기준)</h4>
<p>이 픽셀 수를 초과하면 비율 유지하며 축소합니다</p>
</div>
<div style="width:100%">
<div class="preset-btns" id="presetBtns">
<button class="preset-btn" onclick="setMaxPx(640)" data-px="640" >640px <small style="color:var(--gray4)">저화질</small></button>
<button class="preset-btn" onclick="setMaxPx(1024)" data-px="1024">1024px <small style="color:var(--gray4)">권장 ★</small></button>
<button class="preset-btn" onclick="setMaxPx(1920)" data-px="1920">1920px <small style="color:var(--gray4)">FHD</small></button>
<button class="preset-btn" onclick="setMaxPx(2560)" data-px="2560">2560px <small style="color:var(--gray4)">QHD</small></button>
<button class="preset-btn" onclick="setMaxPx(3840)" data-px="3840">3840px <small style="color:var(--gray4)">4K</small></button>
</div>
<div class="range-wrap" style="margin-top:10px;">
<input type="range" id="maxPx" min="320" max="3840" step="64"
value="1024" oninput="syncMaxPx(this.value)">
<span class="range-val" id="maxPxVal">1024px</span>
</div>
</div>
</div>
<!-- JPEG 품질 -->
<div class="toggle-row" style="flex-direction:column;align-items:flex-start;gap:10px;border-bottom:none;">
<div class="toggle-label">
<h4>🎨 JPEG 압축 품질</h4>
<p>높을수록 화질 좋고 용량 큼 / 낮을수록 용량 작고 화질 저하</p>
</div>
<div style="width:100%">
<div class="preset-btns" id="qualityPresets">
<button class="preset-btn" onclick="setQuality(60)" data-q="60" >60% <small style="color:var(--gray4)">압축 최대</small></button>
<button class="preset-btn" onclick="setQuality(75)" data-q="75" >75% <small style="color:var(--gray4)">균형</small></button>
<button class="preset-btn" onclick="setQuality(85)" data-q="85" >85% <small style="color:var(--gray4)">권장 ★</small></button>
<button class="preset-btn" onclick="setQuality(95)" data-q="95" >95% <small style="color:var(--gray4)">고화질</small></button>
</div>
<div class="range-wrap" style="margin-top:10px;">
<input type="range" id="quality" min="30" max="100" step="5"
value="85" oninput="syncQuality(this.value)">
<span class="range-val" id="qualityVal">85%</span>
</div>
</div>
</div>
<!-- 현재 효과 미리보기 텍스트 -->
<div id="effectDesc" style="background:var(--gray1);border-radius:8px;padding:12px 14px;font-size:13px;color:var(--text);margin-top:4px;line-height:1.7;">
</div>
<div id="imgSaveOk" class="alert alert-success" style="display:none;margin-top:12px">이미지 설정이 저장되었습니다.</div>
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:12px">전체 설정 저장</button>
</div>
<!-- 비밀번호 변경 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🔑 내 비밀번호 변경</div>
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="curPw"></div>
<div class="form-group"><label>새 비밀번호</label><input type="password" id="newPw"></div>
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="newPw2"></div>
<div id="pwErr" class="alert alert-danger" style="display:none"></div>
<div id="pwOk" class="alert alert-success" style="display:none">비밀번호가 변경되었습니다.</div>
<button class="btn btn-outline" onclick="changePw()">비밀번호 변경</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function syncMaxPx(v) {
document.getElementById('maxPxVal').textContent = v + 'px';
updatePresetBtns('presetBtns', 'data-px', v);
updateEffect();
}
function syncQuality(v) {
document.getElementById('qualityVal').textContent = v + '%';
updatePresetBtns('qualityPresets', 'data-q', v);
updateEffect();
}
function setMaxPx(v) {
document.getElementById('maxPx').value = v;
syncMaxPx(v);
}
function setQuality(v) {
document.getElementById('quality').value = v;
syncQuality(v);
}
function updatePresetBtns(containerId, attr, val) {
document.querySelectorAll(`#${containerId} .preset-btn`).forEach(b => {
b.classList.toggle('active', b.getAttribute(attr) == val);
});
}
function updateEffect() {
const enabled = document.getElementById('compressEnabled').checked;
const px = parseInt(document.getElementById('maxPx').value);
const q = parseInt(document.getElementById('quality').value);
const el = document.getElementById('effectDesc');
if (!enabled) {
el.innerHTML = '⚪ 압축 비활성 — 원본 파일 그대로 업로드됩니다.';
el.style.color = 'var(--gray4)';
return;
}
// 대략적 용량 절약 예측 (12MP 스마트폰 사진 기준 ~8MB)
const areaRatio = Math.min(1, (px * px) / (4032 * 3024));
const estMB = (8 * areaRatio * (q / 100) * 0.6).toFixed(1);
el.innerHTML = `✅ <strong>${px}px / JPEG ${q}%</strong> 로 압축<br>
스마트폰 고화질 사진(약 8MB) 기준 → 업로드 약 <strong>${estMB}MB</strong> 예상<br>
<span style="color:var(--green);font-size:12px">업로드 속도 향상 + 서버 용량 절약</span>`;
el.style.color = 'var(--text)';
}
async function load() {
const s = await API.get('/settings');
const policy = s.report_visibility_policy || 'immediate';
document.querySelector(`input[value="${policy}"]`).checked = true;
updateLabels();
const enabled = s.image_compress_enabled !== 'false';
document.getElementById('compressEnabled').checked = enabled;
const px = parseInt(s.image_max_px || '1024');
document.getElementById('maxPx').value = px;
syncMaxPx(px);
const q = parseInt(s.image_quality || '85');
document.getElementById('quality').value = q;
syncQuality(q);
updateEffect();
}
function updateLabels() {
document.querySelectorAll('input[name="policy"]').forEach(r => {
const lbl = r.closest('label');
lbl.style.borderColor = r.checked ? 'var(--accent)' : 'var(--gray3)';
lbl.style.background = r.checked ? '#E3EDFF' : 'white';
});
}
document.querySelectorAll('input[name="policy"]').forEach(r => r.addEventListener('change', updateLabels));
document.getElementById('compressEnabled').addEventListener('change', updateEffect);
async function saveAll() {
const fd = new FormData();
fd.append('report_visibility_policy', document.querySelector('input[name="policy"]:checked').value);
fd.append('image_compress_enabled', document.getElementById('compressEnabled').checked ? 'true' : 'false');
fd.append('image_max_px', document.getElementById('maxPx').value);
fd.append('image_quality', document.getElementById('quality').value);
await API.put('/settings', fd);
const ok = document.getElementById('saveOk');
ok.style.display = 'block';
setTimeout(() => ok.style.display = 'none', 2500);
const ok2 = document.getElementById('imgSaveOk');
ok2.style.display = 'block';
setTimeout(() => ok2.style.display = 'none', 2500);
}
async function changePw() {
const cur = document.getElementById('curPw').value;
const nw = document.getElementById('newPw').value;
const nw2 = document.getElementById('newPw2').value;
const errEl = document.getElementById('pwErr');
errEl.style.display = 'none';
if (!cur || !nw) { errEl.textContent = '현재·새 비밀번호를 입력하세요.'; errEl.style.display = 'block'; return; }
if (nw !== nw2) { errEl.textContent = '새 비밀번호가 일치하지 않습니다.'; errEl.style.display = 'block'; return; }
const fd = new FormData(); fd.append('current_password', cur); fd.append('new_password', nw);
try {
await API.patch('/accounts/me/password', fd);
document.getElementById('pwOk').style.display = 'block';
['curPw','newPw','newPw2'].forEach(id => document.getElementById(id).value = '');
setTimeout(() => document.getElementById('pwOk').style.display = 'none', 2500);
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
}
load();
</script>
</body>
</html>