초기 커밋 - 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>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>로그인 — EV AS 관리</title>
<link rel="stylesheet" href="/css/style.css">
<style>
body{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--navy);}
.login-box{background:white;border-radius:14px;padding:40px 36px;width:100%;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,.3);}
.login-logo{text-align:center;margin-bottom:28px;}
.login-logo h1{font-size:22px;font-weight:900;color:var(--navy);}
.login-logo p{font-size:12px;color:var(--gray4);margin-top:4px;}
.login-box .form-group{margin-bottom:14px;}
#err{color:var(--red);font-size:13px;text-align:center;min-height:18px;margin-bottom:8px;}
</style>
</head>
<body>
<div class="login-box">
<div class="login-logo">
<h1>⚡ EV AS 관리</h1>
<p>cs.byunc.com</p>
</div>
<div class="form-group">
<label>아이디</label>
<input type="text" id="username" placeholder="아이디 입력" autofocus>
</div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="password" placeholder="비밀번호 입력">
</div>
<div id="err"></div>
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
async function doLogin() {
const u = document.getElementById('username').value.trim();
const p = document.getElementById('password').value;
if (!u || !p) { document.getElementById('err').textContent = '아이디와 비밀번호를 입력하세요.'; return; }
document.getElementById('loginBtn').disabled = true;
document.getElementById('err').textContent = '';
try {
const fd = new FormData();
fd.append('username', u); fd.append('password', p);
const res = await fetch('/api/auth/login', { method:'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
Auth.save(data.access_token, data.role, data.name, data.user_id);
if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html';
} catch(e) {
document.getElementById('err').textContent = e.message;
document.getElementById('loginBtn').disabled = false;
}
}
document.getElementById('loginBtn').addEventListener('click', doLogin);
document.getElementById('password').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
</script>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!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">메뉴</div>
<a href="/pages/manufacturer/dashboard.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="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="registered">등록</option><option value="reviewing">검토중</option>
<option value="developing">개발중</option><option value="deployed">배포완료</option>
<option value="done">완료</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>연결AS</th><th>상태</th><th>SW배포목표일</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(['manufacturer']); Auth.renderNav(document.getElementById('navUser'));
const CAT={sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
const PRI={urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
async function load() {
const imps = await API.get('/improvements?status='+document.getElementById('fStatus').value);
const counts = {registered:0,reviewing:0,developing:0,deployed:0,done:0};
imps.forEach(i => { if (counts[i.status]!==undefined) counts[i.status]++; });
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="stat-num">${imps.length}</div><div class="stat-label">전체</div></div>
<div class="stat warn"><div class="stat-num">${counts.registered+counts.reviewing}</div><div class="stat-label">검토 필요</div></div>
<div class="stat warn"><div class="stat-num">${counts.developing}</div><div class="stat-label">개발중</div></div>
<div class="stat good"><div class="stat-num">${counts.deployed+counts.done}</div><div class="stat-label">완료/배포</div></div>`;
document.getElementById('empty').style.display = imps.length ? 'none' : 'block';
document.getElementById('tbody').innerHTML = imps.map(i=>`
<tr onclick="location.href='/pages/manufacturer/improvement.html?id=${i.id}'">
<td>#${i.id}</td>
<td><strong>${i.title}</strong></td>
<td>${CAT[i.category]||i.category}</td>
<td>${PRI[i.priority]||i.priority}</td>
<td><span class="badge s-pending">${i.report_count}건</span></td>
<td>${Auth.statusBadge(i.status)}</td>
<td>${i.sw_deploy_target||'-'}</td>
<td>${Auth.fmtDt(i.created_at)}</td>
</tr>`).join('');
}
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:760px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/manufacturer/dashboard.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(['manufacturer']); 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=['reviewing','developing','deployed'];
const STATUS_LABEL={registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
async function load() {
const imp = await API.get('/improvements/'+id);
document.getElementById('pageTitle').textContent = `개선항목 #${imp.id}${imp.title}`;
document.getElementById('content').innerHTML = `
<div class="card">
<div class="card-title">📋 개선 요청 내용</div>
<table class="no-hover" style="font-size:13px">
<tr><td style="color:var(--gray4);width:100px">제목</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>${Auth.statusBadge(imp.status)}</td></tr>
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
</table>
<div style="margin-top:14px">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">개선 요청 내용</div>
<div style="background:var(--gray1);padding:14px;border-radius:6px;font-size:13px;white-space:pre-wrap;line-height:1.7">${imp.description}</div>
</div>
${imp.attachments.length?`
<div style="margin-top:14px">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">첨부 보고서</div>
${imp.attachments.map(a=>`<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-right:6px;margin-bottom:6px">📄 ${a.name||'파일'}</a>`).join('')}
</div>`:''}
</div>
<div class="card">
<div class="card-title">🔗 관련 AS 신고</div>
${imp.report_ids.length?imp.report_ids.map(rid=>`
<div style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;font-size:13px">
신고 #${rid}
</div>`).join(''):'<div style="color:var(--gray4);font-size:13px">연결된 신고 없음</div>'}
</div>
<div class="card">
<div class="card-title">📝 진행 상태 업데이트</div>
<div class="alert alert-info" style="margin-bottom:14px">
아래에서 진행 상태를 업데이트하고 저장해 주세요. 변경 이력이 자동으로 기록됩니다.
</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>
<textarea id="memo" rows="3" placeholder="현재 진행 상황, 예상 완료일, 이슈 등을 기재해 주세요.">${imp.manufacturer_memo||''}</textarea>
</div>
<div id="saveOk" class="alert alert-success" style="display:none">저장되었습니다.</div>
<button class="btn btn-primary" onclick="save()">저장</button>
</div>
<div class="card">
<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 save() {
const fd = new FormData();
fd.append('status', document.getElementById('newStatus').value);
fd.append('memo', document.getElementById('memo').value);
fd.append('manufacturer_memo', document.getElementById('memo').value);
const d = document.getElementById('deployedAt').value;
if (d) fd.append('sw_deployed_at', d);
await API.patch('/improvements/'+id+'/status', fd);
const ok = document.getElementById('saveOk');
ok.style.display = 'block';
setTimeout(() => { ok.style.display='none'; load(); }, 1200);
}
load();
</script></body></html>

View File

@@ -0,0 +1,70 @@
<!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">메뉴</div>
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<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;">
<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>
<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">처리 대기 중인 AS가 없습니다.</div>
</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'));
async function load() {
const status = document.getElementById('filterStatus').value;
const rows = await API.get('/repairs/pending' + (status ? '?status='+status : ''));
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}'">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>
<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('');
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,184 @@
<!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>
.upload-area{border:2px dashed var(--gray3);border-radius:8px;padding:12px;text-align:center;cursor:pointer;color:var(--gray4);font-size:12px;transition:border-color .15s;margin-bottom:6px;}
.upload-area:hover{border-color:var(--accent);}
.photo-preview{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.photo-preview img{width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);}
.photo-info{font-size:11px;margin-top:4px;min-height:14px;color:var(--gray4);}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="main" 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>
<div id="chargerCard" class="card"></div>
<div class="card">
<div class="card-title">📋 동일 충전기 신고 목록 (중복 선택 가능)</div>
<div id="reportList"></div>
</div>
<div class="card">
<div class="card-title">🔧 조치 내역 입력</div>
<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>
</div>
<div class="form-group">
<label>조치 상세 내용 <span class="req">*</span></label>
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
</div>
<!-- 조치 전 사진 -->
<div class="form-row">
<div class="form-group">
<label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
<label class="upload-area" for="photosBefore">📷 촬영 또는 앨범 선택</label>
<input type="file" id="photosBefore" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="previewBefore"></div>
<div class="photo-info" id="infoBefore"></div>
</div>
<div class="form-group">
<label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
<label class="upload-area" for="photosAfter">📷 촬영 또는 앨범 선택</label>
<input type="file" id="photosAfter" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="previewAfter"></div>
<div class="photo-info" id="infoAfter"></div>
</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="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">조치 완료 저장</button>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/imageCompress.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search);
const chargerId = params.get('charger_id');
const initReportId = params.get('report_id');
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() {
const charger = await API.get('/chargers/' + chargerId);
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>${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>`;
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;
}
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}"
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
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>`
: ''}
</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'; }
}
// 이미지 압축 + 다중 선택 프리뷰
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; }
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 = '저장 중...';
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);
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';
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '조치 완료 저장';
}
});
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,160 @@
<!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:640px;margin:0 auto;">
<div style="margin-bottom:14px;">
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
</div>
<div id="chargerCard" class="card"></div>
<div class="card">
<div class="card-title">📋 동일 충전기 신고 목록 (중복 선택 가능)</div>
<div id="reportList"></div>
</div>
<div class="card">
<div class="card-title">🔧 조치 내역 입력</div>
<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>
</div>
<div class="form-group">
<label>조치 상세 내용 <span class="req">*</span></label>
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>📷 조치 전 사진</label>
<input type="file" id="photosBefore" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="previewBefore"></div>
</div>
<div class="form-group">
<label>📷 조치 후 사진</label>
<input type="file" id="photosAfter" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="previewAfter"></div>
</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="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">조치 완료 저장</button>
</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 params = new URLSearchParams(location.search);
const chargerId = params.get('charger_id');
const initReportId = params.get('report_id');
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() {
const charger = await API.get('/chargers/' + chargerId);
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>${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>`;
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; }
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}"
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
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>`:''}
</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'; }
}
['photosBefore','photosAfter'].forEach(id => {
const prevId = id === 'photosBefore' ? 'previewBefore' : 'previewAfter';
document.getElementById(id).addEventListener('change', function() {
const prev = document.getElementById(prevId);
prev.innerHTML = '';
Array.from(this.files).forEach(f => {
const img = document.createElement('img'); img.src = URL.createObjectURL(f); prev.appendChild(img);
});
});
});
document.getElementById('submitBtn').addEventListener('click', async () => {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
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 = '저장 중...';
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);
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';
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '조치 완료 저장';
}
});
function showErr(msg) {
const el = document.getElementById('formErr'); el.textContent = msg; el.style.display = 'block';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!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">
<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>
<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>
</div>
</div>
</div>
<script src="https://unpkg.com/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const scanner = new Html5Qrcode("reader");
scanner.start({ facingMode: "environment" },
{ fps: 10, qrbox: { width: 250, height: 250 } },
qrText => {
scanner.stop();
document.getElementById('result').style.display = 'block';
document.getElementById('result').textContent = '인식됨: ' + qrText + ' — 이동 중...';
// URL에서 charger_id 추출
try {
const url = new URL(qrText);
const parts = url.pathname.split('/');
const chargerId = parts[parts.length - 1];
setTimeout(() => location.href = `/pages/mechanic/repair.html?charger_id=${chargerId}`, 800);
} catch {
setTimeout(() => location.href = `/pages/mechanic/repair.html?charger_id=${qrText}`, 800);
}
},
() => {}
).catch(() => {
document.getElementById('reader').innerHTML = '<div class="alert alert-warn">카메라 접근이 거부되었습니다. 직접 입력을 이용해 주세요.</div>';
});
function goManual() {
const id = document.getElementById('manualId').value.trim();
if (!id) return;
location.href = `/pages/mechanic/repair.html?charger_id=${id}`;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,522 @@
<!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>
body { background: var(--gray1); }
.report-wrap { max-width: 480px; margin: 0 auto; padding: 20px 16px 40px; }
/* ── 충전기 정보 헤더 ── */
.charger-info {
background: var(--navy);
color: white;
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 14px;
}
.charger-info h2 { font-size: 16px; margin-bottom: 8px; color: var(--accent); }
.charger-info .row {
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255,255,255,.75); margin-top: 4px;
}
/* ── 현황 섹션 ── */
.status-section {
border-radius: 10px;
overflow: hidden;
margin-bottom: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.status-header {
background: #1A2B4A;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.status-header-left { display: flex; align-items: center; gap: 8px; }
.status-header h3 { font-size: 14px; font-weight: 700; color: white; }
.status-badge-count {
background: var(--orange);
color: white;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
}
.status-toggle-icon { font-size: 12px; color: rgba(255,255,255,.5); transition: transform .2s; }
.status-toggle-icon.open { transform: rotate(180deg); }
.status-body { background: white; }
/* ── 개별 신고 현황 카드 ── */
.report-status-card {
padding: 14px 16px;
border-bottom: 1px solid var(--gray2);
position: relative;
}
.report-status-card:last-child { border-bottom: none; }
.rsc-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.rsc-num { font-size: 12px; font-weight: 700; color: var(--navy2); }
.rsc-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
}
.rsc-badge.pending_approval { background: #FFF3CD; color: #856404; }
.rsc-badge.pending { background: #DBEAFE; color: #1565C0; }
.rsc-badge.in_progress { background: #FEF3C7; color: #B45309; }
.rsc-badge.waiting { background: #FFE4E4; color: #C0392B; }
.rsc-badge.revisit { background: #EDE9FE; color: #5B21B6; }
.rsc-issues {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 8px;
}
.rsc-issue-tag {
background: var(--gray1);
color: var(--text2);
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--gray2);
}
.rsc-meta {
font-size: 11px;
color: var(--gray4);
display: flex;
flex-direction: column;
gap: 3px;
}
.rsc-mechanic {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--green);
font-weight: 600;
margin-top: 6px;
padding: 6px 10px;
background: #E8F8F2;
border-radius: 6px;
}
.rsc-progress-bar {
height: 4px;
background: var(--gray2);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.rsc-progress-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width .4s;
}
/* ── 접기/펼치기 버튼 ── */
.collapse-body { overflow: hidden; transition: max-height .3s ease; }
.collapse-body.collapsed { max-height: 0 !important; }
/* ── 신규 신고 폼 ── */
.section {
background: white;
border-radius: 10px;
padding: 18px;
margin-bottom: 14px;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.section h3 {
font-size: 14px; font-weight: 700; color: var(--navy);
border-left: 3px solid var(--accent); padding-left: 9px; margin-bottom: 12px;
}
.issue-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.issue-btn {
padding: 10px; border: 1px solid var(--gray3); border-radius: 7px;
background: white; cursor: pointer; font-size: 13px; text-align: center;
transition: all .15s;
}
.issue-btn.sel { background: #E3EDFF; border-color: var(--accent); font-weight: 700; color: var(--blue); }
.upload-area {
border: 2px dashed var(--gray3); border-radius: 8px; padding: 14px;
text-align: center; cursor: pointer; color: var(--gray4); font-size: 13px;
transition: border-color .15s; margin-bottom: 6px; display: block;
}
.upload-area:hover { border-color: var(--accent); }
.photo-preview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.photo-preview img {
width: 80px; height: 80px; object-fit: cover;
border-radius: 6px; border: 1px solid var(--gray3);
}
.photo-info { font-size: 11px; margin-top: 4px; min-height: 16px; }
/* ── 결과 화면 ── */
#resultBox {
background: var(--navy); color: white;
border-radius: 10px; padding: 24px; text-align: center; display: none;
}
#resultBox h2 { color: var(--green); font-size: 20px; margin-bottom: 10px; }
</style>
</head>
<body>
<div class="report-wrap">
<!-- 충전기 정보 -->
<div id="chargerInfo" class="charger-info">
<h2>⚡ 충전기 정보 로딩 중...</h2>
</div>
<!-- ★ 현재 접수 현황 섹션 -->
<div class="status-section" id="statusSection" style="display:none">
<div class="status-header" onclick="toggleStatus()">
<div class="status-header-left">
<span>🔔</span>
<h3>현재 접수된 신고 현황</h3>
<span class="status-badge-count" id="statusCount">0</span>
</div>
<span class="status-toggle-icon open" id="statusToggleIcon"></span>
</div>
<div class="collapse-body" id="statusBody">
<div class="status-body" id="statusList"></div>
<div style="padding:12px 16px;background:#F4F7FB;border-top:1px solid var(--gray2);">
<p style="font-size:12px;color:var(--gray4);line-height:1.7">
📌 동일 고장이 이미 접수되어 처리 중인 경우 추가 신고는 필요 없습니다.<br>
신고가 미처리 상태이거나 다른 문제가 있다면 아래에서 새로 신고해 주세요.
</p>
</div>
</div>
</div>
<!-- 신고 없음 안내 -->
<div id="noReportNotice" style="display:none;background:#E8F8F2;border:1px solid var(--green);border-radius:10px;padding:12px 16px;margin-bottom:14px;font-size:13px;color:#00531A;">
✅ 이 충전기에 현재 접수된 신고가 없습니다. 고장이 확인되면 아래에서 신고해 주세요.
</div>
<!-- 신고 폼 -->
<div id="mainForm">
<div class="section">
<h3>📍 신고 위치</h3>
<div id="gpsStatus" class="alert alert-info">위치 정보 수집 중...</div>
<input type="hidden" id="gpsLat">
<input type="hidden" id="gpsLng">
</div>
<div class="section">
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
<div class="issue-grid" id="issueGrid"></div>
<div id="errorCodeWrap" style="margin-top:10px;display:none;">
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">
</div>
<div id="etcWrap" style="margin-top:10px;display:none;">
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
</div>
</div>
<div class="section">
<h3>🕐 문제 발생 시각</h3>
<input type="datetime-local" id="occurredAt">
<div style="font-size:11px;color:var(--gray4);margin-top:4px">언제부터 문제가 발생했나요? (선택)</div>
</div>
<div class="section">
<h3>📷 사진 첨부</h3>
<div class="form-group">
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
충전기 사진 <span style="color:var(--red)">*필수</span>
<span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span>
</label>
<label class="upload-area" for="chargerPhoto">
📷 탭하여 촬영하거나 앨범에서 선택<br>
<span style="font-size:11px">여러 장 동시 선택 가능</span>
</label>
<input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="chargerPreview"></div>
<div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div>
</div>
<div class="form-group" style="margin-top:14px">
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span>
</label>
<label class="upload-area" for="carPhoto">📷 탭하여 촬영하거나 앨범에서 선택</label>
<input type="file" id="carPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="carPreview"></div>
<div class="photo-info" id="carInfo" style="color:var(--gray4)"></div>
</div>
</div>
<div class="section">
<h3>📝 상세 설명 (선택)</h3>
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
</div>
<div class="section">
<h3>📞 연락처 (선택)</h3>
<input type="tel" id="contact" placeholder="010-0000-0000">
<div style="margin-top:10px;display:flex;align-items:flex-start;gap:8px;">
<input type="checkbox" id="consent" style="width:auto;margin-top:2px;accent-color:var(--accent)">
<label for="consent" style="font-size:12px;color:var(--gray4);cursor:pointer">
개인정보(연락처)를 AS 처리 목적으로 수집·이용하는 것에 동의합니다.
</label>
</div>
</div>
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">신고 접수하기</button>
</div>
<div id="resultBox">
<h2>✅ 신고 접수 완료</h2>
<p id="resultMsg"></p>
<p style="margin-top:12px;font-size:13px;color:rgba(255,255,255,.6)">빠른 시간 내에 처리하겠습니다.</p>
<button onclick="location.reload()"
style="margin-top:16px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.3);color:white;padding:8px 20px;border-radius:8px;font-size:13px;cursor:pointer;">
🔄 현황 다시 보기
</button>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/imageCompress.js"></script>
<script>
const ISSUES = [
{key:'충전불가', label:'⚡ 충전 불가'},
{key:'화면오류', label:'🖥 화면 오류'},
{key:'케이블불량',label:'🔌 케이블 불량'},
{key:'결제오류', label:'💳 결제 오류'},
{key:'외관손상', label:'🔨 외관 손상'},
{key:'에러발생', label:'⚠️ 에러 발생'},
{key:'기타', label:'📋 기타'},
];
const STATUS_ICON = {
pending_approval: '🕐',
pending: '📋',
in_progress: '🔧',
waiting: '⏳',
revisit: '🔄',
};
const PROGRESS_PCT = {
pending_approval: 10,
pending: 25,
in_progress: 65,
waiting: 50,
revisit: 80,
};
const selected = new Set();
const chargerId = location.pathname.split('/').pop();
let isStatusOpen = true;
// ── 충전기 정보 로드 ──
async function loadCharger() {
try {
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
document.getElementById('chargerInfo').innerHTML = `
<h2>⚡ ${c.name}</h2>
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
<div class="row"><span>종류</span><span>${c.charger_type || '-'}</span></div>
<div class="row"><span>CPO</span><span>${c.cpo_name || '-'}</span></div>
<div class="row"><span>설치일</span><span>${c.installed_at || '-'}</span></div>
`;
} catch {
document.getElementById('chargerInfo').innerHTML =
'<h2 style="color:#ff8888">충전기 정보를 불러올 수 없습니다.</h2>';
}
}
// ── 현재 접수 현황 로드 ──
async function loadStatus() {
try {
const reports = await fetch('/api/reports/public/' + chargerId).then(r => r.json());
if (!reports.length) {
document.getElementById('noReportNotice').style.display = 'block';
return;
}
document.getElementById('statusSection').style.display = 'block';
document.getElementById('statusCount').textContent = reports.length + '건';
const list = document.getElementById('statusList');
list.innerHTML = reports.map(r => {
const pct = PROGRESS_PCT[r.status] || 20;
const icon = STATUS_ICON[r.status] || '📋';
const dt = r.reported_at
? new Date(r.reported_at).toLocaleString('ko-KR',
{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
: '';
const mechHtml = r.mechanic_name
? `<div class="rsc-mechanic">🔧 ${r.mechanic_name} 정비사가 처리 중입니다</div>`
: '';
const detailHtml = r.issue_detail
? `<div style="font-size:12px;color:var(--text2);margin-top:4px;padding:6px 8px;background:var(--gray1);border-radius:5px;">${r.issue_detail}</div>`
: '';
return `
<div class="report-status-card">
<div class="rsc-top">
<span class="rsc-num">#${r.id} · ${dt}</span>
<span class="rsc-badge ${r.status}">${icon} ${r.status_label}</span>
</div>
<div class="rsc-issues">
${(r.issue_types || []).map(t =>
`<span class="rsc-issue-tag">${t}</span>`).join('')}
</div>
${detailHtml}
${r.photo_count > 0
? `<div style="font-size:11px;color:var(--gray4);margin-top:4px;">📷 사진 ${r.photo_count}장 첨부됨</div>`
: ''}
${mechHtml}
<div class="rsc-progress-bar">
<div class="rsc-progress-fill" style="width:${pct}%"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--gray4);margin-top:4px;">
<span>접수</span><span>처리중</span><span>완료</span>
</div>
</div>`;
}).join('');
} catch(e) {
console.warn('현황 로드 실패:', e);
}
}
// ── 현황 접기/펼치기 ──
function toggleStatus() {
isStatusOpen = !isStatusOpen;
const body = document.getElementById('statusBody');
const icon = document.getElementById('statusToggleIcon');
if (isStatusOpen) {
body.style.maxHeight = body.scrollHeight + 'px';
icon.classList.add('open');
} else {
body.style.maxHeight = '0';
icon.classList.remove('open');
}
}
// 초기 펼침 높이 설정
function initCollapseHeight() {
const body = document.getElementById('statusBody');
if (body) body.style.maxHeight = body.scrollHeight + 'px';
}
// ── GPS ──
navigator.geolocation?.getCurrentPosition(
pos => {
document.getElementById('gpsLat').value = pos.coords.latitude;
document.getElementById('gpsLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').textContent =
`📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`;
document.getElementById('gpsStatus').className = 'alert alert-success';
},
() => {
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
document.getElementById('gpsStatus').className = 'alert alert-warn';
}
);
// ── 문제 유형 버튼 ──
const grid = document.getElementById('issueGrid');
ISSUES.forEach(issue => {
const btn = document.createElement('button');
btn.className = 'issue-btn';
btn.textContent = issue.label;
btn.type = 'button';
btn.onclick = () => {
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
else { selected.add(issue.key); btn.classList.add('sel'); }
document.getElementById('errorCodeWrap').style.display =
selected.has('에러발생') ? 'block' : 'none';
document.getElementById('etcWrap').style.display =
selected.has('기타') ? 'block' : 'none';
};
grid.appendChild(btn);
});
// ── 이미지 압축 + 다중 선택 ──
ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2');
ImageCompressor.setupPreview('carPhoto', 'carPreview', 'carInfo');
// ── 신고 제출 ──
document.getElementById('submitBtn').addEventListener('click', async () => {
const issues = [...selected];
if (!issues.length) { showErr('문제 유형을 1개 이상 선택해 주세요.'); return; }
const chargerFiles = document.getElementById('chargerPhoto').files;
if (!chargerFiles.length) { showErr('충전기 사진을 1장 이상 첨부해 주세요.'); return; }
const contact = document.getElementById('contact').value.trim();
const consent = document.getElementById('consent').checked;
if (contact && !consent) {
showErr('연락처를 입력한 경우 개인정보 수집 동의가 필요합니다.'); return;
}
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...';
const fd = new FormData();
fd.append('charger_id', chargerId);
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('detail').value);
fd.append('error_code', document.getElementById('errorCode').value);
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
fd.append('contact', contact);
fd.append('consent', consent);
fd.append('gps_lat', document.getElementById('gpsLat').value || '');
fd.append('gps_lng', document.getElementById('gpsLng').value || '');
Array.from(chargerFiles).forEach(f => fd.append('photos', f));
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports', { method: 'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
document.getElementById('mainForm').style.display = 'none';
document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`;
// 현황 새로고침
document.getElementById('statusSection').style.display = 'none';
document.getElementById('noReportNotice').style.display = 'none';
await loadStatus();
initCollapseHeight();
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '신고 접수하기';
}
});
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
el.scrollIntoView({ behavior: 'smooth' });
}
// ── 초기 로드 ──
(async () => {
await Promise.all([loadCharger(), loadStatus()]);
initCollapseHeight();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,214 @@
<!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>
body{background:var(--gray1);}
.report-wrap{max-width:480px;margin:0 auto;padding:20px 16px 40px;}
.charger-info{background:var(--navy);color:white;border-radius:10px;padding:16px 18px;margin-bottom:18px;}
.charger-info h2{font-size:16px;margin-bottom:8px;color:var(--accent);}
.charger-info .row{display:flex;justify-content:space-between;font-size:12px;color:rgba(255,255,255,.75);margin-top:4px;}
.section{background:white;border-radius:10px;padding:18px;margin-bottom:14px;box-shadow:0 2px 6px rgba(0,0,0,.06);}
.section h3{font-size:14px;font-weight:700;color:var(--navy);border-left:3px solid var(--accent);padding-left:9px;margin-bottom:12px;}
.issue-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.issue-btn{padding:10px;border:1px solid var(--gray3);border-radius:7px;background:white;cursor:pointer;font-size:13px;text-align:center;transition:all .15s;}
.issue-btn.sel{background:#E3EDFF;border-color:var(--accent);font-weight:700;color:var(--blue);}
#submitBtn{margin-top:4px;}
#resultBox{background:var(--navy);color:white;border-radius:10px;padding:24px;text-align:center;display:none;}
#resultBox h2{color:var(--green);font-size:20px;margin-bottom:10px;}
</style>
</head>
<body>
<div class="report-wrap">
<div id="chargerInfo" class="charger-info">
<h2>⚡ 충전기 정보 로딩 중...</h2>
</div>
<div id="mainForm">
<div class="section">
<h3>📍 신고 위치</h3>
<div id="gpsStatus" class="alert alert-info">위치 정보 수집 중...</div>
<input type="hidden" id="gpsLat">
<input type="hidden" id="gpsLng">
</div>
<div class="section">
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
<div class="issue-grid" id="issueGrid"></div>
<div id="errorCodeWrap" style="margin-top:10px;display:none;">
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">
</div>
<div id="etcWrap" style="margin-top:10px;display:none;">
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
</div>
</div>
<div class="section">
<h3>🕐 문제 발생 시각</h3>
<input type="datetime-local" id="occurredAt">
<div style="font-size:11px;color:var(--gray4);margin-top:4px">언제부터 문제가 발생했나요? (선택)</div>
</div>
<div class="section">
<h3>📷 사진 첨부</h3>
<div class="form-group">
<label>충전기 사진 <span style="color:var(--red)">*필수</span></label>
<input type="file" id="chargerPhoto" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="chargerPreview"></div>
</div>
<div class="form-group">
<label>차량 사진 (선택)</label>
<input type="file" id="carPhoto" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="carPreview"></div>
</div>
</div>
<div class="section">
<h3>📝 상세 설명 (선택)</h3>
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
</div>
<div class="section">
<h3>📞 연락처 (선택)</h3>
<input type="tel" id="contact" placeholder="010-0000-0000">
<div style="margin-top:10px;display:flex;align-items:flex-start;gap:8px;">
<input type="checkbox" id="consent" style="width:auto;margin-top:2px;accent-color:var(--accent)">
<label for="consent" style="font-size:12px;color:var(--gray4);cursor:pointer">
개인정보(연락처)를 AS 처리 목적으로 수집·이용하는 것에 동의합니다.
</label>
</div>
</div>
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">신고 접수하기</button>
</div>
<div id="resultBox">
<h2>✅ 신고 접수 완료</h2>
<p id="resultMsg"></p>
<p style="margin-top:12px;font-size:13px;color:rgba(255,255,255,.6)">
빠른 시간 내에 처리하겠습니다.</p>
</div>
</div>
<script src="/js/api.js"></script>
<script>
const ISSUES = [
{key:'충전불가',label:'⚡ 충전 불가'},
{key:'화면오류',label:'🖥 화면 오류'},
{key:'케이블불량',label:'🔌 케이블 불량'},
{key:'결제오류',label:'💳 결제 오류'},
{key:'외관손상',label:'🔨 외관 손상'},
{key:'에러발생',label:'⚠️ 에러 발생'},
{key:'기타',label:'📋 기타'},
];
const selected = new Set();
const chargerId = location.pathname.split('/').pop();
// 충전기 정보 로드
async function loadCharger() {
try {
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
document.getElementById('chargerInfo').innerHTML = `
<h2>⚡ ${c.name}</h2>
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
<div class="row"><span>종류</span><span>${c.charger_type || '-'}</span></div>
<div class="row"><span>CPO</span><span>${c.cpo_name || '-'}</span></div>
<div class="row"><span>설치일</span><span>${c.installed_at || '-'}</span></div>
`;
} catch { document.getElementById('chargerInfo').innerHTML = '<h2 style="color:#ff8888">충전기 정보를 불러올 수 없습니다.</h2>'; }
}
// GPS 수집
navigator.geolocation?.getCurrentPosition(pos => {
document.getElementById('gpsLat').value = pos.coords.latitude;
document.getElementById('gpsLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').textContent = `📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`;
document.getElementById('gpsStatus').className = 'alert alert-success';
}, () => {
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
document.getElementById('gpsStatus').className = 'alert alert-warn';
});
// 문제 유형 버튼
const grid = document.getElementById('issueGrid');
ISSUES.forEach(issue => {
const btn = document.createElement('button');
btn.className = 'issue-btn';
btn.textContent = issue.label;
btn.onclick = () => {
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
else { selected.add(issue.key); btn.classList.add('sel'); }
document.getElementById('errorCodeWrap').style.display = selected.has('에러발생') ? 'block' : 'none';
document.getElementById('etcWrap').style.display = selected.has('기타') ? 'block' : 'none';
};
grid.appendChild(btn);
});
// 사진 미리보기
function setupPreview(inputId, previewId) {
document.getElementById(inputId).addEventListener('change', function() {
const preview = document.getElementById(previewId);
preview.innerHTML = '';
Array.from(this.files).forEach(f => {
const img = document.createElement('img');
img.src = URL.createObjectURL(f);
preview.appendChild(img);
});
});
}
setupPreview('chargerPhoto', 'chargerPreview');
setupPreview('carPhoto', 'carPreview');
// 제출
document.getElementById('submitBtn').addEventListener('click', async () => {
const issues = [...selected];
if (issues.length === 0) { showErr('문제 유형을 1개 이상 선택해 주세요.'); return; }
const chargerPhotos = document.getElementById('chargerPhoto').files;
if (chargerPhotos.length === 0) { showErr('충전기 사진을 첨부해 주세요.'); return; }
const consent = document.getElementById('consent').checked;
const contact = document.getElementById('contact').value.trim();
if (contact && !consent) { showErr('연락처를 입력한 경우 개인정보 수집 동의가 필요합니다.'); return; }
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...';
const fd = new FormData();
fd.append('charger_id', chargerId);
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('detail').value);
fd.append('error_code', document.getElementById('errorCode').value);
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
fd.append('contact', contact);
fd.append('consent', consent);
fd.append('gps_lat', document.getElementById('gpsLat').value || '');
fd.append('gps_lng', document.getElementById('gpsLng').value || '');
Array.from(chargerPhotos).forEach(f => fd.append('photos', f));
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports', { method:'POST', body:fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
document.getElementById('mainForm').style.display = 'none';
document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`;
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '신고 접수하기';
}
});
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
el.scrollIntoView({behavior:'smooth'});
}
loadCharger();
</script>
</body>
</html>