기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, 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

@@ -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:0018:00)</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">주말·공휴일 제외 후, 평일 업무시간(09:0018: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>