기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, 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:
byun
2026-05-31 06:52:56 +09:00
parent 05b478372a
commit 2e8751ea6c
35 changed files with 5541 additions and 353 deletions

View File

@@ -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';