기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, UI 개선
## 처리시간 지표 - 업무시간 기준(09-18 평일) / 공휴일 제외 24h / 달력 기준 3가지 모드 선택 - 공휴일 DB 관리 (holidays 테이블, 수동 등록·삭제·일괄 추가) - 2026년 공휴일 등록 지원 - 설정 페이지에서 라디오 버튼으로 모드 선택 ## 대시보드 차트 - 월별 평균 처리시간 막대 차트 추가 - 월별 신고 접수 건수 누적 막대 차트 추가 - 월별 → 일별 드릴다운 (막대 클릭 시 해당 월의 일별 차트로 전환) - 일별 막대 클릭 시 처리 완료/신고 접수 상세 내역 모달 - 충전기별 누적 고장 건수 Top 10 수평 막대 차트 추가 ## 신고 목록 - # 컬럼을 DB PK 대신 현재 목록 순서(1, 2, 3…)로 표시 - 엑셀 export 접수번호도 순차번호로 변경 ## 모바일 네비게이션 버그 수정 - 모바일에서 가로 오버플로우 시 nav가 body 넓이로 늘어나 햄버거 버튼이 화면 밖으로 밀리는 문제 수정 - nav를 position:fixed + body padding-top:54px 로 변경 (전체 페이지 적용) - 충전기 관리·신고 목록 페이지 지도 컨테이너에 isolation:isolate 적용 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,12 +17,21 @@
|
||||
body{font-family:'Noto Sans KR',sans-serif;background:var(--gray1);color:var(--text);font-size:14px;min-height:100vh;}
|
||||
|
||||
/* ── NAV ── */
|
||||
.nav{background:var(--navy);color:white;padding:0 24px;height:54px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.3);}
|
||||
.nav{background:var(--navy);color:white;padding:0 24px;height:54px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:400;box-shadow:0 2px 8px rgba(0,0,0,.3);}
|
||||
.nav-brand{font-size:16px;font-weight:700;color:var(--accent);}
|
||||
.nav-user{font-size:13px;color:rgba(255,255,255,.7);display:flex;align-items:center;gap:12px;}
|
||||
.nav-user a{color:rgba(255,255,255,.7);text-decoration:none;cursor:pointer;}
|
||||
.nav-user a:hover{color:white;}
|
||||
|
||||
/* ── 햄버거 버튼 (데스크톱 숨김) ── */
|
||||
.nav-hamburger{display:none;background:none;border:none;color:white;font-size:22px;
|
||||
cursor:pointer;padding:4px 10px;margin-right:2px;border-radius:6px;line-height:1;}
|
||||
.nav-hamburger:hover{background:rgba(255,255,255,.12);}
|
||||
|
||||
/* ── 모바일 오버레이 ── */
|
||||
.mobile-nav-overlay{display:none;position:fixed;inset:54px 0 0 0;
|
||||
background:rgba(0,0,0,.45);z-index:299;}
|
||||
|
||||
/* ── SIDEBAR (admin/mechanic) ── */
|
||||
.layout{display:flex;min-height:calc(100vh - 54px);}
|
||||
.sidebar{background:var(--navy2);width:200px;flex-shrink:0;padding:16px 0;}
|
||||
@@ -139,10 +148,38 @@ textarea{resize:vertical;min-height:80px;}
|
||||
.spinner{display:inline-block;width:18px;height:18px;border:2px solid var(--gray3);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;}
|
||||
@keyframes spin{to{transform:rotate(360deg);}}
|
||||
|
||||
/* ── 2컬럼 상세 그리드 ── */
|
||||
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;}
|
||||
|
||||
/* ── RESPONSIVE ── */
|
||||
@media(max-width:768px){
|
||||
.form-row,.form-row-3{grid-template-columns:1fr;}
|
||||
.sidebar{display:none;}
|
||||
.main{padding:16px;}
|
||||
.stats{grid-template-columns:repeat(2,1fr);}
|
||||
.detail-grid{grid-template-columns:1fr;}
|
||||
|
||||
/* nav: sticky → fixed (가로 오버플로우 시 body 넓이에 끌려 햄버거 버튼이 밀려나는 문제 방지) */
|
||||
.nav{position:fixed;left:0;width:100%;box-sizing:border-box;}
|
||||
body{padding-top:54px;}
|
||||
|
||||
/* 사이드바 → 슬라이드 드로어 */
|
||||
.sidebar{
|
||||
position:fixed;top:54px;left:0;bottom:0;
|
||||
width:220px;z-index:300;overflow-y:auto;
|
||||
transform:translateX(-100%);
|
||||
transition:transform .25s ease;
|
||||
box-shadow:none;
|
||||
}
|
||||
.sidebar.mobile-open{
|
||||
transform:translateX(0);
|
||||
box-shadow:4px 0 28px rgba(0,0,0,.45);
|
||||
}
|
||||
.mobile-nav-overlay.show{display:block;}
|
||||
.nav-hamburger{display:inline-flex;align-items:center;}
|
||||
}
|
||||
|
||||
/* ── 정비사 모바일 탭 바 ── */
|
||||
.mech-tab-bar{display:none;background:var(--navy2);position:sticky;top:54px;z-index:200;border-bottom:1px solid rgba(255,255,255,.15);}
|
||||
.mech-tab-bar a{flex:1;display:flex;flex-direction:column;align-items:center;padding:8px 4px 7px;color:rgba(255,255,255,.6);text-decoration:none;font-size:11px;border-bottom:3px solid transparent;transition:all .15s;gap:1px;line-height:1.4;}
|
||||
.mech-tab-bar a:hover,.mech-tab-bar a.active{color:white;border-bottom-color:var(--accent);background:rgba(255,255,255,.06);}
|
||||
@media(max-width:768px){.mech-tab-bar{display:flex;}}
|
||||
|
||||
@@ -66,7 +66,7 @@ const API = (() => {
|
||||
post: (path, body) => req('POST', path, body, body instanceof FormData),
|
||||
put: (path, body) => req('PUT', path, body, body instanceof FormData),
|
||||
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
|
||||
delete: (path) => req('DELETE', path),
|
||||
delete: (path, body) => req('DELETE', path, body ?? null),
|
||||
download,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -33,10 +33,41 @@ const Auth = (() => {
|
||||
function renderNav(el) {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<button class="nav-hamburger" onclick="Auth.toggleMobileNav()" aria-label="메뉴">☰</button>
|
||||
<span class="nav-user">
|
||||
<span>${name()} <small style="color:var(--accent)">[${role()}]</small></span>
|
||||
<a onclick="Auth.logout()">로그아웃</a>
|
||||
</span>`;
|
||||
|
||||
// 모바일 오버레이 삽입 (중복 방지)
|
||||
if (!document.getElementById('mobileNavOverlay')) {
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'mobileNavOverlay';
|
||||
ov.className = 'mobile-nav-overlay';
|
||||
ov.addEventListener('click', closeMobileNav);
|
||||
document.body.appendChild(ov);
|
||||
}
|
||||
|
||||
// 사이드바 링크 클릭 시 드로어 닫기
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.sidebar a').forEach(a => {
|
||||
a.addEventListener('click', closeMobileNav);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function toggleMobileNav() {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const overlay = document.getElementById('mobileNavOverlay');
|
||||
if (!sidebar) return;
|
||||
const opening = !sidebar.classList.contains('mobile-open');
|
||||
sidebar.classList.toggle('mobile-open', opening);
|
||||
overlay?.classList.toggle('show', opening);
|
||||
}
|
||||
|
||||
function closeMobileNav() {
|
||||
document.querySelector('.sidebar')?.classList.remove('mobile-open');
|
||||
document.getElementById('mobileNavOverlay')?.classList.remove('show');
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
@@ -59,5 +90,6 @@ const Auth = (() => {
|
||||
return new Date(dt).toLocaleString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
return { save, token, role, name, uid, logout, require, renderNav, statusBadge, costStatusBadge, fmtDt };
|
||||
return { save, token, role, name, uid, logout, require, renderNav,
|
||||
toggleMobileNav, closeMobileNav, statusBadge, costStatusBadge, fmtDt };
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<!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>
|
||||
<!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>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
</style></head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
<div class="layout">
|
||||
@@ -11,15 +17,40 @@
|
||||
<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/issue-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;">
|
||||
<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-primary" onclick="openModal()">+ 계정 생성</button>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 비활성화 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 계정 생성</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 섹션 -->
|
||||
<div id="pendingSection" style="display:none;margin-bottom:20px;">
|
||||
<div class="card" style="border:2px solid #F59E0B;background:#FFFBEB;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
|
||||
<span style="font-size:18px">⏳</span>
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:700;color:#92400E">가입 승인 대기</div>
|
||||
<div style="font-size:12px;color:#B45309">승인 후 정비사 계정으로 이용 가능합니다.</div>
|
||||
</div>
|
||||
<span id="pendingBadge" style="margin-left:auto;background:#F59E0B;color:white;font-size:12px;font-weight:700;padding:3px 10px;border-radius:10px;"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>이름</th><th>아이디</th><th>회사명</th><th>전화번호</th><th>신청일시</th><th style="width:150px">처리</th></tr></thead>
|
||||
<tbody id="pendingTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;">
|
||||
<select id="fRole" onchange="load()" style="width:auto">
|
||||
@@ -28,7 +59,10 @@
|
||||
</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>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>ID</th><th>아이디</th><th>역할</th><th>이름</th><th>회사/제조사</th><th>전화번호</th><th>상태</th><th>수정</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -70,16 +104,86 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 계정 ${checked.length}개를 비활성화합니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/accounts/bulk', ids); load(); }
|
||||
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사'};
|
||||
|
||||
async function loadPending() {
|
||||
const all = await API.get('/accounts');
|
||||
const pending = all.filter(u => u.is_pending);
|
||||
const sec = document.getElementById('pendingSection');
|
||||
sec.style.display = pending.length ? 'block' : 'none';
|
||||
if (!pending.length) return;
|
||||
document.getElementById('pendingBadge').textContent = pending.length + '명 대기 중';
|
||||
document.getElementById('pendingTbody').innerHTML = pending.map(u => `
|
||||
<tr>
|
||||
<td><strong>${u.name}</strong></td>
|
||||
<td style="color:var(--gray4)">${u.username}</td>
|
||||
<td>${u.company ? `<span style="background:#EFF6FF;color:#1E40AF;font-size:11px;font-weight:600;padding:2px 8px;border-radius:8px">${u.company}</span>` : '<span style="color:var(--gray4)">-</span>'}</td>
|
||||
<td>${u.phone||'-'}</td>
|
||||
<td style="font-size:12px">${Auth.fmtDt(u.created_at)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-success btn-sm" onclick="approveUser(${u.id},'${u.name}')">✅ 승인</button>
|
||||
<button class="btn btn-sm" style="background:#fee2e2;color:#991b1b;border:none" onclick="rejectUser(${u.id},'${u.name}')">✕ 거절</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
async function approveUser(id, name) {
|
||||
if (!confirm(`"${name}" 계정을 승인하시겠습니까?\n승인 후 바로 로그인 가능합니다.`)) return;
|
||||
try {
|
||||
await API.patch('/accounts/'+id+'/approve');
|
||||
loadPending(); load();
|
||||
} catch(e) { alert('오류: '+e.message); }
|
||||
}
|
||||
|
||||
async function rejectUser(id, name) {
|
||||
if (!confirm(`"${name}" 가입 신청을 거절하고 계정을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
await API.delete('/accounts/'+id);
|
||||
loadPending(); load();
|
||||
} catch(e) { alert('오류: '+e.message); }
|
||||
}
|
||||
|
||||
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('');
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
document.getElementById('tbody').innerHTML = users.filter(u => !u.is_pending).map(u=>`
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${u.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<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=''); }
|
||||
@@ -118,5 +222,6 @@ async function save() {
|
||||
} 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(); }
|
||||
loadPending();
|
||||
load();
|
||||
</script></body></html>
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>충전기 종류 관리</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.err-panel { display:none; margin-top:0; }
|
||||
.err-panel.show { display:block; }
|
||||
.err-row-edit input { padding:4px 6px; font-size:12px; border:1px solid var(--gray3); border-radius:5px; }
|
||||
.err-row-edit input[type=number] { width:60px; }
|
||||
.err-row-edit input[type=text] { width:90px; }
|
||||
.err-row-edit input.wide { width:130px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
@@ -20,6 +28,7 @@
|
||||
<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/issue-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>
|
||||
@@ -46,7 +55,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<!-- 종류 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-title">등록된 충전기 종류</div>
|
||||
<div class="tbl-wrap">
|
||||
@@ -57,6 +66,7 @@
|
||||
<th>종류명</th>
|
||||
<th>설명</th>
|
||||
<th>충전기 수</th>
|
||||
<th>에러코드</th>
|
||||
<th>수정</th>
|
||||
<th>삭제</th>
|
||||
</tr>
|
||||
@@ -68,6 +78,62 @@
|
||||
등록된 충전기 종류가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 에러코드 관리 패널 -->
|
||||
<div class="card err-panel" id="errPanel">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<div class="card-title" style="margin:0" id="errPanelTitle">에러코드 관리</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="closeErrPanel()">✕ 닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- 에러코드 목록 -->
|
||||
<div class="tbl-wrap" style="margin-bottom:16px">
|
||||
<table id="errTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>코드</th>
|
||||
<th>에러명</th>
|
||||
<th>진단조건</th>
|
||||
<th style="width:60px">자동복구</th>
|
||||
<th style="width:50px">순서</th>
|
||||
<th>수정</th>
|
||||
<th>삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="errEmpty" class="alert alert-info" style="display:none">등록된 에러코드가 없습니다.</div>
|
||||
|
||||
<!-- 에러코드 추가 폼 -->
|
||||
<div style="background:var(--gray1);border-radius:8px;padding:14px">
|
||||
<div style="font-size:13px;font-weight:700;color:var(--navy2);margin-bottom:10px">+ 에러코드 추가</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-bottom:8px">
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">코드 *</label>
|
||||
<input type="text" id="newCode" placeholder="12200" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">에러명 *</label>
|
||||
<input type="text" id="newName" placeholder="과전압" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">진단조건</label>
|
||||
<input type="text" id="newCond" placeholder=">275V" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">표시순서</label>
|
||||
<input type="number" id="newOrder" value="0" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
||||
<input type="checkbox" id="newAutoRecovery" checked style="width:auto;accent-color:var(--accent)">
|
||||
<label for="newAutoRecovery" style="font-size:13px">자동복구 가능</label>
|
||||
</div>
|
||||
<div id="errFormErr" class="alert alert-danger" style="display:none;margin-bottom:8px"></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="addError()">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +143,9 @@
|
||||
Auth.require(['admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let selectedTypeId = null;
|
||||
let selectedTypeName = '';
|
||||
|
||||
async function load() {
|
||||
const types = await API.get('/chargers/types');
|
||||
const tbody = document.getElementById('tbody');
|
||||
@@ -87,6 +156,11 @@ async function load() {
|
||||
<td><strong>${t.name}</strong></td>
|
||||
<td>${t.description || '-'}</td>
|
||||
<td>${t.charger_count}개</td>
|
||||
<td>
|
||||
<button class="btn btn-outline btn-sm" onclick="openErrPanel(${t.id}, '${escQ(t.name)}')">
|
||||
📋 에러코드
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline btn-sm" onclick="startEdit(${t.id}, '${escQ(t.name)}', '${escQ(t.description||'')}')">
|
||||
수정
|
||||
@@ -101,10 +175,105 @@ async function load() {
|
||||
}
|
||||
|
||||
function escQ(str) {
|
||||
return str.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
return String(str).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
|
||||
}
|
||||
function escH(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/* ── 수정 모드 진입 ── */
|
||||
/* ── 에러코드 패널 ── */
|
||||
async function openErrPanel(typeId, typeName) {
|
||||
selectedTypeId = typeId;
|
||||
selectedTypeName = typeName;
|
||||
document.getElementById('errPanelTitle').textContent = `에러코드 관리 — ${typeName}`;
|
||||
document.getElementById('errPanel').classList.add('show');
|
||||
document.getElementById('errPanel').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
await loadErrors();
|
||||
}
|
||||
|
||||
function closeErrPanel() {
|
||||
document.getElementById('errPanel').classList.remove('show');
|
||||
selectedTypeId = null;
|
||||
}
|
||||
|
||||
async function loadErrors() {
|
||||
const errors = await API.get(`/chargers/types/${selectedTypeId}/errors`);
|
||||
const tbody = document.getElementById('errTbody');
|
||||
document.getElementById('errEmpty').style.display = errors.length ? 'none' : 'block';
|
||||
tbody.innerHTML = errors.map(e => renderErrorRow(e)).join('');
|
||||
}
|
||||
|
||||
function renderErrorRow(e) {
|
||||
return `
|
||||
<tr id="err-row-${e.id}">
|
||||
<td><strong>${escH(e.error_code)}</strong></td>
|
||||
<td>${escH(e.error_name)}</td>
|
||||
<td style="font-size:12px;color:var(--gray4)">${escH(e.range_condition||'')}</td>
|
||||
<td style="text-align:center">${e.auto_recovery ? '✅' : '❌'}</td>
|
||||
<td style="text-align:center">${e.display_order}</td>
|
||||
<td><button class="btn btn-outline btn-sm" onclick="startEditError(${e.id}, '${escQ(e.error_code)}', '${escQ(e.error_name)}', '${escQ(e.range_condition||'')}', ${e.auto_recovery}, ${e.display_order})">수정</button></td>
|
||||
<td><button class="btn btn-danger btn-sm" onclick="deleteError(${e.id})">삭제</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function startEditError(id, code, name, cond, autoRec, order) {
|
||||
document.getElementById(`err-row-${id}`).outerHTML = `
|
||||
<tr id="err-row-${id}" class="err-row-edit">
|
||||
<td><input type="text" id="ec-code-${id}" value="${escH(code)}" placeholder="코드"></td>
|
||||
<td><input type="text" class="wide" id="ec-name-${id}" value="${escH(name)}" placeholder="에러명"></td>
|
||||
<td><input type="text" class="wide" id="ec-cond-${id}" value="${escH(cond)}" placeholder="진단조건"></td>
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" id="ec-auto-${id}" ${autoRec?'checked':''} style="width:auto;accent-color:var(--accent)">
|
||||
</td>
|
||||
<td><input type="number" id="ec-order-${id}" value="${order}"></td>
|
||||
<td><button class="btn btn-primary btn-sm" onclick="saveEditError(${id})">저장</button></td>
|
||||
<td><button class="btn btn-outline btn-sm" onclick="loadErrors()">취소</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function saveEditError(id) {
|
||||
const fd = new FormData();
|
||||
fd.append('error_code', document.getElementById(`ec-code-${id}`).value.trim());
|
||||
fd.append('error_name', document.getElementById(`ec-name-${id}`).value.trim());
|
||||
fd.append('range_condition',document.getElementById(`ec-cond-${id}`).value.trim());
|
||||
fd.append('auto_recovery', document.getElementById(`ec-auto-${id}`).checked);
|
||||
fd.append('display_order', document.getElementById(`ec-order-${id}`).value);
|
||||
if (!fd.get('error_code') || !fd.get('error_name')) { alert('코드와 에러명은 필수입니다.'); return; }
|
||||
await API.put(`/chargers/types/${selectedTypeId}/errors/${id}`, fd);
|
||||
await loadErrors();
|
||||
}
|
||||
|
||||
async function deleteError(id) {
|
||||
if (!confirm('에러코드를 삭제하시겠습니까?')) return;
|
||||
await API.delete(`/chargers/types/${selectedTypeId}/errors/${id}`);
|
||||
await loadErrors();
|
||||
}
|
||||
|
||||
async function addError() {
|
||||
const code = document.getElementById('newCode').value.trim();
|
||||
const name = document.getElementById('newName').value.trim();
|
||||
const cond = document.getElementById('newCond').value.trim();
|
||||
const order = document.getElementById('newOrder').value;
|
||||
const auto = document.getElementById('newAutoRecovery').checked;
|
||||
const errEl = document.getElementById('errFormErr');
|
||||
errEl.style.display = 'none';
|
||||
if (!code || !name) { errEl.textContent = '코드와 에러명은 필수입니다.'; errEl.style.display = 'block'; return; }
|
||||
const fd = new FormData();
|
||||
fd.append('error_code', code); fd.append('error_name', name);
|
||||
fd.append('range_condition', cond); fd.append('auto_recovery', auto);
|
||||
fd.append('display_order', order);
|
||||
try {
|
||||
await API.post(`/chargers/types/${selectedTypeId}/errors`, fd);
|
||||
document.getElementById('newCode').value = '';
|
||||
document.getElementById('newName').value = '';
|
||||
document.getElementById('newCond').value = '';
|
||||
document.getElementById('newOrder').value = '0';
|
||||
document.getElementById('newAutoRecovery').checked = true;
|
||||
await loadErrors();
|
||||
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
||||
}
|
||||
|
||||
/* ── 종류 수정 모드 진입 ── */
|
||||
function startEdit(id, name, desc) {
|
||||
document.getElementById('editId').value = id;
|
||||
document.getElementById('typeName').value = name;
|
||||
@@ -114,12 +283,10 @@ function startEdit(id, name, desc) {
|
||||
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 = '';
|
||||
@@ -131,47 +298,26 @@ function cancelEdit() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!name) { errEl.textContent = '종류명을 입력하세요.'; errEl.style.display = 'block'; return; }
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('description', desc);
|
||||
|
||||
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';
|
||||
}
|
||||
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);
|
||||
}
|
||||
try { await API.delete('/chargers/types/' + id); load(); }
|
||||
catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
@@ -4,6 +4,37 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>충전기 관리</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
|
||||
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
|
||||
.view-btn { padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white; cursor:pointer; color:var(--gray4); transition:all .15s; }
|
||||
.view-btn.active { background:var(--navy); color:white; }
|
||||
|
||||
#mapWrap {
|
||||
display:none;
|
||||
height: calc(100vh - 220px);
|
||||
min-height: 420px;
|
||||
border-radius:10px;
|
||||
overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
margin-top:12px;
|
||||
isolation: isolate;
|
||||
}
|
||||
#chargerMap { width:100%; height:100%; }
|
||||
|
||||
.ck-pin {
|
||||
width:28px; height:28px; border-radius:50% 50% 50% 0;
|
||||
transform:rotate(-45deg); border:3px solid white;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||
}
|
||||
.ck-pin.fault { background:#EF4444; }
|
||||
.ck-pin.normal { background:#22C55E; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
@@ -17,23 +48,54 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html" class="active">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
|
||||
<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-primary" onclick="openModal()">+ 충전기 등록</button>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
|
||||
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 충전기 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
|
||||
<!-- 목록 뷰 -->
|
||||
<div id="listWrap" class="card">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap">
|
||||
<input type="text" id="searchInput" placeholder="충전기ID / 충전소명 / CPO 검색..." style="flex:1;min-width:180px;padding:7px 10px;border:1px solid var(--gray3);border-radius:7px;font-size:13px;outline:none">
|
||||
<select id="filterFault" onchange="renderTable()" style="width:auto">
|
||||
<option value="">전체</option>
|
||||
<option value="fault">미처리 있음</option>
|
||||
<option value="ok">정상</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 지도 뷰 -->
|
||||
<div id="mapWrap">
|
||||
<div id="chargerMap"></div>
|
||||
</div>
|
||||
<div id="mapLegend" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:16px;flex-wrap:wrap">
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>미처리 신고 있음</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#22C55E;margin-right:4px"></span>정상</span>
|
||||
<span id="noGpsNote" style="color:var(--gray4)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,17 +127,89 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
let types = [], isEdit = false;
|
||||
|
||||
let allChargers = [];
|
||||
let types = [];
|
||||
let curView = 'list';
|
||||
let chargerMap = null;
|
||||
let mapMarkers = [];
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
function setView(v) {
|
||||
curView = v;
|
||||
document.getElementById('btnList').classList.toggle('active', v === 'list');
|
||||
document.getElementById('btnMap').classList.toggle('active', v === 'map');
|
||||
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
|
||||
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
|
||||
document.getElementById('mapLegend').style.display = v === 'map' ? 'flex' : 'none';
|
||||
document.getElementById('btnDelete').style.display = v === 'map' ? 'none' :
|
||||
(document.querySelectorAll('.row-chk:checked').length > 0 ? 'inline-flex' : 'none');
|
||||
if (v === 'map') {
|
||||
initChargerMap();
|
||||
renderChargerMap();
|
||||
setTimeout(() => chargerMap && chargerMap.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 충전기 ${checked.length}대를 삭제합니다. 신고 내역이 있는 충전기는 삭제되지 않습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => c.dataset.id);
|
||||
try { await API.delete('/chargers/bulk', ids); load(); }
|
||||
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
async function load() {
|
||||
[types] = 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 => `
|
||||
[types, allChargers] = await Promise.all([
|
||||
API.get('/chargers/types'),
|
||||
API.get('/chargers'),
|
||||
]);
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
document.getElementById('fTypeId').innerHTML = types.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
|
||||
renderTable();
|
||||
if (curView === 'map') renderChargerMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderTable() {
|
||||
const q = document.getElementById('searchInput').value.trim().toLowerCase();
|
||||
const fault = document.getElementById('filterFault').value;
|
||||
const rows = allChargers.filter(c => {
|
||||
if (q && !c.id.toLowerCase().includes(q) &&
|
||||
!c.station_name.toLowerCase().includes(q) &&
|
||||
!(c.cpo_name||'').toLowerCase().includes(q) &&
|
||||
!c.name.toLowerCase().includes(q)) return false;
|
||||
if (fault === 'fault' && c.pending_reports === 0) return false;
|
||||
if (fault === 'ok' && c.pending_reports > 0) return false;
|
||||
return true;
|
||||
});
|
||||
document.getElementById('tbody').innerHTML = rows.map(c => `
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${c.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td><strong>${c.id}</strong></td>
|
||||
<td>${c.charger_type||'-'}</td>
|
||||
<td>${c.name}</td>
|
||||
@@ -87,10 +221,85 @@ async function load() {
|
||||
<td><button class="btn btn-outline btn-sm" onclick="editCharger('${c.id}')">수정</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('searchInput').addEventListener('input', renderTable);
|
||||
});
|
||||
|
||||
function openModal(id=null) { isEdit=!!id; document.getElementById('modal').classList.remove('hidden'); document.getElementById('modalTitle').textContent = id?'충전기 수정':'충전기 등록'; }
|
||||
// ── 지도 초기화 ──
|
||||
function initChargerMap() {
|
||||
if (chargerMap) return;
|
||||
chargerMap = L.map('chargerMap', { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(chargerMap);
|
||||
}
|
||||
|
||||
// ── 지도 마커 렌더 ──
|
||||
function renderChargerMap() {
|
||||
if (!chargerMap) return;
|
||||
mapMarkers.forEach(m => m.remove());
|
||||
mapMarkers = [];
|
||||
|
||||
const visible = allChargers.filter(c => c.gps_lat && c.gps_lng);
|
||||
const noGps = allChargers.length - visible.length;
|
||||
document.getElementById('noGpsNote').textContent =
|
||||
noGps ? `📍 GPS 미등록 ${noGps}대 미표시` : '';
|
||||
|
||||
if (!visible.length) {
|
||||
chargerMap.setView([36.5, 127.8], 7);
|
||||
return;
|
||||
}
|
||||
|
||||
visible.forEach(c => {
|
||||
const hasFault = c.pending_reports > 0;
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="ck-pin ${hasFault ? 'fault' : 'normal'}"></div>`,
|
||||
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
|
||||
});
|
||||
|
||||
const popup = `
|
||||
<div style="min-width:200px">
|
||||
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">⚡ ${c.id}</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:8px;line-height:1.6">
|
||||
📍 ${c.station_name}${c.location_detail ? '<br>' + c.location_detail : ''}
|
||||
${c.charger_type ? '<br>종류: ' + c.charger_type : ''}
|
||||
${c.cpo_name ? '<br>CPO: ' + c.cpo_name : ''}
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<span class="badge ${hasFault ? 's-pending' : 's-done'}" style="font-size:12px">${hasFault ? '⚠ 미처리 ' + c.pending_reports + '건' : '✅ 정상'}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
${hasFault
|
||||
? `<a href="/pages/admin/reports.html?charger_id=${c.id}" style="flex:1;text-align:center;background:#EF4444;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none">📋 신고 보기</a>`
|
||||
: ''}
|
||||
<button onclick="editCharger('${c.id}')" style="flex:1;background:#1e3a5f;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;border:none;cursor:pointer">✏ 수정</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
|
||||
.addTo(chargerMap)
|
||||
.bindPopup(popup, { maxWidth: 260 });
|
||||
mapMarkers.push(m);
|
||||
});
|
||||
|
||||
const bounds = L.latLngBounds(visible.map(c => [c.gps_lat, c.gps_lng]));
|
||||
chargerMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
if (visible.length === 1) chargerMap.setZoom(14);
|
||||
}
|
||||
|
||||
// ── 모달 ──
|
||||
function openModal(id=null) {
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
document.getElementById('modalTitle').textContent = id ? '충전기 수정' : '충전기 등록';
|
||||
}
|
||||
function closeModal() { document.getElementById('modal').classList.add('hidden'); clearForm(); }
|
||||
function clearForm() { ['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id=>document.getElementById(id).value=''); document.getElementById('modalErr').style.display='none'; }
|
||||
function clearForm() {
|
||||
['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('fId').disabled = false;
|
||||
document.getElementById('modalErr').style.display = 'none';
|
||||
}
|
||||
|
||||
async function editCharger(id) {
|
||||
const c = await API.get('/chargers/'+id);
|
||||
@@ -125,6 +334,7 @@ async function save() {
|
||||
closeModal(); load();
|
||||
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>출장비 관리</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
@@ -17,6 +23,7 @@
|
||||
<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/issue-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>
|
||||
@@ -24,7 +31,10 @@
|
||||
<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 style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/costs','출장비목록.xlsx')">📥 엑셀 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="card">
|
||||
@@ -48,7 +58,10 @@
|
||||
</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>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -59,6 +72,28 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 출장비 내역 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/costs/bulk', ids); load(); }
|
||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
|
||||
|
||||
async function load() {
|
||||
@@ -67,17 +102,23 @@ async function load() {
|
||||
<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('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
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>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${c.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.charger_id||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.station_name||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer;font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${Auth.costStatusBadge(c.cost_status)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${Auth.fmtDt(c.reviewed_at)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
load();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,28 @@
|
||||
<!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 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/issue-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="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>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
@@ -99,4 +115,4 @@ async function changeStatus() {
|
||||
load();
|
||||
}
|
||||
load();
|
||||
</script></body></html>
|
||||
</script></div></div></body></html>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<!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>
|
||||
<!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>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
</style></head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
<div class="layout">
|
||||
@@ -11,6 +17,7 @@
|
||||
<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/issue-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>
|
||||
@@ -18,7 +25,8 @@
|
||||
<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">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/improvements','개선항목목록.xlsx')">📥 엑셀</button>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 개선항목 등록</button>
|
||||
</div>
|
||||
@@ -35,7 +43,10 @@
|
||||
<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>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<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>
|
||||
@@ -90,6 +101,28 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 개선항목 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/improvements/bulk', ids); load(); }
|
||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
|
||||
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
|
||||
const selectedReports = new Set();
|
||||
@@ -105,18 +138,24 @@ async function load() {
|
||||
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('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
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>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${i.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">#${i.id}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer;max-width:200px"><strong>${i.title}</strong></td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${CAT[i.category]||i.category}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${PRI[i.priority]||i.priority}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.manufacturer_company||'-'}<br><small>${i.manufacturer_name||''}</small></td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer"><span class="badge s-pending">${i.report_count}건</span></td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.statusBadge(i.status)}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.fmtDt(i.created_at)}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.sw_deployed_at||'-'}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
|
||||
441
frontend/static/pages/admin/issue-types.html
Normal file
441
frontend/static/pages/admin/issue-types.html
Normal file
@@ -0,0 +1,441 @@
|
||||
<!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>
|
||||
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--gray2); margin-bottom:20px; }
|
||||
.tab-btn {
|
||||
padding:9px 22px; font-size:14px; font-weight:600; border:none; background:none;
|
||||
cursor:pointer; color:var(--gray4); border-bottom:3px solid transparent; margin-bottom:-2px;
|
||||
transition:color .15s, border-color .15s;
|
||||
}
|
||||
.tab-btn.active { color:var(--navy); border-bottom-color:var(--accent); }
|
||||
.tab-pane { display:none; }
|
||||
.tab-pane.active { display:block; }
|
||||
|
||||
/* 유형 공통 */
|
||||
.type-list { border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||
.type-row {
|
||||
display:grid; grid-template-columns:36px 1fr 1fr 80px;
|
||||
align-items:center; gap:10px; padding:10px 14px;
|
||||
border-bottom:1px solid var(--border); background:#fff;
|
||||
}
|
||||
.type-row:last-child { border-bottom:none; }
|
||||
.type-row.header { background:var(--gray1); font-size:11px; font-weight:700; color:var(--gray4); text-transform:uppercase; letter-spacing:.5px; }
|
||||
.type-row input[type=text] { font-size:13px; padding:5px 8px; border:1px solid var(--border); border-radius:5px; width:100%; }
|
||||
.order-btns { display:flex; flex-direction:column; gap:2px; }
|
||||
.order-btn { background:none; border:1px solid var(--border); border-radius:3px; padding:0 6px; font-size:11px; cursor:pointer; color:var(--gray4); line-height:18px; }
|
||||
.order-btn:hover { background:var(--gray1); color:var(--text); }
|
||||
.del-btn { background:none; border:none; color:#e53e3e; font-size:18px; cursor:pointer; padding:0 4px; }
|
||||
.del-btn:hover { color:#c53030; }
|
||||
.add-row { display:grid; grid-template-columns:36px 1fr 1fr 80px; gap:10px; align-items:center; padding:12px 14px; background:#f9faff; border-top:2px dashed var(--blue); border-radius:0 0 8px 8px; }
|
||||
.add-row input[type=text] { font-size:13px; padding:5px 8px; border:1px solid var(--border); border-radius:5px; width:100%; }
|
||||
.hint { font-size:11px; color:var(--gray4); margin-top:4px; }
|
||||
|
||||
/* 제조사 테이블 */
|
||||
.mfr-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.mfr-table th { background:var(--gray1); color:var(--gray4); font-size:11px; font-weight:700; padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); }
|
||||
.mfr-table td { padding:9px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||
.mfr-table tr:last-child td { border-bottom:none; }
|
||||
.mfr-table tr:hover td { background:#f8faff; }
|
||||
.badge-active { background:#D1FAE5; color:#065F46; font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; }
|
||||
.badge-inactive { background:#FEE2E2; color:#991B1B; font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; }
|
||||
|
||||
/* 제조사 추가/편집 모달 */
|
||||
.mfr-modal-bg { display:none; position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:200; align-items:center; justify-content:center; }
|
||||
.mfr-modal-bg.open { display:flex; }
|
||||
.mfr-modal { background:white; border-radius:12px; width:480px; max-width:calc(100vw - 32px); padding:28px 28px 22px; box-shadow:0 8px 32px rgba(0,0,0,.2); }
|
||||
.mfr-modal h3 { font-size:16px; font-weight:700; color:var(--navy); margin-bottom:18px; }
|
||||
.mfr-field { margin-bottom:12px; }
|
||||
.mfr-field label { display:block; font-size:12px; font-weight:600; color:var(--navy2); margin-bottom:4px; }
|
||||
.mfr-field input, .mfr-field textarea {
|
||||
width:100%; padding:8px 10px; border:1px solid var(--gray3); border-radius:7px;
|
||||
font-size:13px; box-sizing:border-box; font-family:inherit; outline:none;
|
||||
}
|
||||
.mfr-field input:focus, .mfr-field textarea:focus { border-color:var(--accent); }
|
||||
.mfr-field .opt { font-size:11px; color:var(--gray4); font-weight:400; margin-left:4px; }
|
||||
</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/issue-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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" onclick="switchTab('issue')">📋 문제유형</button>
|
||||
<button class="tab-btn" onclick="switchTab('repair')">🔧 조치유형</button>
|
||||
<button class="tab-btn" onclick="switchTab('mfr')">🏢 제조사</button>
|
||||
</div>
|
||||
|
||||
<!-- ── 문제유형 탭 ── -->
|
||||
<div class="tab-pane active" id="pane-issue">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<p style="font-size:13px;color:var(--text2);margin:0">신고 접수 시 사용자가 선택하는 문제 유형입니다. 저장키는 기존 신고 데이터와 연결되므로 수정 시 주의하세요.</p>
|
||||
<button class="btn btn-primary btn-sm" style="margin-left:16px;white-space:nowrap" onclick="saveIssue()">💾 저장</button>
|
||||
</div>
|
||||
<div class="type-list" id="issueList"></div>
|
||||
<div class="add-row">
|
||||
<span style="text-align:center;font-size:18px;color:var(--blue)">+</span>
|
||||
<div>
|
||||
<input type="text" id="iNewLabel" placeholder="표시명 예) ⚡ 충전 불가">
|
||||
<div class="hint">신고 화면에 보이는 이름 (이모지 포함 가능)</div>
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" id="iNewKey" placeholder="저장키 예) 충전불가">
|
||||
<div class="hint">공백 없는 한글/영문 권장</div>
|
||||
</div>
|
||||
<div style="text-align:center"><button class="btn btn-outline btn-sm" onclick="addIssueRow()">추가</button></div>
|
||||
</div>
|
||||
<div id="issueSaveMsg" style="display:none;margin-top:14px"></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px">
|
||||
<div class="card-title">👁 신고 화면 미리보기</div>
|
||||
<div id="issuePreview" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:420px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 조치유형 탭 ── -->
|
||||
<div class="tab-pane" id="pane-repair">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<p style="font-size:13px;color:var(--text2);margin:0">정비사가 조치 입력 시 선택하는 조치 유형입니다. 저장키는 기존 조치 기록과 연결되므로 수정 시 주의하세요.</p>
|
||||
<button class="btn btn-primary btn-sm" style="margin-left:16px;white-space:nowrap" onclick="saveRepair()">💾 저장</button>
|
||||
</div>
|
||||
<div class="type-list" id="repairList"></div>
|
||||
<div class="add-row">
|
||||
<span style="text-align:center;font-size:18px;color:var(--blue)">+</span>
|
||||
<div>
|
||||
<input type="text" id="rNewLabel" placeholder="표시명 예) 🔩 부품 교체">
|
||||
<div class="hint">조치 화면에 보이는 이름 (이모지 포함 가능)</div>
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" id="rNewKey" placeholder="저장키 예) 부품교체">
|
||||
<div class="hint">공백 없는 한글/영문 권장</div>
|
||||
</div>
|
||||
<div style="text-align:center"><button class="btn btn-outline btn-sm" onclick="addRepairRow()">추가</button></div>
|
||||
</div>
|
||||
<div id="repairSaveMsg" style="display:none;margin-top:14px"></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px">
|
||||
<div class="card-title">👁 조치 화면 미리보기</div>
|
||||
<div id="repairPreview" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:420px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 제조사 탭 ── -->
|
||||
<div class="tab-pane" id="pane-mfr">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<p style="font-size:13px;color:var(--text2);margin:0">
|
||||
개선항목·출장비에 연결하거나 정비사 가입 시 선택할 수 있는 회사 목록입니다.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm" style="white-space:nowrap;margin-left:16px" onclick="openMfrModal()">+ 제조사 등록</button>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="mfr-table" id="mfrTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>회사명</th><th>대표자명</th><th>사업자번호</th><th>대표전화</th><th>주소</th><th>상태</th><th style="width:90px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mfrTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="mfrEmpty" class="alert alert-info" style="display:none;margin-top:12px">등록된 제조사가 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제조사 등록/편집 모달 -->
|
||||
<div class="mfr-modal-bg" id="mfrModal">
|
||||
<div class="mfr-modal">
|
||||
<h3 id="mfrModalTitle">제조사 등록</h3>
|
||||
<input type="hidden" id="mfrEditId">
|
||||
<div class="mfr-field">
|
||||
<label>회사명 <span style="color:var(--red)">*</span></label>
|
||||
<input type="text" id="mfrName" placeholder="예) (주)한국EV">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div class="mfr-field">
|
||||
<label>대표자명 <span class="opt">(선택)</span></label>
|
||||
<input type="text" id="mfrRep" placeholder="예) 홍길동">
|
||||
</div>
|
||||
<div class="mfr-field">
|
||||
<label>사업자번호 <span class="opt">(선택)</span></label>
|
||||
<input type="text" id="mfrBiz" placeholder="예) 123-45-67890">
|
||||
</div>
|
||||
<div class="mfr-field">
|
||||
<label>대표전화 <span class="opt">(선택)</span></label>
|
||||
<input type="tel" id="mfrPhone" placeholder="예) 02-1234-5678">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mfr-field">
|
||||
<label>주소 <span class="opt">(선택)</span></label>
|
||||
<input type="text" id="mfrAddr" placeholder="예) 서울시 강남구 ...">
|
||||
</div>
|
||||
<div class="mfr-field" id="mfrActiveField" style="display:none">
|
||||
<label>상태</label>
|
||||
<select id="mfrActive" style="width:auto;padding:7px 10px;border:1px solid var(--gray3);border-radius:7px;font-size:13px">
|
||||
<option value="true">활성</option>
|
||||
<option value="false">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="mfrModalErr" class="alert alert-danger" style="display:none;margin-top:10px"></div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:18px">
|
||||
<button class="btn btn-outline" onclick="closeMfrModal()">취소</button>
|
||||
<button class="btn btn-primary" onclick="saveMfr()">저장</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 issueTypes = [];
|
||||
let repairTypes = [];
|
||||
let manufacturers = [];
|
||||
|
||||
// ── 탭 전환 ──
|
||||
const TAB_NAMES = ['issue','repair','mfr'];
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) =>
|
||||
b.classList.toggle('active', TAB_NAMES[i] === name));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('pane-' + name).classList.add('active');
|
||||
}
|
||||
|
||||
// ── 유형 공통 렌더러 ──
|
||||
function renderList(listId, types, varName, moveFn, delFn, previewFn) {
|
||||
const header = `
|
||||
<div class="type-row header">
|
||||
<span style="text-align:center">순서</span><span>표시명</span><span>저장키</span><span style="text-align:center">삭제</span>
|
||||
</div>`;
|
||||
const rows = types.map((t, i) => `
|
||||
<div class="type-row">
|
||||
<div class="order-btns">
|
||||
<button class="order-btn" onclick="${moveFn}(${i},-1)" ${i===0?'disabled':''}>▲</button>
|
||||
<button class="order-btn" onclick="${moveFn}(${i},1)" ${i===types.length-1?'disabled':''}>▼</button>
|
||||
</div>
|
||||
<input type="text" value="${esc(t.label)}" onchange="${varName}[${i}].label=this.value;${previewFn}()">
|
||||
<input type="text" value="${esc(t.key)}" onchange="${varName}[${i}].key=this.value">
|
||||
<div style="text-align:center"><button class="del-btn" onclick="${delFn}(${i})">×</button></div>
|
||||
</div>`).join('');
|
||||
document.getElementById(listId).innerHTML = header + rows;
|
||||
}
|
||||
|
||||
function renderPreview(previewId, types) {
|
||||
document.getElementById(previewId).innerHTML = types.map(t => `
|
||||
<label style="border:1.5px solid var(--border);border-radius:8px;padding:8px 12px;font-size:13px;
|
||||
display:flex;align-items:center;gap:8px;background:#fff;cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--accent)">${esc(t.label)}
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
// ── 문제유형 ──
|
||||
function renderIssue() {
|
||||
renderList('issueList', issueTypes, 'issueTypes', 'moveIssue', 'delIssue', 'renderIssuePreview');
|
||||
renderIssuePreview();
|
||||
}
|
||||
function renderIssuePreview() { renderPreview('issuePreview', issueTypes); }
|
||||
function moveIssue(idx, dir) {
|
||||
const t = idx+dir; if(t<0||t>=issueTypes.length) return;
|
||||
[issueTypes[idx],issueTypes[t]]=[issueTypes[t],issueTypes[idx]]; renderIssue();
|
||||
}
|
||||
function delIssue(idx) {
|
||||
if(!confirm(`"${issueTypes[idx].label}" 유형을 삭제하시겠습니까?\n기존 신고 기록에는 저장키가 남습니다.`)) return;
|
||||
issueTypes.splice(idx,1); renderIssue();
|
||||
}
|
||||
function addIssueRow() {
|
||||
const label=document.getElementById('iNewLabel').value.trim();
|
||||
const key=document.getElementById('iNewKey').value.trim();
|
||||
if(!label||!key){alert('표시명과 저장키를 모두 입력하세요.');return;}
|
||||
if(issueTypes.some(t=>t.key===key)){alert(`저장키 "${key}"가 이미 존재합니다.`);return;}
|
||||
issueTypes.push({key,label});
|
||||
document.getElementById('iNewLabel').value=''; document.getElementById('iNewKey').value='';
|
||||
renderIssue();
|
||||
}
|
||||
async function saveIssue() {
|
||||
if(!issueTypes.length){alert('최소 1개 이상 필요합니다.');return;}
|
||||
if(issueTypes.find(t=>!t.key.trim()||!t.label.trim())){alert('빈 항목이 있습니다.');return;}
|
||||
try{await API.put('/settings/issue-types',issueTypes);showMsg('issueSaveMsg');}
|
||||
catch(e){alert('저장 실패: '+e.message);}
|
||||
}
|
||||
|
||||
// ── 조치유형 ──
|
||||
function renderRepair() {
|
||||
renderList('repairList', repairTypes, 'repairTypes', 'moveRepair', 'delRepair', 'renderRepairPreview');
|
||||
renderRepairPreview();
|
||||
}
|
||||
function renderRepairPreview() { renderPreview('repairPreview', repairTypes); }
|
||||
function moveRepair(idx, dir) {
|
||||
const t=idx+dir; if(t<0||t>=repairTypes.length) return;
|
||||
[repairTypes[idx],repairTypes[t]]=[repairTypes[t],repairTypes[idx]]; renderRepair();
|
||||
}
|
||||
function delRepair(idx) {
|
||||
if(!confirm(`"${repairTypes[idx].label}" 유형을 삭제하시겠습니까?\n기존 조치 기록에는 저장키가 남습니다.`)) return;
|
||||
repairTypes.splice(idx,1); renderRepair();
|
||||
}
|
||||
function addRepairRow() {
|
||||
const label=document.getElementById('rNewLabel').value.trim();
|
||||
const key=document.getElementById('rNewKey').value.trim();
|
||||
if(!label||!key){alert('표시명과 저장키를 모두 입력하세요.');return;}
|
||||
if(repairTypes.some(t=>t.key===key)){alert(`저장키 "${key}"가 이미 존재합니다.`);return;}
|
||||
repairTypes.push({key,label});
|
||||
document.getElementById('rNewLabel').value=''; document.getElementById('rNewKey').value='';
|
||||
renderRepair();
|
||||
}
|
||||
async function saveRepair() {
|
||||
if(!repairTypes.length){alert('최소 1개 이상 필요합니다.');return;}
|
||||
if(repairTypes.find(t=>!t.key.trim()||!t.label.trim())){alert('빈 항목이 있습니다.');return;}
|
||||
try{await API.put('/settings/repair-types',repairTypes);showMsg('repairSaveMsg');}
|
||||
catch(e){alert('저장 실패: '+e.message);}
|
||||
}
|
||||
|
||||
// ── 제조사 ──
|
||||
function renderMfr() {
|
||||
const tbody = document.getElementById('mfrTbody');
|
||||
document.getElementById('mfrEmpty').style.display = manufacturers.length ? 'none' : 'block';
|
||||
if (!manufacturers.length) { tbody.innerHTML = ''; return; }
|
||||
tbody.innerHTML = manufacturers.map(m => `
|
||||
<tr>
|
||||
<td><strong>${esc(m.name)}</strong></td>
|
||||
<td style="color:var(--text2)">${esc(m.representative_name||'-')}</td>
|
||||
<td style="color:var(--gray4);font-size:12px">${esc(m.business_number||'-')}</td>
|
||||
<td>${esc(m.phone||'-')}</td>
|
||||
<td style="font-size:12px;color:var(--text2);max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(m.address||'-')}</td>
|
||||
<td><span class="${m.is_active?'badge-active':'badge-inactive'}">${m.is_active?'활성':'비활성'}</span></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-outline btn-sm" style="font-size:11px" onclick="editMfr(${m.id})">편집</button>
|
||||
<button class="btn btn-sm" style="font-size:11px;background:#fee2e2;color:#991b1b;border:none"
|
||||
onclick="deleteMfr(${m.id},'${esc(m.name)}')">삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function openMfrModal(id) {
|
||||
document.getElementById('mfrEditId').value = id || '';
|
||||
document.getElementById('mfrModalTitle').textContent = id ? '제조사 편집' : '제조사 등록';
|
||||
document.getElementById('mfrActiveField').style.display = id ? 'block' : 'none';
|
||||
document.getElementById('mfrModalErr').style.display = 'none';
|
||||
if (!id) {
|
||||
['mfrName','mfrRep','mfrBiz','mfrPhone','mfrAddr'].forEach(i => document.getElementById(i).value = '');
|
||||
document.getElementById('mfrActive').value = 'true';
|
||||
}
|
||||
document.getElementById('mfrModal').classList.add('open');
|
||||
}
|
||||
function closeMfrModal() { document.getElementById('mfrModal').classList.remove('open'); }
|
||||
|
||||
function editMfr(id) {
|
||||
const m = manufacturers.find(x => x.id === id);
|
||||
if (!m) return;
|
||||
openMfrModal(id);
|
||||
document.getElementById('mfrName').value = m.name || '';
|
||||
document.getElementById('mfrRep').value = m.representative_name || '';
|
||||
document.getElementById('mfrBiz').value = m.business_number || '';
|
||||
document.getElementById('mfrPhone').value = m.phone || '';
|
||||
document.getElementById('mfrAddr').value = m.address || '';
|
||||
document.getElementById('mfrActive').value = m.is_active ? 'true' : 'false';
|
||||
}
|
||||
|
||||
async function saveMfr() {
|
||||
const id = document.getElementById('mfrEditId').value;
|
||||
const name = document.getElementById('mfrName').value.trim();
|
||||
if (!name) { showMfrErr('회사명은 필수입니다.'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('representative_name', document.getElementById('mfrRep').value.trim());
|
||||
fd.append('business_number', document.getElementById('mfrBiz').value.trim());
|
||||
fd.append('phone', document.getElementById('mfrPhone').value.trim());
|
||||
fd.append('address', document.getElementById('mfrAddr').value.trim());
|
||||
if (id) fd.append('is_active', document.getElementById('mfrActive').value);
|
||||
try {
|
||||
if (id) await API.put('/manufacturers/' + id, fd);
|
||||
else await API.post('/manufacturers', fd);
|
||||
closeMfrModal();
|
||||
await loadMfr();
|
||||
} catch(e) { showMfrErr(e.message); }
|
||||
}
|
||||
|
||||
async function deleteMfr(id, name) {
|
||||
if (!confirm(`"${name}" 제조사를 삭제하시겠습니까?`)) return;
|
||||
try { await API.delete('/manufacturers/' + id); await loadMfr(); }
|
||||
catch(e) { alert('삭제 실패: ' + e.message); }
|
||||
}
|
||||
|
||||
function showMfrErr(msg) {
|
||||
const el = document.getElementById('mfrModalErr');
|
||||
el.textContent = msg; el.style.display = 'block';
|
||||
}
|
||||
|
||||
async function loadMfr() {
|
||||
manufacturers = await API.get('/manufacturers');
|
||||
renderMfr();
|
||||
}
|
||||
|
||||
// ── 공용 ──
|
||||
function showMsg(id) {
|
||||
const el = document.getElementById(id);
|
||||
el.className = 'alert alert-success';
|
||||
el.textContent = '✅ 저장되었습니다.';
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 3000);
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.getElementById('iNewLabel').addEventListener('input', function() {
|
||||
const k = document.getElementById('iNewKey');
|
||||
if (!k.value) k.value = this.value.replace(/[^가-힣a-zA-Z0-9]/g, '');
|
||||
});
|
||||
document.getElementById('rNewLabel').addEventListener('input', function() {
|
||||
const k = document.getElementById('rNewKey');
|
||||
if (!k.value) k.value = this.value.replace(/[^가-힣a-zA-Z0-9]/g, '');
|
||||
});
|
||||
|
||||
// 모달 바깥 클릭 닫기
|
||||
document.getElementById('mfrModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeMfrModal();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const [it, rt] = await Promise.all([
|
||||
API.get('/settings/issue-types'),
|
||||
API.get('/settings/repair-types'),
|
||||
loadMfr(),
|
||||
]);
|
||||
issueTypes = it;
|
||||
repairTypes = rt;
|
||||
renderIssue();
|
||||
renderRepair();
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,7 @@
|
||||
<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/issue-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>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>신고 상세</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
/* 출장비 요약 카드 */
|
||||
.cost-summary {
|
||||
@@ -104,6 +106,14 @@
|
||||
padding-top: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 신고 인라인 편집 */
|
||||
.report-view { display:block; }
|
||||
.report-edit { display:none; }
|
||||
.report-edit.active { display:block; }
|
||||
.report-view.hidden { display:none; }
|
||||
.issue-chk-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:6px; }
|
||||
.issue-chk-item { display:flex; align-items:center; gap:6px; font-size:13px; }
|
||||
.issue-chk-item input { width:15px; height:15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -111,16 +121,33 @@
|
||||
<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 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/issue-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="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>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/imageCompress.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
@@ -165,11 +192,20 @@ function toggleEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
const IMP_CAT_LABEL = {
|
||||
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
|
||||
installation:'설치환경', other:'기타'
|
||||
};
|
||||
|
||||
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');
|
||||
const [r, issueTypes, manufacturers, improvements] = await Promise.all([
|
||||
API.get('/reports/' + reportId),
|
||||
API.get('/settings/issue-types'),
|
||||
API.get('/accounts?role=manufacturer'),
|
||||
API.get('/improvements'),
|
||||
]);
|
||||
const repair = r.repair;
|
||||
const cost = repair?.cost;
|
||||
|
||||
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`;
|
||||
|
||||
@@ -321,34 +357,130 @@ async function load() {
|
||||
}
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
|
||||
<div class="detail-grid">
|
||||
|
||||
<!-- 신고 정보 -->
|
||||
<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 class="card-title" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>📋 신고 정보</span>
|
||||
<button class="edit-toggle-btn" id="reportEditBtn" onclick="toggleReportEdit()">✏️ 내용 수정</button>
|
||||
</div>
|
||||
|
||||
<!-- 보기 모드 -->
|
||||
<div class="report-view" id="reportView">
|
||||
<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>
|
||||
<tr><td style="color:var(--gray4)">신고 출처</td><td>${r.source === 'dashboard'
|
||||
? `<span style="background:#F5F3FF;color:#7C3AED;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">🖥 대시보드 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
|
||||
: r.source === 'admin'
|
||||
? `<span style="background:#EFF6FF;color:#1565C0;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">⚙️ 관리자 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
|
||||
: `<span style="background:#F0FDF4;color:#166534;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">📱 QR 스캔</span>`
|
||||
}</td></tr>
|
||||
</table>
|
||||
${r.ocpp_log ? `
|
||||
<div style="margin-top:12px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그</label>
|
||||
<pre style="margin-top:6px;background:var(--gray1);border:1px solid var(--gray3);border-radius:6px;padding:10px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;">${escHtmlDetail(r.ocpp_log)}</pre>
|
||||
</div>` : ''}
|
||||
${r.status === 'pending_approval' ? `
|
||||
<button class="btn btn-success btn-sm" style="margin-top:12px"
|
||||
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- 편집 모드 -->
|
||||
<div class="report-edit" id="reportEdit">
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">문제 유형 <span class="req">*</span></label>
|
||||
<div class="issue-chk-grid">
|
||||
${issueTypes.map(i => `
|
||||
<label class="issue-chk-item">
|
||||
<input type="checkbox" class="r-issue-chk" value="${i.key}"
|
||||
${(r.issue_types||[]).includes(i.key) ? 'checked' : ''}>
|
||||
${i.label}
|
||||
</label>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">에러 코드</label>
|
||||
<input type="text" id="rEditErrorCode" value="${r.error_code||''}" placeholder="에러 코드">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">상세 설명</label>
|
||||
<textarea id="rEditDetail" rows="3" placeholder="문제 상황 설명">${r.issue_detail||''}</textarea>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:10px">
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">연락처</label>
|
||||
<input type="text" id="rEditContact" value="${r.contact||''}" placeholder="연락처">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">발생 시각</label>
|
||||
<input type="datetime-local" id="rEditOccurred"
|
||||
value="${r.occurred_at ? r.occurred_at.slice(0,16) : ''}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:14px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 상태</label>
|
||||
<select id="rEditStatus">
|
||||
<option value="pending_approval" ${r.status==='pending_approval'?'selected':''}>승인대기</option>
|
||||
<option value="pending" ${r.status==='pending' ?'selected':''}>접수</option>
|
||||
<option value="in_progress" ${r.status==='in_progress' ?'selected':''}>처리중</option>
|
||||
<option value="done" ${r.status==='done' ?'selected':''}>완료</option>
|
||||
<option value="waiting" ${r.status==='waiting' ?'selected':''}>부품대기</option>
|
||||
<option value="revisit" ${r.status==='revisit' ?'selected':''}>재방문</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:14px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그 <span style="font-weight:400;color:var(--gray4)">(선택)</span></label>
|
||||
<textarea id="rEditOcppLog" rows="5"
|
||||
style="width:100%;padding:8px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:12px;font-family:monospace;resize:vertical;box-sizing:border-box;"
|
||||
placeholder="OCPP 통신 로그 붙여넣기...">${r.ocpp_log || ''}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:5px;cursor:pointer;font-size:12px;color:var(--blue);">
|
||||
<input type="file" id="rEditOcppFile" accept=".txt,.csv,.log" style="display:none" onchange="readOcppFileEdit(this)">
|
||||
📄 파일 선택 (.txt/.csv/.log)
|
||||
<span id="rEditOcppFileName" style="color:var(--gray4);font-weight:400"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:14px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">사진 관리</label>
|
||||
${(r.photos||[]).length ? `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
${(r.photos||[]).map(p => `
|
||||
<div style="position:relative;">
|
||||
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
|
||||
<button onclick="deleteReportPhoto(${r.id},${p.id})"
|
||||
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
<label class="upload-area" for="rEditPhoto" style="padding:10px;font-size:12px;">📷 사진 추가 (선택 · 여러 장)</label>
|
||||
<input type="file" id="rEditPhoto" accept="image/*" multiple style="display:none">
|
||||
<div class="photo-preview" id="rEditPhotoPreview"></div>
|
||||
<div class="photo-info" id="rEditPhotoInfo" style="color:var(--gray4)"></div>
|
||||
</div>
|
||||
<div id="rEditErr" class="alert alert-danger" style="display:none"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-primary btn-sm" onclick="saveReport(${r.id})">💾 저장</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="toggleReportEdit()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">`
|
||||
`<img src="${p.path}" onclick="window.open('${p.path}')" style="cursor:zoom-in">`
|
||||
).join('') || '<span style="font-size:12px;color:var(--gray4)">첨부 없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,7 +488,17 @@ async function load() {
|
||||
|
||||
<!-- 조치 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-title">🔧 조치 정보</div>
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
|
||||
<span>🔧 조치 정보</span>
|
||||
${repair ? `
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
${repair.approved_at
|
||||
? `<span style="font-size:12px;background:#D1FAE5;color:#065F46;padding:3px 12px;border-radius:10px;font-weight:700;">✅ 승인완료 · ${repair.approved_by_name||''}</span>`
|
||||
: `<button onclick="toggleApprovePanel()" id="approvePanelBtn" style="padding:5px 14px;border:none;border-radius:7px;background:var(--green);color:white;font-size:12px;font-weight:700;cursor:pointer;">✅ 조치 승인</button>`
|
||||
}
|
||||
<button onclick="cancelRepair(${repair.id}, ${!!repair.approved_at})" style="padding:5px 14px;border:none;border-radius:7px;background:#FEE2E2;color:#991B1B;font-size:12px;font-weight:700;cursor:pointer;">🔄 조치취소</button>
|
||||
</div>` : ''}
|
||||
</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>
|
||||
@@ -381,6 +523,105 @@ async function load() {
|
||||
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
${renderLocationMap(repair)}
|
||||
|
||||
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
|
||||
repair.linked_improvements && repair.linked_improvements.length ? `
|
||||
<div style="margin-top:14px;padding:12px 14px;background:#EDE9FE;border-radius:8px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#5B21B6;margin-bottom:8px;">🔧 연결된 개선항목</div>
|
||||
${repair.linked_improvements.map(i => `
|
||||
<a href="/pages/admin/improvement-detail.html?id=${i.id}"
|
||||
style="display:flex;align-items:center;gap:8px;font-size:13px;color:#5B21B6;text-decoration:none;padding:4px 0;">
|
||||
<span style="background:#DDD6FE;border-radius:4px;padding:1px 7px;font-size:11px;">${IMP_CAT_LABEL[i.category]||i.category}</span>
|
||||
<strong>#${i.id}</strong> ${i.title}
|
||||
</a>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
${/* ── 승인 패널 (미승인 시) ── */
|
||||
!repair.approved_at ? `
|
||||
<div id="approvePanel" style="display:none;border-top:1px dashed var(--gray3);margin-top:16px;padding-top:16px;">
|
||||
<div style="font-size:14px;font-weight:700;color:var(--navy);margin-bottom:14px;">✅ 조치 승인 — 개선항목 연결</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:14px;">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
||||
<input type="radio" name="impAction" value="none" checked onchange="updateImpSection()">
|
||||
<span>개선항목 연결 안 함</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
||||
<input type="radio" name="impAction" value="link" onchange="updateImpSection()">
|
||||
<span>기존 개선항목에 연결 — <span style="color:var(--gray4);font-size:12px;">이전에 등록된 항목 선택</span></span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
||||
<input type="radio" name="impAction" value="create" onchange="updateImpSection()">
|
||||
<span>신규 개선항목 생성 — <span style="color:var(--gray4);font-size:12px;">이번 조치 기반으로 새 항목 등록</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 기존 항목 연결 -->
|
||||
<div id="impLinkSection" style="display:none;margin-bottom:14px;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;">개선항목 검색</div>
|
||||
<input type="text" id="impSearch" placeholder="제목으로 검색..."
|
||||
style="width:100%;padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;margin-bottom:6px;"
|
||||
oninput="filterImpOptions()">
|
||||
<select id="impSelect" size="5"
|
||||
style="width:100%;border:1px solid var(--gray3);border-radius:6px;font-size:13px;padding:4px;">
|
||||
${improvements.length
|
||||
? improvements.map(i =>
|
||||
`<option value="${i.id}">[${IMP_CAT_LABEL[i.category]||i.category}] #${i.id} ${i.title}</option>`
|
||||
).join('')
|
||||
: '<option disabled>등록된 개선항목이 없습니다</option>'}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 신규 항목 생성 -->
|
||||
<div id="impCreateSection" style="display:none;margin-bottom:14px;padding:14px;background:var(--gray1);border-radius:8px;">
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">제목 <span class="req">*</span></label>
|
||||
<input type="text" id="impTitle" placeholder="개선항목 제목">
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:10px">
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">분류 <span class="req">*</span></label>
|
||||
<select id="impCategory">
|
||||
<option value="">선택</option>
|
||||
<option value="hardware">하드웨어</option>
|
||||
<option value="software">소프트웨어</option>
|
||||
<option value="firmware">펌웨어</option>
|
||||
<option value="installation">설치환경</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">우선순위</label>
|
||||
<select id="impPriority">
|
||||
<option value="low">낮음</option>
|
||||
<option value="normal" selected>보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">내용 <span class="req">*</span></label>
|
||||
<textarea id="impDesc" rows="3" placeholder="개선이 필요한 내용을 구체적으로 기술해 주세요."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">담당 제조사</label>
|
||||
<select id="impMfr">
|
||||
<option value="">미지정 (나중에 설정)</option>
|
||||
${manufacturers.map(m =>
|
||||
`<option value="${m.id}">${m.company ? m.company+' / ' : ''}${m.name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="approveErr" class="alert alert-danger" style="display:none;margin-bottom:10px;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-success btn-sm" onclick="doApproveRepair(${repair.id})">✅ 승인 완료</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="toggleApprovePanel()">취소</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
` : '<div class="alert alert-info">아직 정비사가 조치를 입력하지 않았습니다.</div>'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,18 +629,256 @@ async function load() {
|
||||
${costHtml}
|
||||
`;
|
||||
|
||||
// 신고 편집 폼 사진 압축 설정
|
||||
if (document.getElementById('rEditPhoto')) {
|
||||
ImageCompressor.setupPreview('rEditPhoto', 'rEditPhotoPreview', 'rEditPhotoInfo');
|
||||
}
|
||||
|
||||
// 지도 초기화 (수리 정보가 있을 때만)
|
||||
if (repair) initRepairMap(repair);
|
||||
|
||||
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
|
||||
if (!editOpen) return;
|
||||
const wrap = document.getElementById('costEditWrap');
|
||||
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
|
||||
}
|
||||
|
||||
/* ── 방문 위치 지도 ── */
|
||||
function renderLocationMap(repair) {
|
||||
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
|
||||
const cLat = repair.charger_lat, cLng = repair.charger_lng;
|
||||
if (!mLat && !cLat) return '';
|
||||
|
||||
let distHtml = '';
|
||||
if (mLat && cLat) {
|
||||
const d = haversineM(mLat, mLng, cLat, cLng);
|
||||
const within = d <= 200;
|
||||
distHtml = `
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:8px;">
|
||||
<span style="padding:3px 10px;border-radius:12px;font-weight:700;
|
||||
background:${within ? '#D1FAE5' : '#FEE2E2'};color:${within ? '#065F46' : '#991B1B'}">
|
||||
${within ? '✅ 현장 방문 확인' : '⚠️ 현장 거리 초과'}
|
||||
</span>
|
||||
<span style="color:var(--gray4)">충전기와의 거리: <strong>${d < 1000 ? Math.round(d)+'m' : (d/1000).toFixed(1)+'km'}</strong></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="margin-top:16px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2);display:block;margin-bottom:6px">📍 조치 위치</label>
|
||||
${distHtml}
|
||||
<div id="repairMap" style="height:220px;border-radius:8px;border:1px solid var(--gray3);overflow:hidden"></div>
|
||||
<div style="display:flex;gap:16px;font-size:11px;color:var(--gray4);margin-top:5px;">
|
||||
${mLat ? '<span>🔵 정비사 위치</span>' : ''}
|
||||
${cLat ? '<span>🔴 충전기 등록 위치</span>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function initRepairMap(repair) {
|
||||
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
|
||||
const cLat = repair.charger_lat, cLng = repair.charger_lng;
|
||||
if (!document.getElementById('repairMap')) return;
|
||||
|
||||
const center = mLat ? [mLat, mLng] : [cLat, cLng];
|
||||
const map = L.map('repairMap', { zoomControl: true, scrollWheelZoom: false })
|
||||
.setView(center, 16);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
const bounds = [];
|
||||
|
||||
if (mLat) {
|
||||
const icon = L.divIcon({
|
||||
html: '<div style="width:14px;height:14px;border-radius:50%;background:#2563EB;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
|
||||
});
|
||||
L.marker([mLat, mLng], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup('<b>정비사 위치</b><br>조치 제출 시점 기록')
|
||||
.openPopup();
|
||||
bounds.push([mLat, mLng]);
|
||||
}
|
||||
|
||||
if (cLat) {
|
||||
const icon = L.divIcon({
|
||||
html: '<div style="width:14px;height:14px;border-radius:50%;background:#DC2626;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
|
||||
});
|
||||
L.marker([cLat, cLng], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup('<b>충전기 등록 위치</b>');
|
||||
bounds.push([cLat, cLng]);
|
||||
}
|
||||
|
||||
if (mLat && cLat) {
|
||||
L.polyline([[mLat, mLng], [cLat, cLng]], {
|
||||
color: '#6366F1', weight: 2, dashArray: '5 5', opacity: 0.7
|
||||
}).addTo(map);
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
|
||||
function haversineM(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2)**2 +
|
||||
Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
}
|
||||
|
||||
function escHtmlDetail(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function readOcppFileEdit(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
document.getElementById('rEditOcppFileName').textContent = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => { document.getElementById('rEditOcppLog').value = e.target.result; };
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
}
|
||||
|
||||
async function deleteReportPhoto(reportId, photoId) {
|
||||
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await API.delete(`/reports/${reportId}/photos/${photoId}`);
|
||||
load();
|
||||
} catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
function toggleReportEdit() {
|
||||
const view = document.getElementById('reportView');
|
||||
const edit = document.getElementById('reportEdit');
|
||||
const btn = document.getElementById('reportEditBtn');
|
||||
const isEditing = edit.classList.contains('active');
|
||||
if (isEditing) {
|
||||
edit.classList.remove('active');
|
||||
view.classList.remove('hidden');
|
||||
btn.innerHTML = '✏️ 내용 수정';
|
||||
} else {
|
||||
view.classList.add('hidden');
|
||||
edit.classList.add('active');
|
||||
btn.innerHTML = '✕ 취소';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReport(reportId) {
|
||||
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
|
||||
if (!issues.length) {
|
||||
const err = document.getElementById('rEditErr');
|
||||
err.textContent = '문제 유형을 1개 이상 선택해 주세요.';
|
||||
err.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
document.getElementById('rEditErr').style.display = 'none';
|
||||
const fd = new FormData();
|
||||
fd.append('issue_types', JSON.stringify(issues));
|
||||
fd.append('issue_detail', document.getElementById('rEditDetail').value);
|
||||
fd.append('error_code', document.getElementById('rEditErrorCode').value);
|
||||
fd.append('contact', document.getElementById('rEditContact').value);
|
||||
fd.append('occurred_at', document.getElementById('rEditOccurred').value);
|
||||
fd.append('status', document.getElementById('rEditStatus').value);
|
||||
fd.append('ocpp_log', document.getElementById('rEditOcppLog').value);
|
||||
const newPhotos = document.getElementById('rEditPhoto')?.files || [];
|
||||
Array.from(newPhotos).forEach(f => fd.append('photos', f));
|
||||
try {
|
||||
await API.patch(`/reports/${reportId}`, fd);
|
||||
load();
|
||||
} catch(e) {
|
||||
const err = document.getElementById('rEditErr');
|
||||
err.textContent = e.message;
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function toggleApprovePanel() {
|
||||
const panel = document.getElementById('approvePanel');
|
||||
const btn = document.getElementById('approvePanelBtn');
|
||||
if (!panel) return;
|
||||
const opening = panel.style.display === 'none';
|
||||
panel.style.display = opening ? 'block' : 'none';
|
||||
if (btn) btn.textContent = opening ? '✕ 취소' : '✅ 조치 승인';
|
||||
}
|
||||
|
||||
function updateImpSection() {
|
||||
const action = document.querySelector('input[name="impAction"]:checked')?.value;
|
||||
document.getElementById('impLinkSection').style.display = action === 'link' ? 'block' : 'none';
|
||||
document.getElementById('impCreateSection').style.display = action === 'create' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterImpOptions() {
|
||||
const q = document.getElementById('impSearch').value.toLowerCase();
|
||||
[...document.getElementById('impSelect').options].forEach(opt => {
|
||||
opt.hidden = q && !opt.text.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
async function doApproveRepair(repairId) {
|
||||
const action = document.querySelector('input[name="impAction"]:checked')?.value || 'none';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('improvement_action', action);
|
||||
|
||||
if (action === 'link') {
|
||||
const sel = document.getElementById('impSelect');
|
||||
const impId = sel?.value;
|
||||
if (!impId) { showApproveErr('연결할 개선항목을 선택해 주세요.'); return; }
|
||||
fd.append('improvement_id', impId);
|
||||
} else if (action === 'create') {
|
||||
const title = document.getElementById('impTitle').value.trim();
|
||||
const cat = document.getElementById('impCategory').value;
|
||||
const desc = document.getElementById('impDesc').value.trim();
|
||||
if (!title || !cat || !desc) { showApproveErr('제목, 분류, 내용을 모두 입력해 주세요.'); return; }
|
||||
fd.append('imp_title', title);
|
||||
fd.append('imp_category', cat);
|
||||
fd.append('imp_description', desc);
|
||||
fd.append('imp_priority', document.getElementById('impPriority').value);
|
||||
const mfr = document.getElementById('impMfr').value;
|
||||
if (mfr) fd.append('imp_manufacturer_id', mfr);
|
||||
}
|
||||
|
||||
if (!confirm('이 조치 내역을 승인하시겠습니까?\n승인 후에는 정비사가 수정할 수 없습니다.')) return;
|
||||
|
||||
try {
|
||||
const res = await API.post(`/repairs/${repairId}/approve`, fd);
|
||||
let msg = '✅ 조치가 승인되었습니다.';
|
||||
if (res.improvement_id) {
|
||||
msg += `\n개선항목 #${res.improvement_id}에 연결되었습니다.`;
|
||||
}
|
||||
alert(msg);
|
||||
load();
|
||||
} catch(e) { showApproveErr(e.message); }
|
||||
}
|
||||
|
||||
function showApproveErr(msg) {
|
||||
const el = document.getElementById('approveErr');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
async function cancelRepair(repairId, isApproved) {
|
||||
const msg = isApproved
|
||||
? '⚠️ 승인된 조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?'
|
||||
: '조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?';
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
await API.delete('/repairs/' + repairId);
|
||||
load();
|
||||
} catch(e) { alert('조치취소 오류: ' + e.message); }
|
||||
}
|
||||
|
||||
async function approveReport(id) {
|
||||
if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return;
|
||||
await API.patch(`/reports/${id}/approve`);
|
||||
@@ -434,5 +913,7 @@ function showCostErr(msg) {
|
||||
|
||||
load();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,40 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>신고 목록</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected { background:var(--light-gray,#f0f4ff); }
|
||||
#btnDelete { display:none; }
|
||||
|
||||
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
|
||||
.view-btn { padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white; cursor:pointer; color:var(--gray4); transition:all .15s; }
|
||||
.view-btn.active { background:var(--navy); color:white; }
|
||||
|
||||
#mapWrap {
|
||||
display:none;
|
||||
height: calc(100vh - 230px);
|
||||
min-height: 420px;
|
||||
border-radius:10px;
|
||||
overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
isolation: isolate;
|
||||
}
|
||||
#reportMap { width:100%; height:100%; }
|
||||
|
||||
.rp-pin {
|
||||
width:28px; height:28px; border-radius:50% 50% 50% 0;
|
||||
transform:rotate(-45deg); border:3px solid white;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||
}
|
||||
.rp-pin.pending { background:#EF4444; }
|
||||
.rp-pin.in_progress { background:#F59E0B; }
|
||||
.rp-pin.waiting { background:#3B82F6; }
|
||||
.rp-pin.revisit { background:#8B5CF6; }
|
||||
.rp-pin.done { background:#9CA3AF; }
|
||||
.rp-pin.multi { background:#7C3AED; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
@@ -17,6 +51,7 @@
|
||||
<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/issue-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>
|
||||
@@ -24,10 +59,19 @@
|
||||
<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 style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/reports','AS신고목록.xlsx')">📥 엑셀 다운로드</button>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
|
||||
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card" style="padding:12px 16px;margin-bottom:12px">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<select id="fStatus" style="width:auto">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending_approval">승인대기</option>
|
||||
@@ -39,42 +83,256 @@
|
||||
</select>
|
||||
<input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px">
|
||||
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
|
||||
<span id="resultCount" style="font-size:13px;color:var(--gray4);margin-left:4px"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 뷰 -->
|
||||
<div id="listWrap" class="card" style="padding:0">
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>#</th><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 id="empty" class="alert alert-info" style="display:none">조회된 신고가 없습니다.</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none;margin:14px">조회된 신고가 없습니다.</div>
|
||||
</div>
|
||||
|
||||
<!-- 지도 뷰 -->
|
||||
<div id="mapWrap"><div id="reportMap"></div></div>
|
||||
<div id="mapMeta" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:14px;flex-wrap:wrap;align-items:center">
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>접수</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#F59E0B;margin-right:4px"></span>처리중</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;margin-right:4px"></span>부품대기</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8B5CF6;margin-right:4px"></span>재방문</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#9CA3AF;margin-right:4px"></span>완료</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#7C3AED;margin-right:4px"></span>복수신고</span>
|
||||
<span id="mapNoGps" style="margin-left:auto"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let allRows = [];
|
||||
let curView = 'list';
|
||||
let reportMap = null;
|
||||
let mapMarkers = [];
|
||||
|
||||
// ── URL 파라미터 초기값 ──
|
||||
const _p = new URLSearchParams(location.search);
|
||||
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
|
||||
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
function setView(v) {
|
||||
sessionStorage.setItem('reportsView', v);
|
||||
curView = v;
|
||||
document.getElementById('btnList').classList.toggle('active', v === 'list');
|
||||
document.getElementById('btnMap').classList.toggle('active', v === 'map');
|
||||
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
|
||||
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
|
||||
document.getElementById('mapMeta').style.display = v === 'map' ? 'flex' : 'none';
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(v === 'list' && document.querySelectorAll('.row-chk:checked').length > 0) ? 'inline-flex' : 'none';
|
||||
if (v === 'map') {
|
||||
initReportMap();
|
||||
renderReportMap();
|
||||
setTimeout(() => reportMap && reportMap.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 체크박스 ──
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 신고 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/reports/bulk', ids); await load(); }
|
||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
function maskPhone(p) {
|
||||
const d = (p||'').replace(/\D/g,'');
|
||||
if (d.length >= 10) return d.slice(0,3) + '-****-' + d.slice(-4);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
async function load() {
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
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);
|
||||
if (s) url += 'status=' + s + '&';
|
||||
if (c) url += 'charger_id=' + c + '&';
|
||||
allRows = await API.get(url);
|
||||
|
||||
document.getElementById('resultCount').textContent = allRows.length + '건';
|
||||
renderTable();
|
||||
if (curView === 'map') renderReportMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderTable() {
|
||||
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>
|
||||
document.getElementById('empty').style.display = allRows.length ? 'none' : 'block';
|
||||
tbody.innerHTML = allRows.map((r, i) => `
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${r.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
<span style="font-weight:700">${i+1}</span>
|
||||
<span style="display:block;font-size:10px;color:var(--gray4);font-weight:400">#${r.id}</span>
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"><strong>${r.charger_id}</strong></td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.station_name||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.charger_type||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer;max-width:200px">${(r.issue_types||[]).join(', ')}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
${r.source === 'dashboard'
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:#7C3AED">🖥 대시보드</div>`
|
||||
: r.source === 'admin'
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:var(--blue)">⚙️ 관리자</div>`
|
||||
: `<div style="font-size:12px;color:var(--text)">${r.contact ? maskPhone(r.contact) : '익명'}</div><div style="font-size:11px;color:#166534">📱 QR</div>`}
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.statusBadge(r.status)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
${r.mechanic_name
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.mechanic_name}</div>${r.mechanic_company ? `<div style="font-size:11px;color:var(--gray4)">${r.mechanic_company}</div>` : ''}`
|
||||
: '<span style="color:var(--gray4)">-</span>'}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
load();
|
||||
|
||||
// ── 지도 초기화 ──
|
||||
function initReportMap() {
|
||||
if (reportMap) return;
|
||||
reportMap = L.map('reportMap', { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(reportMap);
|
||||
}
|
||||
|
||||
// 상태별 마커 색상
|
||||
const STATUS_CLASS = {
|
||||
pending: 'pending', pending_approval: 'pending',
|
||||
in_progress: 'in_progress',
|
||||
waiting: 'waiting', revisit: 'revisit',
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
// ── 지도 마커 렌더 ──
|
||||
function renderReportMap() {
|
||||
if (!reportMap) return;
|
||||
mapMarkers.forEach(m => m.remove());
|
||||
mapMarkers = [];
|
||||
|
||||
// 충전기별 그룹핑 (charger GPS 우선, 없으면 신고 GPS)
|
||||
const grouped = {};
|
||||
allRows.forEach(r => {
|
||||
const lat = r.charger_lat || r.gps_lat;
|
||||
const lng = r.charger_lng || r.gps_lng;
|
||||
if (!lat || !lng) return;
|
||||
if (!grouped[r.charger_id]) {
|
||||
grouped[r.charger_id] = {
|
||||
charger_id: r.charger_id, charger_name: r.charger_name,
|
||||
station_name: r.station_name, location_detail: r.location_detail,
|
||||
lat, lng, reports: [],
|
||||
};
|
||||
}
|
||||
grouped[r.charger_id].reports.push(r);
|
||||
});
|
||||
|
||||
const groups = Object.values(grouped);
|
||||
const noGps = allRows.filter(r => !r.charger_lat && !r.gps_lat).length;
|
||||
document.getElementById('mapNoGps').textContent = noGps ? `📍 GPS 미등록 ${noGps}건 미표시` : '';
|
||||
|
||||
if (!groups.length) {
|
||||
reportMap.setView([36.5, 127.8], 7);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.forEach(g => {
|
||||
// 대표 상태 결정 (우선순위: pending > in_progress > waiting > revisit > done)
|
||||
const priority = ['pending','pending_approval','in_progress','waiting','revisit','done'];
|
||||
const topStatus = priority.find(s => g.reports.some(r => r.status === s)) || 'pending';
|
||||
const pinClass = g.reports.length > 1 ? 'multi' : (STATUS_CLASS[topStatus] || 'pending');
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="rp-pin ${pinClass}"></div>`,
|
||||
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
|
||||
});
|
||||
|
||||
const m = L.marker([g.lat, g.lng], { icon }).addTo(reportMap);
|
||||
|
||||
if (g.reports.length === 1) {
|
||||
const r = g.reports[0];
|
||||
m.on('click', () => { location.href = `/pages/admin/report-detail.html?id=${r.id}`; });
|
||||
} else {
|
||||
const rowsHtml = g.reports.map(r => {
|
||||
const h = (Date.now() - new Date(r.reported_at)) / 3600000;
|
||||
const age = h < 1 ? Math.round(h*60)+'분' : h < 24 ? Math.round(h)+'h' : (h/24).toFixed(1)+'일';
|
||||
return `<a href="/pages/admin/report-detail.html?id=${r.id}"
|
||||
style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:6px 8px;border-radius:6px;font-size:12px;text-decoration:none;
|
||||
color:inherit;background:#f9fafb;border:1px solid #e5e7eb;margin-bottom:5px">
|
||||
<span><strong>#${r.id}</strong> ${(r.issue_types||[]).join(', ')}</span>
|
||||
<span style="margin-left:8px;white-space:nowrap">${Auth.statusBadge(r.status)}</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
const popup = `
|
||||
<div style="min-width:230px">
|
||||
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">
|
||||
⚡ ${g.charger_id}
|
||||
<span style="font-size:12px;color:#7C3AED;font-weight:600">${g.reports.length}건</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:10px;line-height:1.5">
|
||||
📍 ${g.station_name||'-'}${g.location_detail ? '<br>'+g.location_detail : ''}
|
||||
</div>
|
||||
${rowsHtml}
|
||||
</div>`;
|
||||
m.bindPopup(popup, { maxWidth: 300 });
|
||||
}
|
||||
|
||||
mapMarkers.push(m);
|
||||
});
|
||||
|
||||
const bounds = L.latLngBounds(groups.map(g => [g.lat, g.lng]));
|
||||
reportMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
if (groups.length === 1) reportMap.setZoom(14);
|
||||
}
|
||||
|
||||
load().then(() => {
|
||||
if (sessionStorage.getItem('reportsView') === 'map') setView('map');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<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/issue-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>
|
||||
@@ -69,6 +70,103 @@
|
||||
<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:14px">
|
||||
대시보드의 <strong>처리시간 평균</strong> 및 <strong>대기 심각도</strong> 지표를 계산할 때<br>
|
||||
시작 시점으로 사용할 기준을 선택합니다.
|
||||
</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-occurred">
|
||||
<input type="radio" name="timeBase" value="occurred" 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-reported">
|
||||
<input type="radio" name="timeBase" value="reported" 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>
|
||||
|
||||
<!-- 처리시간 집계 방식 -->
|
||||
<div class="card" style="max-width:560px;margin-top:20px">
|
||||
<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-wt-off">
|
||||
<input type="radio" name="worktimeMode" value="off" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
|
||||
<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;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-wt-holiday24h">
|
||||
<input type="radio" name="worktimeMode" value="holiday_24h" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
|
||||
<div>
|
||||
<div style="font-weight:700">🗓 공휴일 제외 24시간</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-top:3px">공휴일만 제외하고, 주말을 포함한 나머지 날은 하루 24시간 전체를 카운트합니다.</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-wt-worktime">
|
||||
<input type="radio" name="worktimeMode" value="worktime" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
|
||||
<div>
|
||||
<div style="font-weight:700">💼 업무시간 기준 (09:00–18:00)</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-top:3px">주말·공휴일 제외 후, 평일 업무시간(09:00–18:00) 내 경과시간만 집계합니다.</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 공휴일 관리 (공휴일 제외 모드일 때만 표시) -->
|
||||
<div id="holidaySection" style="display:none;margin-top:18px;border-top:1px solid var(--gray2);padding-top:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<div style="font-size:13px;font-weight:700;color:var(--navy)">
|
||||
📅 공휴일 관리
|
||||
<select id="holidayYear" onchange="loadHolidays()" style="margin-left:10px;width:auto;font-size:13px;padding:4px 8px">
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-outline" onclick="addFixedHolidays()">📋 고정 공휴일 추가</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="openHolidayModal()">+ 공휴일 추가</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-bottom:10px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;padding:8px 12px">
|
||||
⚠ <strong>설날·추석·부처님오신날</strong> 등 음력 공휴일과 <strong>대체공휴일</strong>은 매년 직접 추가해야 합니다.
|
||||
</div>
|
||||
<div id="holidayList" style="max-height:300px;overflow-y:auto">
|
||||
<div style="color:var(--gray4);font-size:13px;text-align:center;padding:20px">불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공휴일 추가 모달 -->
|
||||
<div class="modal-bg hidden" id="holidayModal">
|
||||
<div class="modal" style="max-width:380px">
|
||||
<div class="modal-title">공휴일 추가</div>
|
||||
<div class="form-group">
|
||||
<label>날짜 <span class="req">*</span></label>
|
||||
<input type="date" id="hDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>공휴일명 <span class="req">*</span></label>
|
||||
<input type="text" id="hName" placeholder="예) 추석">
|
||||
</div>
|
||||
<div id="hErr" class="alert alert-danger" style="display:none"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeHolidayModal()">취소</button>
|
||||
<button class="btn btn-primary" onclick="saveHoliday()">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이미지 압축 설정 -->
|
||||
<div class="card" style="max-width:560px;margin-top:20px">
|
||||
<div class="card-title">🖼️ 사진 업로드 압축 설정</div>
|
||||
@@ -203,9 +301,20 @@ function updateEffect() {
|
||||
async function load() {
|
||||
const s = await API.get('/settings');
|
||||
const policy = s.report_visibility_policy || 'immediate';
|
||||
document.querySelector(`input[value="${policy}"]`).checked = true;
|
||||
document.querySelector(`input[name="policy"][value="${policy}"]`).checked = true;
|
||||
updateLabels();
|
||||
|
||||
const timeBase = s.time_metric_base || 'occurred';
|
||||
document.querySelector(`input[name="timeBase"][value="${timeBase}"]`).checked = true;
|
||||
updateTimeBaseLabels();
|
||||
|
||||
const wtMode = ['off','holiday_24h','worktime'].includes(s.time_metric_worktime)
|
||||
? s.time_metric_worktime
|
||||
: (s.time_metric_worktime === 'true' ? 'worktime' : 'off');
|
||||
const wtRadio = document.querySelector(`input[name="worktimeMode"][value="${wtMode}"]`);
|
||||
if (wtRadio) wtRadio.checked = true;
|
||||
updateWorktimeModeLabels();
|
||||
|
||||
const enabled = s.image_compress_enabled !== 'false';
|
||||
document.getElementById('compressEnabled').checked = enabled;
|
||||
|
||||
@@ -227,12 +336,22 @@ function updateLabels() {
|
||||
lbl.style.background = r.checked ? '#E3EDFF' : 'white';
|
||||
});
|
||||
}
|
||||
function updateTimeBaseLabels() {
|
||||
document.querySelectorAll('input[name="timeBase"]').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.querySelectorAll('input[name="timeBase"]').forEach(r => r.addEventListener('change', updateTimeBaseLabels));
|
||||
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('time_metric_base', document.querySelector('input[name="timeBase"]:checked').value);
|
||||
fd.append('time_metric_worktime', document.querySelector('input[name="worktimeMode"]: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);
|
||||
@@ -263,6 +382,100 @@ async function changePw() {
|
||||
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
||||
}
|
||||
|
||||
// ── 처리시간 집계 방식 ──
|
||||
function updateWorktimeModeLabels() {
|
||||
document.querySelectorAll('input[name="worktimeMode"]').forEach(r => {
|
||||
const lbl = r.closest('label');
|
||||
lbl.style.borderColor = r.checked ? 'var(--accent)' : 'var(--gray3)';
|
||||
lbl.style.background = r.checked ? '#E3EDFF' : 'white';
|
||||
});
|
||||
const mode = document.querySelector('input[name="worktimeMode"]:checked')?.value || 'off';
|
||||
const showHoliday = mode === 'holiday_24h' || mode === 'worktime';
|
||||
document.getElementById('holidaySection').style.display = showHoliday ? 'block' : 'none';
|
||||
if (showHoliday && !document.getElementById('holidayYear').options.length) initHolidayYear();
|
||||
}
|
||||
|
||||
function initHolidayYear() {
|
||||
const sel = document.getElementById('holidayYear');
|
||||
const cur = new Date().getFullYear();
|
||||
for (let y = cur + 1; y >= cur - 2; y--) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = y; opt.textContent = y + '년';
|
||||
if (y === cur) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
loadHolidays();
|
||||
}
|
||||
|
||||
async function loadHolidays() {
|
||||
const year = document.getElementById('holidayYear').value;
|
||||
const list = await API.get('/holidays?year=' + year);
|
||||
const el = document.getElementById('holidayList');
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:20px">등록된 공휴일이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="background:var(--gray2)">
|
||||
<th style="padding:7px 10px;text-align:left">날짜</th>
|
||||
<th style="padding:7px 10px;text-align:left">공휴일명</th>
|
||||
<th style="padding:7px 10px;width:50px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${list.map(h => `
|
||||
<tr style="border-bottom:1px solid var(--gray2)">
|
||||
<td style="padding:7px 10px">${h.date}</td>
|
||||
<td style="padding:7px 10px">${h.name}</td>
|
||||
<td style="padding:7px 10px;text-align:center">
|
||||
<button onclick="deleteHoliday('${h.date}')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:15px" title="삭제">✕</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function openHolidayModal() {
|
||||
document.getElementById('holidayModal').classList.remove('hidden');
|
||||
document.getElementById('hErr').style.display = 'none';
|
||||
document.getElementById('hDate').value = '';
|
||||
document.getElementById('hName').value = '';
|
||||
}
|
||||
function closeHolidayModal() { document.getElementById('holidayModal').classList.add('hidden'); }
|
||||
|
||||
async function saveHoliday() {
|
||||
const d = document.getElementById('hDate').value;
|
||||
const n = document.getElementById('hName').value.trim();
|
||||
const errEl = document.getElementById('hErr');
|
||||
if (!d || !n) { errEl.textContent = '날짜와 공휴일명을 입력하세요.'; errEl.style.display = 'block'; return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('holiday_date', d); fd.append('name', n);
|
||||
await API.post('/holidays', fd);
|
||||
closeHolidayModal(); loadHolidays();
|
||||
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
||||
}
|
||||
|
||||
async function deleteHoliday(date) {
|
||||
if (!confirm(`${date} 공휴일을 삭제하시겠습니까?`)) return;
|
||||
await API.delete('/holidays/' + date);
|
||||
loadHolidays();
|
||||
}
|
||||
|
||||
// 고정 공휴일 (양력) 일괄 추가
|
||||
async function addFixedHolidays() {
|
||||
const year = parseInt(document.getElementById('holidayYear').value);
|
||||
const fixed = [
|
||||
{ date: `${year}-01-01`, name: '신정' },
|
||||
{ date: `${year}-03-01`, name: '삼일절' },
|
||||
{ date: `${year}-05-05`, name: '어린이날' },
|
||||
{ date: `${year}-06-06`, name: '현충일' },
|
||||
{ date: `${year}-08-15`, name: '광복절' },
|
||||
{ date: `${year}-10-03`, name: '개천절' },
|
||||
{ date: `${year}-10-09`, name: '한글날' },
|
||||
{ date: `${year}-12-25`, name: '성탄절' },
|
||||
];
|
||||
const res = await API.post('/holidays/bulk', fixed);
|
||||
alert(`${res.added}개 고정 공휴일이 추가되었습니다.\n설날·추석·부처님오신날·대체공휴일은 직접 추가해 주세요.`);
|
||||
loadHolidays();
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -5,13 +5,36 @@
|
||||
<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;}
|
||||
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, #regErr { font-size:13px; text-align:center; min-height:18px; margin-bottom:8px; }
|
||||
#err { color:var(--red); }
|
||||
#regErr { color:var(--red); }
|
||||
|
||||
.tab-row {
|
||||
display:flex; gap:0; border-bottom:2px solid var(--gray2); margin-bottom:24px;
|
||||
}
|
||||
.tab-row button {
|
||||
flex:1; background:none; border:none; padding:9px 0; font-size:14px; font-weight:600;
|
||||
color:var(--gray4); border-bottom:3px solid transparent; margin-bottom:-2px; cursor:pointer;
|
||||
transition:color .15s, border-color .15s;
|
||||
}
|
||||
.tab-row button.active { color:var(--navy); border-bottom-color:var(--accent); }
|
||||
|
||||
.pane { display:none; }
|
||||
.pane.active { display:block; }
|
||||
|
||||
.reg-notice {
|
||||
background:#EFF6FF; border:1px solid #BFDBFE; border-radius:8px;
|
||||
padding:10px 14px; font-size:12px; color:#1E40AF; margin-bottom:16px; line-height:1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,20 +43,81 @@ body{display:flex;align-items:center;justify-content:center;min-height:100vh;bac
|
||||
<h1>⚡ EV AS 관리</h1>
|
||||
<p>cs.byunc.com</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>아이디</label>
|
||||
<input type="text" id="username" placeholder="아이디 입력" autofocus>
|
||||
|
||||
<div class="tab-row">
|
||||
<button id="tabLogin" class="active" onclick="switchTab('login')">로그인</button>
|
||||
<button id="tabRegister" onclick="switchTab('register')">회원가입</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비밀번호</label>
|
||||
<input type="password" id="password" placeholder="비밀번호 입력">
|
||||
|
||||
<!-- 로그인 -->
|
||||
<div class="pane active" id="paneLogin">
|
||||
<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>
|
||||
<div id="err"></div>
|
||||
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button>
|
||||
|
||||
<!-- 회원가입 -->
|
||||
<div class="pane" id="paneRegister">
|
||||
<div class="reg-notice">
|
||||
📌 정비사 계정으로 가입됩니다.<br>
|
||||
가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>이름 <span style="color:var(--red)">*</span></label>
|
||||
<input type="text" id="regName" placeholder="실명 입력">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>아이디 <span style="color:var(--red)">*</span></label>
|
||||
<input type="text" id="regUsername" placeholder="영문·숫자 조합">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비밀번호 <span style="color:var(--red)">*</span></label>
|
||||
<input type="password" id="regPassword" placeholder="8자 이상 권장">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비밀번호 확인 <span style="color:var(--red)">*</span></label>
|
||||
<input type="password" id="regPassword2" placeholder="비밀번호 재입력">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>전화번호 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
|
||||
<input type="tel" id="regPhone" placeholder="예) 010-1234-5678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>회사명 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
|
||||
<select id="regCompany">
|
||||
<option value="">-- 소속 제조사 없음 --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="regErr"></div>
|
||||
<button class="btn btn-primary btn-lg" id="regBtn">가입 신청</button>
|
||||
<div id="regOk" class="alert alert-success" style="display:none;margin-top:14px;text-align:center">
|
||||
✅ 가입 신청이 완료되었습니다.<br>
|
||||
<span style="font-size:12px">관리자 승인 후 로그인 가능합니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.getElementById('tabLogin').classList.toggle('active', name==='login');
|
||||
document.getElementById('tabRegister').classList.toggle('active', name==='register');
|
||||
document.getElementById('paneLogin').classList.toggle('active', name==='login');
|
||||
document.getElementById('paneRegister').classList.toggle('active', name==='register');
|
||||
document.getElementById('err').textContent = '';
|
||||
document.getElementById('regErr').textContent = '';
|
||||
}
|
||||
|
||||
// ── 로그인 ──
|
||||
async function doLogin() {
|
||||
const u = document.getElementById('username').value.trim();
|
||||
const p = document.getElementById('password').value;
|
||||
@@ -47,16 +131,70 @@ async function doLogin() {
|
||||
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';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 제조사 목록 로드 (비인증) ──
|
||||
async function loadCompanies() {
|
||||
try {
|
||||
const list = await fetch('/api/manufacturers/public').then(r => r.json());
|
||||
const sel = document.getElementById('regCompany');
|
||||
list.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name; opt.textContent = m.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
loadCompanies();
|
||||
|
||||
// ── 회원가입 ──
|
||||
async function doRegister() {
|
||||
const name = document.getElementById('regName').value.trim();
|
||||
const uname = document.getElementById('regUsername').value.trim();
|
||||
const pw = document.getElementById('regPassword').value;
|
||||
const pw2 = document.getElementById('regPassword2').value;
|
||||
const phone = document.getElementById('regPhone').value.trim();
|
||||
const company = document.getElementById('regCompany').value;
|
||||
const errEl = document.getElementById('regErr');
|
||||
|
||||
errEl.textContent = '';
|
||||
if (!name) { errEl.textContent = '이름을 입력하세요.'; return; }
|
||||
if (!uname) { errEl.textContent = '아이디를 입력하세요.'; return; }
|
||||
if (!pw) { errEl.textContent = '비밀번호를 입력하세요.'; return; }
|
||||
if (pw !== pw2) { errEl.textContent = '비밀번호가 일치하지 않습니다.'; return; }
|
||||
|
||||
document.getElementById('regBtn').disabled = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('username', uname);
|
||||
fd.append('password', pw);
|
||||
fd.append('name', name);
|
||||
fd.append('phone', phone);
|
||||
fd.append('company', company);
|
||||
const res = await fetch('/api/auth/register', { method:'POST', body: fd });
|
||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
|
||||
document.getElementById('regOk').style.display = 'block';
|
||||
document.getElementById('regBtn').style.display = 'none';
|
||||
['regName','regUsername','regPassword','regPassword2','regPhone'].forEach(id =>
|
||||
document.getElementById(id).value = '');
|
||||
document.getElementById('regCompany').value = '';
|
||||
} catch(e) {
|
||||
errEl.textContent = e.message;
|
||||
document.getElementById('regBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('loginBtn').addEventListener('click', doLogin);
|
||||
document.getElementById('password').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
|
||||
document.getElementById('regBtn').addEventListener('click', doRegister);
|
||||
document.getElementById('regPassword2').addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,12 +4,57 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>정비사 대시보드</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
|
||||
.view-btn {
|
||||
padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white;
|
||||
cursor:pointer; color:var(--gray4); transition:all .15s;
|
||||
}
|
||||
.view-btn.active { background:var(--navy); color:white; }
|
||||
|
||||
#mapWrap {
|
||||
display:none;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 420px;
|
||||
border-radius:10px;
|
||||
overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
}
|
||||
#map { width:100%; height:100%; }
|
||||
|
||||
/* 마커 커스텀 */
|
||||
.mk-pin {
|
||||
width:32px; height:32px; border-radius:50% 50% 50% 0;
|
||||
transform:rotate(-45deg); border:3px solid white;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||
}
|
||||
.mk-pin.pending { background:#EF4444; }
|
||||
.mk-pin.in_progress{ background:#F59E0B; }
|
||||
|
||||
.leaflet-popup-content { min-width:200px; font-size:13px; }
|
||||
.popup-title { font-size:14px; font-weight:700; color:var(--navy); margin-bottom:6px; }
|
||||
.popup-meta { font-size:12px; color:var(--gray4); margin-bottom:8px; line-height:1.6; }
|
||||
.popup-tags { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:10px; }
|
||||
.popup-tag { font-size:11px; padding:2px 7px; background:var(--gray1); border-radius:8px; border:1px solid var(--gray2); }
|
||||
.popup-count { font-size:12px; font-weight:700; color:#DC2626; margin-bottom:10px; }
|
||||
|
||||
.no-gps-notice {
|
||||
font-size:12px; color:var(--gray4); padding:6px 10px;
|
||||
background:var(--gray1); border-radius:6px; margin-bottom:10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html" class="active">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
@@ -18,18 +63,31 @@
|
||||
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 처리 목록</h2>
|
||||
<a href="/pages/mechanic/scan.html" class="btn btn-accent">📷 QR 스캔하여 조치 시작</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
<select id="filterStatus" style="width:auto">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
</select>
|
||||
<button class="btn btn-outline btn-sm" onclick="load()">새로고침</button>
|
||||
|
||||
<!-- 필터 + 뷰 토글 -->
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center;">
|
||||
<select id="filterStatus" style="width:auto" onchange="load()">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
</select>
|
||||
<button class="btn btn-outline btn-sm" onclick="load()">새로고침</button>
|
||||
<div style="margin-left:auto">
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
|
||||
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 뷰 -->
|
||||
<div id="listWrap" class="card" style="padding:0">
|
||||
<div style="padding:14px 16px 0">
|
||||
<div id="noGpsNotice" class="no-gps-notice" style="display:none"></div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
@@ -37,23 +95,68 @@
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">처리 대기 중인 AS가 없습니다.</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none;margin:14px">처리 대기 중인 AS가 없습니다.</div>
|
||||
</div>
|
||||
|
||||
<!-- 지도 뷰 -->
|
||||
<div id="mapWrap">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let allRows = [];
|
||||
let mapObj = null;
|
||||
let markers = [];
|
||||
let curView = 'list';
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
function setView(v) {
|
||||
curView = v;
|
||||
document.getElementById('btnList').classList.toggle('active', v === 'list');
|
||||
document.getElementById('btnMap').classList.toggle('active', v === 'map');
|
||||
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
|
||||
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
|
||||
if (v === 'map') {
|
||||
initMap();
|
||||
renderMap();
|
||||
// 컨테이너가 보인 직후 Leaflet에 크기 재계산 알림
|
||||
setTimeout(() => mapObj && mapObj.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
async function load() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const rows = await API.get('/repairs/pending' + (status ? '?status='+status : ''));
|
||||
allRows = await API.get('/repairs/pending' + (status ? '?status=' + status : ''));
|
||||
renderList();
|
||||
if (curView === 'map') renderMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderList() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
if (!rows.length) { tbody.innerHTML=''; document.getElementById('empty').style.display='block'; return; }
|
||||
document.getElementById('empty').style.display='none';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr onclick="location.href='/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}'">
|
||||
const empty = document.getElementById('empty');
|
||||
if (!allRows.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
document.getElementById('noGpsNotice').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = allRows.map(r => {
|
||||
const href = r.repair_id
|
||||
? `/pages/mechanic/repair.html?repair_id=${r.repair_id}`
|
||||
: `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`;
|
||||
return `
|
||||
<tr onclick="location.href='${href}'">
|
||||
<td>#${r.id}</td>
|
||||
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
|
||||
<td>${r.station_name||'-'}</td>
|
||||
@@ -61,9 +164,120 @@ async function load() {
|
||||
<td>${(r.issue_types||[]).join(', ')}</td>
|
||||
<td>${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td>${Auth.statusBadge(r.status)}</td>
|
||||
<td><a class="btn btn-primary btn-sm" href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}" onclick="event.stopPropagation()">조치</a></td>
|
||||
</tr>`).join('');
|
||||
<td><a class="btn btn-primary btn-sm" href="${href}" onclick="event.stopPropagation()">조치</a></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const noGps = allRows.filter(r => !r.gps_lat || !r.gps_lng).length;
|
||||
const noticeEl = document.getElementById('noGpsNotice');
|
||||
if (noGps) {
|
||||
noticeEl.textContent = `📍 GPS 미등록 충전기 ${noGps}건은 지도에 표시되지 않습니다.`;
|
||||
noticeEl.style.display = 'block';
|
||||
} else {
|
||||
noticeEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 지도 초기화 ──
|
||||
function initMap() {
|
||||
if (mapObj) return;
|
||||
mapObj = L.map('map', { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(mapObj);
|
||||
}
|
||||
|
||||
// ── 지도 마커 렌더 ──
|
||||
function renderMap() {
|
||||
if (!mapObj) return;
|
||||
|
||||
// 기존 마커 제거
|
||||
markers.forEach(m => m.remove());
|
||||
markers = [];
|
||||
|
||||
// 충전기별로 그룹핑
|
||||
const chargerMap = {};
|
||||
allRows.forEach(r => {
|
||||
if (!r.gps_lat || !r.gps_lng) return;
|
||||
const key = r.charger_id;
|
||||
if (!chargerMap[key]) {
|
||||
chargerMap[key] = {
|
||||
charger_id: r.charger_id,
|
||||
charger_name: r.charger_name,
|
||||
station_name: r.station_name,
|
||||
location_detail:r.location_detail,
|
||||
gps_lat: r.gps_lat,
|
||||
gps_lng: r.gps_lng,
|
||||
reports: [],
|
||||
};
|
||||
}
|
||||
chargerMap[key].reports.push(r);
|
||||
});
|
||||
|
||||
const chargers = Object.values(chargerMap);
|
||||
if (!chargers.length) {
|
||||
// GPS 없는 경우 한국 중심으로
|
||||
mapObj.setView([36.5, 127.8], 7);
|
||||
return;
|
||||
}
|
||||
|
||||
chargers.forEach(c => {
|
||||
const hasInProgress = c.reports.some(r => r.status === 'in_progress');
|
||||
const statusClass = hasInProgress ? 'in_progress' : 'pending';
|
||||
const color = hasInProgress ? '#F59E0B' : '#EF4444';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="mk-pin ${statusClass}"></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 32],
|
||||
popupAnchor: [0, -34],
|
||||
});
|
||||
|
||||
// 팝업 내용
|
||||
const allIssues = [...new Set(c.reports.flatMap(r => r.issue_types || []))];
|
||||
const firstReport = c.reports[0];
|
||||
const href = firstReport.repair_id
|
||||
? `/pages/mechanic/repair.html?repair_id=${firstReport.repair_id}`
|
||||
: `/pages/mechanic/repair.html?charger_id=${c.charger_id}&report_id=${firstReport.id}`;
|
||||
|
||||
const popup = `
|
||||
<div class="popup-title">⚡ ${c.charger_id}</div>
|
||||
<div class="popup-meta">
|
||||
📍 ${c.station_name || '-'}${c.location_detail ? '<br>' + c.location_detail : ''}
|
||||
${c.charger_name ? '<br>' + c.charger_name : ''}
|
||||
</div>
|
||||
${c.reports.length > 1
|
||||
? `<div class="popup-count">📋 신고 ${c.reports.length}건</div>`
|
||||
: ''}
|
||||
<div class="popup-tags">
|
||||
${allIssues.map(t => `<span class="popup-tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<a href="${href}" class="btn btn-primary btn-sm" style="font-size:12px;text-decoration:none">🔧 조치 시작</a>
|
||||
${c.reports.length > 1
|
||||
? c.reports.map(r =>
|
||||
`<a href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}"
|
||||
style="font-size:11px;color:var(--blue);text-decoration:none;align-self:center">#${r.id}</a>`
|
||||
).join('')
|
||||
: ''}
|
||||
</div>`;
|
||||
|
||||
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
|
||||
.addTo(mapObj)
|
||||
.bindPopup(popup, { maxWidth: 280 });
|
||||
markers.push(m);
|
||||
});
|
||||
|
||||
// 모든 마커가 보이도록 뷰 조정
|
||||
const bounds = L.latLngBounds(chargers.map(c => [c.gps_lat, c.gps_lng]));
|
||||
mapObj.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
|
||||
// 마커 1개면 줌 고정
|
||||
if (chargers.length === 1) mapObj.setZoom(15);
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
144
frontend/static/pages/mechanic/history.html
Normal file
144
frontend/static/pages/mechanic/history.html
Normal file
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>처리 이력</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.history-card {
|
||||
border: 1px solid var(--gray2);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.history-card:hover { box-shadow: 0 3px 12px rgba(0,0,0,.1); }
|
||||
.history-card.approved { border-left: 4px solid var(--green); }
|
||||
.history-card.pending { border-left: 4px solid var(--orange); }
|
||||
.hc-top { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; margin-bottom:8px; }
|
||||
.hc-title { font-size:14px; font-weight:700; color:var(--navy); }
|
||||
.hc-meta { font-size:12px; color:var(--gray4); margin-top:3px; }
|
||||
.hc-tags { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; }
|
||||
.hc-tag { font-size:11px; padding:2px 8px; border-radius:10px; background:var(--gray1); color:var(--text2); border:1px solid var(--gray2); }
|
||||
.badge-approved { background:#D1FAE5; color:#065F46; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
|
||||
.badge-pending { background:#FEF3C7; color:#92400E; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html" class="active">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
||||
<a href="/pages/mechanic/history.html" class="active">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">내 처리 이력</h2>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
<select id="fStatus" style="width:auto">
|
||||
<option value="">전체</option>
|
||||
<option value="approved">승인 완료</option>
|
||||
<option value="pending">승인 대기</option>
|
||||
</select>
|
||||
<select id="fResult" style="width:auto">
|
||||
<option value="">전체 처리상태</option>
|
||||
<option value="done">완료</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="waiting">부품대기</option>
|
||||
<option value="revisit">재방문</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="alert alert-info">이력을 불러오는 중...</div>
|
||||
<div id="error" class="alert alert-danger" style="display:none"></div>
|
||||
<div id="list"></div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">처리 이력이 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
const RESULT_LABEL = {
|
||||
done: '✅ 완료',
|
||||
in_progress: '🔧 진행중',
|
||||
waiting: '⏳ 부품대기',
|
||||
revisit: '🔄 재방문',
|
||||
};
|
||||
|
||||
let allRepairs = [];
|
||||
|
||||
async function load() {
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('error').style.display = 'none';
|
||||
try {
|
||||
allRepairs = await API.get('/repairs/my');
|
||||
render();
|
||||
} catch(e) {
|
||||
document.getElementById('error').textContent = '이력을 불러오지 못했습니다: ' + e.message;
|
||||
document.getElementById('error').style.display = 'block';
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const fStatus = document.getElementById('fStatus').value;
|
||||
const fResult = document.getElementById('fResult').value;
|
||||
|
||||
let list = allRepairs;
|
||||
if (fStatus === 'approved') list = list.filter(r => r.approved_at);
|
||||
if (fStatus === 'pending') list = list.filter(r => !r.approved_at);
|
||||
if (fResult) list = list.filter(r => r.result_status === fResult);
|
||||
|
||||
document.getElementById('empty').style.display = list.length ? 'none' : 'block';
|
||||
document.getElementById('list').innerHTML = list.map(r => {
|
||||
const isApproved = !!r.approved_at;
|
||||
const dt = r.completed_at
|
||||
? new Date(r.completed_at).toLocaleDateString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit'})
|
||||
: '';
|
||||
return `
|
||||
<div class="history-card ${isApproved ? 'approved' : 'pending'}"
|
||||
onclick="location.href='/pages/mechanic/repair.html?repair_id=${r.id}'">
|
||||
<div class="hc-top">
|
||||
<div>
|
||||
<div class="hc-title">
|
||||
${r.station_name || '-'} · ${r.charger_id || '-'}
|
||||
</div>
|
||||
<div class="hc-meta">${r.charger_name || ''} · 신고 ${r.report_count}건 · ${dt}</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;">
|
||||
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
|
||||
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
|
||||
</span>
|
||||
<span style="font-size:11px;color:var(--gray4)">${RESULT_LABEL[r.result_status] || r.result_status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hc-tags">
|
||||
${(r.repair_types||[]).map(t => `<span class="hc-tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('fStatus').onchange = render;
|
||||
document.getElementById('fResult').onchange = render;
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,7 +17,20 @@
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="main" style="max-width:640px;margin:0 auto;">
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
||||
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="max-width:640px;margin:0 auto;">
|
||||
<div style="margin-bottom:14px;">
|
||||
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
|
||||
</div>
|
||||
@@ -34,10 +47,7 @@
|
||||
<div class="form-group">
|
||||
<label>조치 유형 <span class="req">*</span></label>
|
||||
<div class="check-group" id="repairTypes">
|
||||
<label class="check-item"><input type="checkbox" value="부품교체"> 부품 교체</label>
|
||||
<label class="check-item"><input type="checkbox" value="재시작"> 재시작</label>
|
||||
<label class="check-item"><input type="checkbox" value="설정변경"> 설정 변경</label>
|
||||
<label class="check-item"><input type="checkbox" value="기타"> 기타</label>
|
||||
<div style="color:var(--gray4);font-size:12px">불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +56,13 @@
|
||||
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 사진 안내 -->
|
||||
<div style="background:#FFF8E6;border:1px solid #FFD600;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;line-height:1.7;">
|
||||
📌 <strong>촬영 필수 항목</strong><br>
|
||||
· 충전기 <strong>명판(제조사·모델명)</strong> 및 <strong>충전기 식별 ID</strong>가 선명하게 보이도록 촬영해 주세요.<br>
|
||||
· 조치 전·후 상태를 각각 촬영하면 검증에 도움이 됩니다.
|
||||
</div>
|
||||
|
||||
<!-- 조치 전 사진 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@@ -64,23 +81,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>처리 상태 <span class="req">*</span></label>
|
||||
<select id="resultStatus">
|
||||
<option value="done">✅ 처리 완료</option>
|
||||
<option value="waiting">⏳ 부품 대기</option>
|
||||
<option value="revisit">🔄 재방문 필요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" style="margin-bottom:14px;">
|
||||
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록)
|
||||
</div>
|
||||
|
||||
<div id="gpsStatus" class="alert alert-info" style="margin-bottom:14px;">
|
||||
📍 위치 정보 수집 중...
|
||||
</div>
|
||||
<input type="hidden" id="mechanicLat">
|
||||
<input type="hidden" id="mechanicLng">
|
||||
|
||||
<div id="formErr" class="alert alert-danger" style="display:none"></div>
|
||||
<button class="btn btn-primary btn-lg" id="submitBtn">조치 완료 저장</button>
|
||||
|
||||
<!-- 저장 버튼 영역 -->
|
||||
<div style="background:var(--gray1);border:1px solid var(--gray2);border-radius:10px;padding:16px;margin-top:4px;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:10px;">💾 저장 방식 선택</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<button class="btn btn-outline btn-lg" id="saveBtn" style="flex:1;min-width:140px;" onclick="submitForm(false)">
|
||||
💾 상태 저장
|
||||
</button>
|
||||
<button class="btn btn-primary btn-lg" id="doneBtn" style="flex:1;min-width:140px;" onclick="submitForm(true)">
|
||||
✅ 조치 완료 저장
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;">
|
||||
<div style="flex:1;min-width:140px;">
|
||||
<label style="font-size:11px;color:var(--gray4)">저장 상태 선택</label>
|
||||
<select id="resultStatus" style="width:100%;margin-top:4px;font-size:13px;">
|
||||
<option value="in_progress">🔧 계속 진행 중</option>
|
||||
<option value="waiting">⏳ 부품 대기</option>
|
||||
<option value="revisit">🔄 재방문 필요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;min-width:140px;display:flex;align-items:flex-end;">
|
||||
<div style="font-size:11px;color:var(--gray4);padding-bottom:6px;line-height:1.6;">
|
||||
✅ <strong>조치 완료 저장</strong>은 처리 완료로 확정됩니다.<br>
|
||||
💾 <strong>상태 저장</strong>은 왼쪽 상태로 임시 저장됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- max-width wrapper -->
|
||||
</div><!-- .main -->
|
||||
</div><!-- .layout -->
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
@@ -89,16 +133,20 @@
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const chargerId = params.get('charger_id');
|
||||
const params = new URLSearchParams(location.search);
|
||||
const repairId = params.get('repair_id'); // 편집 모드
|
||||
const chargerId = params.get('charger_id'); // 신규 모드
|
||||
const initReportId = params.get('report_id');
|
||||
const startTime = new Date();
|
||||
const isEditMode = !!repairId;
|
||||
|
||||
const startTime = new Date();
|
||||
document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR');
|
||||
|
||||
const selectedReports = new Set();
|
||||
if (initReportId) selectedReports.add(parseInt(initReportId));
|
||||
|
||||
async function load() {
|
||||
// ── 신규 모드 ──
|
||||
async function loadCreate() {
|
||||
const charger = await API.get('/chargers/' + chargerId);
|
||||
document.getElementById('chargerCard').innerHTML = `
|
||||
<div class="card-title">⚡ 충전기 정보</div>
|
||||
@@ -106,79 +154,219 @@ async function load() {
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${charger.id}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${charger.name}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${charger.station_name}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name || '-'}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name||'-'}</strong></div>
|
||||
</div>`;
|
||||
|
||||
const reports = await API.get('/repairs/charger/' + chargerId + '/open');
|
||||
const list = document.getElementById('reportList');
|
||||
if (!reports.length) {
|
||||
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
const list = document.getElementById('reportList');
|
||||
if (!reports.length) { list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>'; return; }
|
||||
list.innerHTML = reports.map(r => `
|
||||
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:${selectedReports.has(r.id)?'#E3EDFF':'white'}">
|
||||
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''}
|
||||
value="${r.id}"
|
||||
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}"
|
||||
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
|
||||
onchange="toggleReport(${r.id}, this.checked, this.closest('label'))">
|
||||
onchange="toggleReport(${r.id},this.checked,this.closest('label'))">
|
||||
<div>
|
||||
<div><strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}</div>
|
||||
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
|
||||
<div style="font-size:11px;color:var(--gray4)">${Auth.fmtDt(r.reported_at)}</div>
|
||||
${r.photos.length
|
||||
? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>`
|
||||
: ''}
|
||||
${r.photos.length ? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>` : ''}
|
||||
</div>
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
function toggleReport(id, checked, label) {
|
||||
if (checked) { selectedReports.add(id); label.style.background = '#E3EDFF'; }
|
||||
else { selectedReports.delete(id); label.style.background = 'white'; }
|
||||
if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; }
|
||||
else { selectedReports.delete(id); label.style.background='white'; }
|
||||
}
|
||||
|
||||
// ── 편집 모드 ──
|
||||
async function loadEdit() {
|
||||
let repair;
|
||||
try { repair = await API.get('/repairs/' + repairId); }
|
||||
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
|
||||
|
||||
// 헤더 업데이트
|
||||
document.querySelector('h2, .main h2') && (document.querySelector('.main > div > h2') || document.querySelector('h2'))?.remove?.();
|
||||
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
|
||||
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
|
||||
|
||||
// 충전기 카드
|
||||
document.getElementById('chargerCard').innerHTML = `
|
||||
<div class="card-title">⚡ 충전기 정보</div>
|
||||
<div class="form-row">
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${repair.charger_id||'-'}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${repair.charger_name||'-'}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${repair.station_name||'-'}</strong></div>
|
||||
</div>`;
|
||||
|
||||
// 연결된 신고 (읽기 전용)
|
||||
document.getElementById('reportList').innerHTML = (repair.reports||[]).length
|
||||
? (repair.reports||[]).map(r => `
|
||||
<div style="padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;background:#F8FAFF;">
|
||||
<strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}
|
||||
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
|
||||
</div>`).join('')
|
||||
: '<div class="alert alert-info">연결된 신고 없음</div>';
|
||||
|
||||
// 승인 완료 → 잠금
|
||||
if (repair.approved_at) {
|
||||
const dt = new Date(repair.approved_at).toLocaleString('ko-KR');
|
||||
document.querySelector('.card:last-child').innerHTML = `
|
||||
<div class="alert alert-success" style="margin-bottom:0">
|
||||
✅ <strong>관리자 승인 완료</strong> (${repair.approved_by_name||''} · ${dt})<br>
|
||||
<span style="font-size:12px;">승인된 조치는 수정할 수 없습니다.</span>
|
||||
</div>
|
||||
${renderRepairView(repair)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 폼 미리채우기 — 조치유형 동적 로드 후 체크 복원
|
||||
await loadRepairTypes(repair.repair_types || []);
|
||||
document.getElementById('description').value = repair.description || '';
|
||||
const sel = document.getElementById('resultStatus');
|
||||
if (repair.result_status && sel.querySelector(`option[value="${repair.result_status}"]`))
|
||||
sel.value = repair.result_status;
|
||||
|
||||
// 기존 사진 표시
|
||||
renderExistingPhotos(repair);
|
||||
}
|
||||
|
||||
function renderRepairView(r) {
|
||||
const LABEL = {done:'✅ 완료',in_progress:'🔧 진행중',waiting:'⏳ 부품대기',revisit:'🔄 재방문'};
|
||||
const photoHtml = (type, list) => (list||[]).length
|
||||
? `<div style="margin-top:8px"><label style="font-size:11px;font-weight:700;color:var(--navy2)">${type}</label>
|
||||
<div class="photo-preview">${(list||[]).map(p=>`<img src="${p.path||p}" onclick="window.open('${p.path||p}')" style="cursor:zoom-in">`).join('')}</div></div>`
|
||||
: '';
|
||||
return `<div style="padding:14px 0">
|
||||
<table style="font-size:13px;width:100%">
|
||||
<tr><td style="color:var(--gray4);width:90px">조치유형</td><td>${(r.repair_types||[]).join(', ')}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">조치내용</td><td>${r.description||'-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">처리결과</td><td>${LABEL[r.result_status]||r.result_status}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(r.completed_at)}</td></tr>
|
||||
</table>
|
||||
${photoHtml('조치 전 사진', r.photos_before)}
|
||||
${photoHtml('조치 후 사진', r.photos_after)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderExistingPhotos(repair) {
|
||||
const mkGrid = (list, type) => {
|
||||
if (!list || !list.length) return '';
|
||||
return `<div style="display:flex;flex-wrap:wrap;gap:7px;margin-bottom:8px;">
|
||||
${list.map(p => `
|
||||
<div style="position:relative;">
|
||||
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
|
||||
<button onclick="deleteRepairPhoto(${repair.id},${p.id},'${type}')"
|
||||
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
};
|
||||
const bWrap = document.getElementById('previewBefore');
|
||||
const aWrap = document.getElementById('previewAfter');
|
||||
if (repair.photos_before?.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
|
||||
if (repair.photos_after?.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
|
||||
}
|
||||
|
||||
async function deleteRepairPhoto(rId, pId) {
|
||||
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await API.delete(`/repairs/${rId}/photos/${pId}`);
|
||||
location.reload();
|
||||
} catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
// GPS 수집
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
pos => {
|
||||
document.getElementById('mechanicLat').value = pos.coords.latitude;
|
||||
document.getElementById('mechanicLng').value = pos.coords.longitude;
|
||||
document.getElementById('gpsStatus').className = 'alert alert-success';
|
||||
document.getElementById('gpsStatus').innerHTML =
|
||||
`📍 위치 수집 완료 <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
|
||||
},
|
||||
() => {
|
||||
document.getElementById('gpsStatus').className = 'alert alert-warn';
|
||||
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다.';
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
|
||||
// 이미지 압축 + 다중 선택 프리뷰
|
||||
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
|
||||
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
|
||||
|
||||
document.getElementById('submitBtn').addEventListener('click', async () => {
|
||||
const rids = [...selectedReports];
|
||||
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
|
||||
|
||||
async function submitForm(isDone) {
|
||||
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
|
||||
if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; }
|
||||
|
||||
const desc = document.getElementById('description').value.trim();
|
||||
if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; }
|
||||
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
document.getElementById('submitBtn').textContent = '저장 중...';
|
||||
if (!isEditMode) {
|
||||
const rids = [...selectedReports];
|
||||
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
const doneBtn = document.getElementById('doneBtn');
|
||||
saveBtn.disabled = doneBtn.disabled = true;
|
||||
(isDone ? doneBtn : saveBtn).textContent = '저장 중...';
|
||||
|
||||
const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value;
|
||||
const lat = document.getElementById('mechanicLat').value;
|
||||
const lng = document.getElementById('mechanicLng').value;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('report_ids', JSON.stringify(rids));
|
||||
fd.append('repair_types', JSON.stringify(types));
|
||||
fd.append('description', desc);
|
||||
fd.append('result_status', document.getElementById('resultStatus').value);
|
||||
fd.append('result_status', resultStatus);
|
||||
if (lat) fd.append('mechanic_lat', lat);
|
||||
if (lng) fd.append('mechanic_lng', lng);
|
||||
Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f));
|
||||
Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f));
|
||||
|
||||
try {
|
||||
await API.post('/repairs', fd);
|
||||
alert('✅ 조치 완료 저장되었습니다.');
|
||||
location.href = '/pages/mechanic/dashboard.html';
|
||||
if (isEditMode) {
|
||||
await API.put('/repairs/' + repairId, fd);
|
||||
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
|
||||
location.href = '/pages/mechanic/history.html';
|
||||
} else {
|
||||
fd.append('report_ids', JSON.stringify([...selectedReports]));
|
||||
await API.post('/repairs', fd);
|
||||
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
|
||||
location.href = '/pages/mechanic/history.html';
|
||||
}
|
||||
} catch(e) {
|
||||
showErr(e.message);
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
document.getElementById('submitBtn').textContent = '조치 완료 저장';
|
||||
saveBtn.disabled = doneBtn.disabled = false;
|
||||
saveBtn.textContent = '💾 상태 저장';
|
||||
doneBtn.textContent = '✅ 조치 완료 저장';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showErr(msg) {
|
||||
const el = document.getElementById('formErr');
|
||||
el.textContent = msg; el.style.display = 'block';
|
||||
}
|
||||
|
||||
load();
|
||||
async function loadRepairTypes(preChecked = []) {
|
||||
try {
|
||||
const types = await API.get('/settings/repair-types');
|
||||
document.getElementById('repairTypes').innerHTML = types.map(t => `
|
||||
<label class="check-item">
|
||||
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
|
||||
${t.label}
|
||||
</label>`).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('repairTypes').innerHTML =
|
||||
'<div class="alert alert-danger" style="margin:0">조치유형을 불러오지 못했습니다.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
loadEdit();
|
||||
} else {
|
||||
loadRepairTypes();
|
||||
loadCreate();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,24 +6,39 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
#reader{width:100%;max-width:400px;margin:0 auto;border-radius:10px;overflow:hidden;}
|
||||
.scan-wrap{max-width:480px;margin:0 auto;padding:20px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">⚡ QR 스캔</span>
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="scan-wrap">
|
||||
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
|
||||
<div id="reader"></div>
|
||||
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
|
||||
<div style="margin-top:16px;">
|
||||
<div class="form-group">
|
||||
<label>충전기 ID 직접 입력</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="manualId" placeholder="예: CG-003">
|
||||
<button class="btn btn-primary" onclick="goManual()">이동</button>
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html" class="active">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>
|
||||
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="max-width:480px;margin:0 auto;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:16px">📷 QR 스캔</h2>
|
||||
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
|
||||
<div id="reader"></div>
|
||||
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
|
||||
<div style="margin-top:16px;">
|
||||
<div class="form-group">
|
||||
<label>충전기 ID 직접 입력</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="manualId" placeholder="예: CG-003">
|
||||
<button class="btn btn-primary" onclick="goManual()">이동</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -221,7 +221,8 @@ body { background: var(--gray1); }
|
||||
<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)">
|
||||
<!-- populated as dropdown or text input depending on chargerErrors -->
|
||||
<div id="errorCodeInner"></div>
|
||||
</div>
|
||||
<div id="etcWrap" style="margin-top:10px;display:none;">
|
||||
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
|
||||
@@ -260,6 +261,24 @@ body { background: var(--gray1); }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>📡 신고 범위</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
|
||||
<input type="radio" name="scope" value="single" checked style="width:auto;accent-color:var(--accent)">
|
||||
<div><strong>이 충전기만</strong><div style="font-size:11px;color:var(--gray4)">현재 스캔한 충전기에만 신고</div></div>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
|
||||
<input type="radio" name="scope" value="station" style="width:auto;accent-color:var(--accent)">
|
||||
<div><strong>충전소 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전소의 모든 충전기에 신고</div></div>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
|
||||
<input type="radio" name="scope" value="type" style="width:auto;accent-color:var(--accent)">
|
||||
<div><strong>동일 모델 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전기 모델 전체에 신고</div></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>📝 상세 설명 (선택)</h3>
|
||||
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
|
||||
@@ -294,7 +313,7 @@ body { background: var(--gray1); }
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/imageCompress.js"></script>
|
||||
<script>
|
||||
const ISSUES = [
|
||||
let ISSUES = [
|
||||
{key:'충전불가', label:'⚡ 충전 불가'},
|
||||
{key:'화면오류', label:'🖥 화면 오류'},
|
||||
{key:'케이블불량',label:'🔌 케이블 불량'},
|
||||
@@ -303,6 +322,7 @@ const ISSUES = [
|
||||
{key:'에러발생', label:'⚠️ 에러 발생'},
|
||||
{key:'기타', label:'📋 기타'},
|
||||
];
|
||||
let chargerErrors = [];
|
||||
|
||||
const STATUS_ICON = {
|
||||
pending_approval: '🕐',
|
||||
@@ -327,7 +347,11 @@ let isStatusOpen = true;
|
||||
// ── 충전기 정보 로드 ──
|
||||
async function loadCharger() {
|
||||
try {
|
||||
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
|
||||
const [c, errs] = await Promise.all([
|
||||
fetch('/api/chargers/' + chargerId).then(r => r.json()),
|
||||
fetch('/api/chargers/' + chargerId + '/errors').then(r => r.json()).catch(() => []),
|
||||
]);
|
||||
chargerErrors = errs;
|
||||
document.getElementById('chargerInfo').innerHTML = `
|
||||
<h2>⚡ ${c.name}</h2>
|
||||
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
|
||||
@@ -435,23 +459,67 @@ navigator.geolocation?.getCurrentPosition(
|
||||
}
|
||||
);
|
||||
|
||||
// ── 에러코드 UI 갱신 ──
|
||||
function updateErrorCodeUI() {
|
||||
const wrap = document.getElementById('errorCodeWrap');
|
||||
const inner = document.getElementById('errorCodeInner');
|
||||
if (!selected.has('에러발생')) { wrap.style.display = 'none'; return; }
|
||||
wrap.style.display = 'block';
|
||||
if (chargerErrors.length > 0) {
|
||||
inner.innerHTML = `
|
||||
<select id="errorCode" style="width:100%">
|
||||
<option value="">-- 에러코드 선택 --</option>
|
||||
${chargerErrors.map(e =>
|
||||
`<option value="${e.error_code}">${e.error_code} — ${e.error_name}${e.range_condition ? ' ('+e.range_condition+')' : ''}</option>`
|
||||
).join('')}
|
||||
<option value="__other__">기타 (직접 입력)</option>
|
||||
</select>
|
||||
<input type="text" id="errorCodeCustom" placeholder="에러코드 직접 입력" style="margin-top:6px;display:none">`;
|
||||
document.getElementById('errorCode').onchange = function() {
|
||||
document.getElementById('errorCodeCustom').style.display =
|
||||
this.value === '__other__' ? 'block' : 'none';
|
||||
};
|
||||
} else {
|
||||
inner.innerHTML = `<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 에러코드 값 가져오기 ──
|
||||
function getErrorCodeValue() {
|
||||
const sel = document.getElementById('errorCode');
|
||||
if (!sel) return '';
|
||||
if (sel.tagName === 'SELECT') {
|
||||
if (sel.value === '__other__') return document.getElementById('errorCodeCustom')?.value || '';
|
||||
return sel.value;
|
||||
}
|
||||
return sel.value;
|
||||
}
|
||||
|
||||
// ── 문제 유형 버튼 ──
|
||||
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);
|
||||
});
|
||||
function renderIssueButtons(issues) {
|
||||
const grid = document.getElementById('issueGrid');
|
||||
grid.innerHTML = '';
|
||||
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'); }
|
||||
updateErrorCodeUI();
|
||||
document.getElementById('etcWrap').style.display =
|
||||
selected.has('기타') ? 'block' : 'none';
|
||||
};
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/api/settings/issue-types')
|
||||
.then(r => r.json())
|
||||
.then(data => { if (Array.isArray(data) && data.length) { ISSUES = data; } })
|
||||
.catch(() => {})
|
||||
.finally(() => renderIssueButtons(ISSUES));
|
||||
|
||||
// ── 이미지 압축 + 다중 선택 ──
|
||||
ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2');
|
||||
@@ -474,11 +542,14 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
document.getElementById('submitBtn').textContent = '접수 중...';
|
||||
|
||||
const scope = document.querySelector('input[name="scope"]:checked')?.value || 'single';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('charger_id', chargerId);
|
||||
fd.append('scope', scope);
|
||||
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('error_code', getErrorCodeValue());
|
||||
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
|
||||
fd.append('contact', contact);
|
||||
fd.append('consent', consent);
|
||||
@@ -488,12 +559,13 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
|
||||
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/reports', { method: 'POST', body: fd });
|
||||
const res = await fetch('/api/reports/batch', { 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}`;
|
||||
const label = data.count > 1 ? `접수번호: #${data.primary_id} 외 ${data.count-1}건` : `접수번호: #${data.primary_id}`;
|
||||
document.getElementById('resultMsg').textContent = label;
|
||||
// 현황 새로고침
|
||||
document.getElementById('statusSection').style.display = 'none';
|
||||
document.getElementById('noReportNotice').style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user