초기 커밋 - EV AS 관리 시스템
This commit is contained in:
122
frontend/static/pages/admin/accounts.html
Normal file
122
frontend/static/pages/admin/accounts.html
Normal 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>
|
||||
180
frontend/static/pages/admin/charger-types.html
Normal file
180
frontend/static/pages/admin/charger-types.html
Normal 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, '"');
|
||||
}
|
||||
|
||||
/* ── 수정 모드 진입 ── */
|
||||
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>
|
||||
55
frontend/static/pages/admin/charger-types.html.bak
Normal file
55
frontend/static/pages/admin/charger-types.html.bak
Normal 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>
|
||||
131
frontend/static/pages/admin/chargers.html
Normal file
131
frontend/static/pages/admin/chargers.html
Normal 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>
|
||||
86
frontend/static/pages/admin/costs.html
Normal file
86
frontend/static/pages/admin/costs.html
Normal 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>
|
||||
93
frontend/static/pages/admin/dashboard.html
Normal file
93
frontend/static/pages/admin/dashboard.html
Normal 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>
|
||||
102
frontend/static/pages/admin/improvement-detail.html
Normal file
102
frontend/static/pages/admin/improvement-detail.html
Normal 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>
|
||||
164
frontend/static/pages/admin/improvements.html
Normal file
164
frontend/static/pages/admin/improvements.html
Normal 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>
|
||||
77
frontend/static/pages/admin/qr.html
Normal file
77
frontend/static/pages/admin/qr.html
Normal 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>
|
||||
438
frontend/static/pages/admin/report-detail.html
Normal file
438
frontend/static/pages/admin/report-detail.html
Normal 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>
|
||||
80
frontend/static/pages/admin/reports.html
Normal file
80
frontend/static/pages/admin/reports.html
Normal 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>
|
||||
269
frontend/static/pages/admin/settings.html
Normal file
269
frontend/static/pages/admin/settings.html
Normal 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>
|
||||
62
frontend/static/pages/login.html
Normal file
62
frontend/static/pages/login.html
Normal 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>
|
||||
59
frontend/static/pages/manufacturer/dashboard.html
Normal file
59
frontend/static/pages/manufacturer/dashboard.html
Normal 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>
|
||||
102
frontend/static/pages/manufacturer/improvement.html
Normal file
102
frontend/static/pages/manufacturer/improvement.html
Normal 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>
|
||||
70
frontend/static/pages/mechanic/dashboard.html
Normal file
70
frontend/static/pages/mechanic/dashboard.html
Normal 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>
|
||||
184
frontend/static/pages/mechanic/repair.html
Normal file
184
frontend/static/pages/mechanic/repair.html
Normal 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>
|
||||
160
frontend/static/pages/mechanic/repair.html.bak
Normal file
160
frontend/static/pages/mechanic/repair.html.bak
Normal 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>
|
||||
66
frontend/static/pages/mechanic/scan.html
Normal file
66
frontend/static/pages/mechanic/scan.html
Normal 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>
|
||||
522
frontend/static/pages/report.html
Normal file
522
frontend/static/pages/report.html
Normal 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>
|
||||
214
frontend/static/pages/report.html.bak
Normal file
214
frontend/static/pages/report.html.bak
Normal 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>
|
||||
Reference in New Issue
Block a user