1차완료

This commit is contained in:
byun
2026-06-02 19:34:36 +09:00
parent 9f0f4326fe
commit b6863cd260
28 changed files with 1667 additions and 460 deletions

View File

@@ -6,9 +6,10 @@
#btnDelete { display:none; }
</style></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -20,6 +21,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -15,11 +15,12 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -31,6 +32,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -37,9 +37,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -51,6 +52,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -12,9 +12,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -26,6 +27,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
@@ -49,7 +51,7 @@
<select id="fParty" style="width:auto">
<option value="">전체 부담주체</option>
<option value="cpo">CPO</option>
<option value="manufacturer">제조사</option>
<option value="manufacturer">업체</option>
<option value="self">자체</option>
<option value="user">사용자과실</option>
<option value="other">기타</option>
@@ -60,7 +62,7 @@
<table>
<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>
<th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>수급주체</th><th>금액</th><th>상태</th><th>처리일시</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
@@ -94,7 +96,7 @@ async function bulkDelete() {
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
const PARTY_LABEL = {cpo:'CPO',manufacturer:'업체',self:'자체',user:'사용자과실',other:'기타'};
async function load() {
const [statsData, costs] = await Promise.all([API.get('/costs/stats'), API.get('/costs?cost_status='+document.getElementById('fStatus').value+'&cost_party_type='+document.getElementById('fParty').value)]);
@@ -115,7 +117,8 @@ async function load() {
<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">${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'&&c.cost_manufacturer_name?`<br><small>${c.cost_manufacturer_name}</small>`:''}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${c.recv_party_type?(PARTY_LABEL[c.recv_party_type]||c.recv_party_type):'-'}${c.recv_party_type==='manufacturer'&&c.recv_manufacturer_name?`<br><small>${c.recv_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>

View File

@@ -70,12 +70,21 @@
.charger-option.selected { background: #EFF6FF; }
.charger-option .opt-name { font-weight: 600; color: var(--navy); }
.charger-option .opt-sub { font-size: 11px; color: var(--gray4); margin-top: 2px; }
.charger-selected-badge {
display: none; margin-top: 6px; padding: 7px 10px;
background: #EFF6FF; border: 1px solid #BFDBFE;
border-radius: 6px; font-size: 12px; color: var(--navy2);
.selected-chargers-list {
display: none; flex-wrap: wrap; gap: 5px;
margin-top: 8px;
}
.charger-selected-badge.show { display: flex; justify-content: space-between; align-items: center; }
.selected-chargers-list.show { display: flex; }
.sel-tag {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; background: #EFF6FF; border: 1px solid #BFDBFE;
border-radius: 20px; font-size: 12px; color: var(--navy2);
}
.sel-tag button {
background: none; border: none; cursor: pointer; color: var(--gray4);
font-size: 13px; line-height: 1; padding: 0 1px;
}
.sel-tag button:hover { color: var(--red); }
/* ── 증상 체크박스 ── */
.issue-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 4px; }
@@ -139,15 +148,31 @@
}
.detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.detail-tab:hover:not(.active) { color: var(--navy2); }
/* ── 대시보드 레이아웃 클래스 ── */
.dash-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:10px; }
.time-metrics-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; }
.dash-chart-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.dash-chart-wrap { position:relative; height:220px; }
.dash-bottom-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:768px) {
.time-metrics-grid { grid-template-columns:repeat(2,1fr); }
.dash-chart-wrap { height:170px; }
.dash-bottom-grid { grid-template-columns:1fr; }
#adminMapWrap { height:260px !important; }
.btn-report-new { font-size:12px; padding:7px 12px; }
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -159,10 +184,11 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<div class="dash-header">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin:0">대시보드</h2>
<button class="btn-report-new" onclick="openReportModal()">+ 신고 접수</button>
</div>
@@ -171,7 +197,7 @@
<!-- 처리 시간 지표 카드 -->
<div class="card" id="timeMetrics" style="margin-bottom:20px">
<div class="card-title">⏱ 처리 시간 지표</div>
<div id="timeMetricsBody" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:14px"></div>
<div id="timeMetricsBody" class="time-metrics-grid"></div>
</div>
<!-- 드릴다운 뒤로가기 -->
@@ -183,7 +209,7 @@
<!-- 월별 처리시간 차트 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0" id="monthlyChartTitle">📈 월별 평균 처리시간</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span id="monthlyChartMode" style="font-size:11px;color:var(--gray4)"></span>
@@ -194,28 +220,28 @@
</div>
</div>
</div>
<div style="position:relative;height:220px">
<div class="dash-chart-wrap">
<canvas id="monthlyChart"></canvas>
</div>
</div>
<!-- 월별 신고 접수 건수 차트 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0" id="monthlyReportChartTitle">📊 월별 신고 접수 건수</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#CBD5E1;margin-right:3px"></span>미처리</span>
</div>
</div>
<div style="position:relative;height:220px">
<div class="dash-chart-wrap">
<canvas id="monthlyReportChart"></canvas>
</div>
</div>
<!-- 충전기별 누적 고장 Top 10 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0">🏆 충전기별 누적 고장 건수 Top 10</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
@@ -227,18 +253,32 @@
</div>
</div>
<!-- 충전소별 누적 고장 Top 10 -->
<div class="card" style="margin-bottom:20px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0">🏢 충전소별 누적 고장 건수 Top 10</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#FCA5A5;margin-right:3px"></span>미처리</span>
</div>
</div>
<div style="position:relative" id="topStationsWrap">
<canvas id="topStationsChart"></canvas>
</div>
</div>
<!-- 충전기별 에러코드 누적 순위 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0">⚠️ 에러코드 누적 순위 Top 10</div>
<span style="font-size:11px;color:var(--gray4)">에러코드 입력된 신고 기준</span>
<span style="font-size:11px;color:var(--gray4)">전체 신고 기준 (에러코드 없음 포함)</span>
</div>
<div id="errorCodesChartWrap" style="position:relative">
<canvas id="errorCodesChart"></canvas>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="dash-bottom-grid">
<div class="card">
<div class="card-title">🔴 접수 대기 현황 <span id="pendingSort" style="font-size:11px;font-weight:400;color:var(--gray4)">(오래된 순)</span></div>
<div id="recentReports"></div>
@@ -284,10 +324,7 @@
onfocus="openDropdown()" autocomplete="off">
<div class="charger-dropdown" id="chargerDropdown"></div>
</div>
<div class="charger-selected-badge" id="selectedBadge">
<span id="selectedBadgeText"></span>
<button onclick="clearCharger()" style="background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px"></button>
</div>
<div class="selected-chargers-list" id="selectedChargersList"></div>
</div>
<!-- 발생 일시 -->
@@ -311,7 +348,7 @@
</div>
<!-- 신고 범위 -->
<div class="form-row">
<div class="form-row" id="scopeRow">
<label class="form-label">신고 범위</label>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
@@ -320,11 +357,11 @@
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="scope" value="station" style="width:auto;accent-color:var(--accent)">
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">같은 충전소 모든 충전기</span></span>
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">신고 1건으로 충전소 모든 충전기 대상</span></span>
</label>
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
<input type="radio" name="scope" value="type" style="width:auto;accent-color:var(--accent)">
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">— 같은 충전기 모델 전체</span></span>
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">신고 1건으로 같은 모델 전체 대상</span></span>
</label>
</div>
</div>
@@ -396,7 +433,7 @@ Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
let allChargers = [];
let selectedChargerId = null;
let selectedChargers = [];
let cachedIssueTypes = null;
let selectedChargerErrors = [];
@@ -425,6 +462,7 @@ async function load() {
]);
loadMonthlyChart();
loadTopChargersChart();
loadTopStationsChart();
loadErrorCodesChart();
/* ── 통계 카드 ── */
@@ -433,7 +471,7 @@ async function load() {
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html'">
<div class="stat-num">${stats.total}</div><div class="stat-label">전체 신고</div>
</div>
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'">
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'">
<div class="stat-num">${stats.pending}</div><div class="stat-label">접수 대기</div>
</div>
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=in_progress'">
@@ -448,11 +486,11 @@ async function load() {
<div class="stat warn stat-link" onclick="location.href='/pages/admin/improvements.html'">
<div class="stat-num">${stats.improvement_open}</div><div class="stat-label">개선항목 진행중</div>
</div>
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'" style="border-top:3px solid var(--accent)">
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'" style="border-top:3px solid var(--accent)">
<div class="stat-num" style="font-size:22px">${fmtHours(stats.avg_resolution_hours_30d)}</div>
<div class="stat-label">평균 처리 시간<br><small>(최근 30일)</small></div>
</div>
<div class="stat ${over72Class} stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'" style="border-top:3px solid ${stats.pending_over_72h>0?'var(--red)':'var(--green)'}">
<div class="stat ${over72Class} stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'" style="border-top:3px solid ${stats.pending_over_72h>0?'var(--red)':'var(--green)'}">
<div class="stat-num">${stats.pending_over_72h}</div>
<div class="stat-label">72h+ 장기 대기</div>
</div>
@@ -636,13 +674,103 @@ async function loadTopChargersChart() {
});
}
/* ── Top 10 충전소별 누적 고장 차트 ── */
let _topStationsChart = null;
async function loadTopStationsChart() {
const data = await API.get('/stats/top-stations');
if (!data.length) return;
const rev = [...data].reverse();
const labels = rev.map(d => {
const s = d.station_name || '-';
return s.length > 22 ? s.slice(0, 20) + '…' : s;
});
const rowH = 32;
const height = rev.length * rowH + 40;
document.getElementById('topStationsWrap').style.height = height + 'px';
if (_topStationsChart) _topStationsChart.destroy();
const ctx = document.getElementById('topStationsChart').getContext('2d');
_topStationsChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{
label: '처리 완료',
data: rev.map(d => d.done),
backgroundColor: '#3B82F6',
borderRadius: 3,
stack: 'top',
},
{
label: '미처리',
data: rev.map(d => d.active),
backgroundColor: '#FCA5A5',
borderRadius: 3,
stack: 'top',
},
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
onClick: (evt, elems) => {
if (!elems.length) return;
const d = rev[elems[0].index];
location.href = `/pages/admin/reports.html?station_name=${encodeURIComponent(d.station_name)}`;
},
onHover: (evt, elems) => {
evt.native.target.style.cursor = elems.length ? 'pointer' : 'default';
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => rev[items[0].dataIndex].station_name,
label: c => {
const d = rev[c.dataIndex];
return [
`총 누적: ${d.total}`,
`처리 완료: ${d.done}`,
`미처리: ${d.active}`,
];
},
afterLabel: () => '(클릭하면 신고 목록으로)',
}
}
},
scales: {
x: {
stacked: true,
grid: { color: '#F1F5F9' },
border: { dash: [3, 3] },
beginAtZero: true,
ticks: {
font: { size: 11 }, color: '#64748B', stepSize: 1,
callback: v => Number.isInteger(v) ? v + '건' : '',
}
},
y: {
stacked: true,
grid: { display: false },
ticks: { font: { size: 12 }, color: '#334155' }
}
}
}
});
}
/* ── 에러코드별 누적 순위 차트 ── */
let _errorCodesChart = null;
async function loadErrorCodesChart() {
const data = await API.get('/stats/charger-error-codes');
const wrap = document.getElementById('errorCodesChartWrap');
if (!data.error_codes || !data.error_codes.length) {
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">신고 데이터가 없습니다.</div>';
return;
}
const { error_codes } = data;
@@ -655,7 +783,7 @@ async function loadErrorCodesChart() {
labels: error_codes.map(e => e.error_code),
datasets: [{
data: error_codes.map(e => e.total),
backgroundColor: '#1565C0',
backgroundColor: error_codes.map(e => e.error_code === '에러코드 없음' ? '#94A3B8' : '#1565C0'),
borderRadius: 4,
}]
},
@@ -667,7 +795,9 @@ async function loadErrorCodesChart() {
legend: { display: false },
tooltip: {
callbacks: {
title: items => `에러코드: ${error_codes[items[0].dataIndex].error_code}`,
title: items => error_codes[items[0].dataIndex].error_code === '에러코드 없음'
? '에러코드 없음'
: `에러코드: ${error_codes[items[0].dataIndex].error_code}`,
label: c => `누적 ${c.raw}`,
}
}
@@ -983,11 +1113,12 @@ function closeReportModal() {
}
function resetModal() {
selectedChargerId = null;
selectedChargers = [];
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('chargerDropdown').classList.remove('open');
document.getElementById('selectedBadge').classList.remove('show');
renderSelectedChargers();
updateScopeVisibility();
document.getElementById('occurredAt').value = '';
document.getElementById('issueDetail').value = '';
document.getElementById('contact').value = '';
@@ -1016,7 +1147,7 @@ function filterChargers(q) {
: allChargers.slice(0, 50);
dd.innerHTML = filtered.map(c => `
<div class="charger-option ${c.id === selectedChargerId ? 'selected' : ''}"
<div class="charger-option ${selectedChargers.some(s => s.id === c.id) ? 'selected' : ''}"
onclick="selectCharger('${c.id}', '${escHtml(c.station_name)}', '${escHtml(c.name)}', '${escHtml(c.location_detail||'')}')">
<div class="opt-name">${escHtml(c.station_name)} · ${escHtml(c.name)}</div>
<div class="opt-sub">${c.id}${c.location_detail ? ' · ' + escHtml(c.location_detail) : ''}</div>
@@ -1030,18 +1161,59 @@ function openDropdown() {
}
async function selectCharger(id, station, name, region) {
selectedChargerId = id;
if (selectedChargers.some(c => c.id === id)) {
document.getElementById('chargerDropdown').classList.remove('open');
document.getElementById('chargerSearchInput').value = '';
return;
}
selectedChargers.push({ id, station, name, region });
document.getElementById('chargerSearchInput').value = '';
document.getElementById('chargerDropdown').classList.remove('open');
const badge = document.getElementById('selectedBadge');
document.getElementById('selectedBadgeText').textContent =
`${station} · ${name}${region ? ' (' + region + ')' : ''}`;
badge.classList.add('show');
// Load error codes for this charger type
try {
selectedChargerErrors = await API.get('/chargers/' + id + '/errors');
} catch { selectedChargerErrors = []; }
renderErrorCodeUI();
renderSelectedChargers();
updateScopeVisibility();
if (selectedChargers.length === 1) {
try {
selectedChargerErrors = await API.get('/chargers/' + id + '/errors');
} catch { selectedChargerErrors = []; }
renderErrorCodeUI();
} else {
selectedChargerErrors = [];
renderErrorCodeUI();
}
}
function renderSelectedChargers() {
const el = document.getElementById('selectedChargersList');
if (!selectedChargers.length) {
el.innerHTML = '';
el.classList.remove('show');
return;
}
el.classList.add('show');
el.innerHTML = selectedChargers.map(c => `
<div class="sel-tag">
<span>${escHtml(c.station)} · ${escHtml(c.name)}</span>
<button onclick="removeCharger('${escHtml(c.id)}')" title="제거">✕</button>
</div>`).join('');
}
function removeCharger(id) {
selectedChargers = selectedChargers.filter(c => c.id !== id);
renderSelectedChargers();
updateScopeVisibility();
if (selectedChargers.length === 0) {
selectedChargerErrors = [];
renderErrorCodeUI();
} else if (selectedChargers.length === 1) {
API.get('/chargers/' + selectedChargers[0].id + '/errors')
.then(e => { selectedChargerErrors = e; renderErrorCodeUI(); })
.catch(() => { selectedChargerErrors = []; renderErrorCodeUI(); });
}
}
function updateScopeVisibility() {
const row = document.getElementById('scopeRow');
if (row) row.style.display = selectedChargers.length > 1 ? 'none' : '';
}
function renderErrorCodeUI() {
@@ -1075,10 +1247,11 @@ function getModalErrorCode() {
}
function clearCharger() {
selectedChargerId = null;
selectedChargers = [];
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('selectedBadge').classList.remove('show');
renderSelectedChargers();
updateScopeVisibility();
renderErrorCodeUI();
}
@@ -1096,44 +1269,56 @@ function escHtml(s) {
/* ── 신고 제출 ── */
async function submitReport() {
if (!selectedChargerId) { alert('충전기를 선택해주세요.'); return; }
if (!selectedChargers.length) { alert('충전기를 선택해주세요.'); return; }
const issues = [...document.querySelectorAll('.issue-chk:checked')].map(c => c.value);
if (!issues.length) { alert('증상을 하나 이상 선택해주세요.'); return; }
const btn = document.getElementById('submitBtn');
btn.disabled = true; btn.textContent = '접수 중...';
const scope = document.querySelector('input[name="scope"]:checked')?.value || 'single';
const scope = selectedChargers.length === 1
? (document.querySelector('input[name="scope"]:checked')?.value || 'single')
: 'single';
const issueDetail = document.getElementById('issueDetail').value;
const errorCode = getModalErrorCode();
const contact = document.getElementById('contact').value;
const occurredAt = document.getElementById('occurredAt').value;
const ocppLog = document.getElementById('ocppLog').value.trim();
const photos = Array.from(document.getElementById('modalPhoto').files);
const isMulti = selectedChargers.length > 1;
const fd = new FormData();
fd.append('charger_id', selectedChargerId);
fd.append('scope', scope);
fd.append('charger_id', selectedChargers[0].id);
fd.append('scope', isMulti ? 'multi' : scope);
if (isMulti) fd.append('charger_ids', JSON.stringify(selectedChargers.map(c => c.id)));
fd.append('source', 'dashboard');
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('issueDetail').value);
fd.append('error_code', getModalErrorCode());
fd.append('contact', document.getElementById('contact').value);
fd.append('consent', 'false');
const occ = document.getElementById('occurredAt').value;
if (occ) fd.append('occurred_at', occ);
const ocppLogText = document.getElementById('ocppLog').value.trim();
if (ocppLogText) fd.append('ocpp_log', ocppLogText);
Array.from(document.getElementById('modalPhoto').files).forEach(f => fd.append('photos', f));
if (issueDetail) fd.append('issue_detail', issueDetail);
if (errorCode) fd.append('error_code', errorCode);
if (contact) fd.append('contact', contact);
if (occurredAt) fd.append('occurred_at', occurredAt);
if (ocppLog) fd.append('ocpp_log', ocppLog);
photos.forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports/batch', { method: 'POST', body: fd,
headers: { 'Authorization': 'Bearer ' + Auth.token() } });
const res = await fetch('/api/reports/batch', {
method: 'POST', body: fd,
headers: { 'Authorization': 'Bearer ' + Auth.token() },
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
closeReportModal();
load();
if (data.count > 1) {
if (isMulti) {
alert(`${selectedChargers.length}대 충전기 신고가 1건으로 접수되었습니다. (신고 #${data.primary_id})`);
} else if (data.count > 1) {
alert(`신고가 ${data.count}건 접수되었습니다. (첫 번째 신고 #${data.primary_id})`);
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
} else {
alert(`신고가 접수되었습니다. (신고 #${data.primary_id})`);
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
}
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
} catch(e) {
alert('오류: ' + e.message);
} finally {

View File

@@ -0,0 +1,224 @@
<!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>
.export-card { background:white; border-radius:10px; padding:24px; box-shadow:0 2px 8px rgba(0,0,0,.06); margin-bottom:20px; }
.sheet-badge { display:inline-flex; align-items:center; gap:6px; padding:5px 14px; border-radius:20px; font-size:12px; font-weight:700; margin:3px; }
.date-row { display:flex; gap:14px; align-items:flex-end; flex-wrap:wrap; margin-top:18px; }
.date-row .form-group { margin:0; min-width:140px; }
.quick-btns { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
.quick-btn { padding:4px 12px; border:1px solid var(--gray3); border-radius:6px; background:white; font-size:12px; color:var(--navy); cursor:pointer; }
.quick-btn:hover { background:var(--gray1); border-color:var(--accent); }
.download-btn { padding:12px 28px; font-size:15px; font-weight:700; border-radius:8px; border:none;
background:var(--blue); color:white; cursor:pointer; display:flex; align-items:center; gap:8px;
transition:background .15s; min-width:220px; justify-content:center; }
.download-btn:hover { background:#1251A3; }
.download-btn:disabled { background:var(--gray3); cursor:not-allowed; }
.ind-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; margin-top:16px; }
.ind-item { border:1px solid var(--gray2); border-radius:8px; padding:14px 16px; }
.ind-item .ind-title { font-size:13px; font-weight:700; color:var(--navy); margin-bottom:8px; }
.ind-item .ind-desc { font-size:11px; color:var(--gray4); margin-bottom:10px; line-height:1.5; }
.status-msg { padding:10px 14px; border-radius:6px; font-size:13px; margin-top:12px; display:none; }
@media(max-width:768px){
.date-row { flex-direction:column; align-items:stretch; }
.download-btn { width:100%; }
.ind-grid { grid-template-columns:1fr; }
}
</style>
</head>
<body>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar" id="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">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html" class="active">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">📥 데이터 내보내기</h2>
</div>
<!-- 통합 다운로드 -->
<div class="export-card">
<div class="card-title">📊 기간별 통합 다운로드</div>
<p style="font-size:13px;color:var(--gray4);margin-bottom:8px;line-height:1.7;">
설정한 기간의 모든 데이터를 <strong>하나의 엑셀 파일(4개 시트)</strong>로 다운로드합니다.
</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:4px;">
<span class="sheet-badge" style="background:#0B1E3D;color:white;">① AS 신고이력</span>
<span class="sheet-badge" style="background:#1B5E20;color:white;">② 조치이력</span>
<span class="sheet-badge" style="background:#4A148C;color:white;">③ 개선항목</span>
<span class="sheet-badge" style="background:#E65100;color:white;">④ 출장비정산</span>
<span class="sheet-badge" style="background:#37474F;color:white;">+ 요약</span>
</div>
<div class="date-row">
<div class="form-group">
<label>시작일</label>
<input type="date" id="dateFrom">
</div>
<div class="form-group">
<label>종료일</label>
<input type="date" id="dateTo">
</div>
<button class="download-btn" id="fullBtn" onclick="downloadFull()">
📥 통합 엑셀 다운로드
</button>
</div>
<div class="quick-btns">
<span style="font-size:11px;color:var(--gray4);align-self:center;">빠른 선택:</span>
<button class="quick-btn" onclick="setRange(7)">최근 7일</button>
<button class="quick-btn" onclick="setRange(30)">최근 30일</button>
<button class="quick-btn" onclick="setRange(90)">최근 3개월</button>
<button class="quick-btn" onclick="setThisMonth()">이번 달</button>
<button class="quick-btn" onclick="setLastMonth()">지난 달</button>
<button class="quick-btn" onclick="setThisYear()">올해 전체</button>
<button class="quick-btn" onclick="clearRange()">전체 기간</button>
</div>
<div id="statusMsg" class="status-msg"></div>
</div>
<!-- 개별 다운로드 -->
<div class="export-card">
<div class="card-title">📄 항목별 개별 다운로드</div>
<p style="font-size:13px;color:var(--gray4);margin-bottom:4px;">위 기간 설정이 동일하게 적용됩니다.</p>
<div class="ind-grid">
<div class="ind-item">
<div class="ind-title" style="color:#0B1E3D;">📋 AS 신고이력</div>
<div class="ind-desc">신고 접수일 기준 · 충전기/신고자/상태/<br>정비사/조치내용/출장비 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('reports')">📥 다운로드</button>
</div>
<div class="ind-item">
<div class="ind-title" style="color:#1B5E20;">🔧 조치이력</div>
<div class="ind-desc">조치 완료일 기준 · 정비사/조치유형/<br>소요시간/승인 여부 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('repairs')">📥 다운로드</button>
</div>
<div class="ind-item">
<div class="ind-title" style="color:#4A148C;">🔧 개선항목</div>
<div class="ind-desc">등록일 기준 · 분류/우선순위/<br>담당업체/진행상태 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('improvements')">📥 다운로드</button>
</div>
<div class="ind-item">
<div class="ind-title" style="color:#E65100;">💰 출장비 정산</div>
<div class="ind-desc">조치 완료일 기준 · 부담/수급 주체/<br>금액/정산상태 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('costs')">📥 다운로드</button>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
function pad(n) { return String(n).padStart(2,'0'); }
function fmtDate(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
function setRange(days) {
const to = new Date();
const from = new Date(); from.setDate(from.getDate() - days + 1);
document.getElementById('dateFrom').value = fmtDate(from);
document.getElementById('dateTo').value = fmtDate(to);
}
function setThisMonth() {
const now = new Date();
document.getElementById('dateFrom').value = `${now.getFullYear()}-${pad(now.getMonth()+1)}-01`;
document.getElementById('dateTo').value = fmtDate(now);
}
function setLastMonth() {
const now = new Date();
const y = now.getMonth() === 0 ? now.getFullYear()-1 : now.getFullYear();
const m = now.getMonth() === 0 ? 12 : now.getMonth();
const last = new Date(y, m, 0);
document.getElementById('dateFrom').value = `${y}-${pad(m)}-01`;
document.getElementById('dateTo').value = fmtDate(last);
}
function setThisYear() {
const now = new Date();
document.getElementById('dateFrom').value = `${now.getFullYear()}-01-01`;
document.getElementById('dateTo').value = fmtDate(now);
}
function clearRange() {
document.getElementById('dateFrom').value = '';
document.getElementById('dateTo').value = '';
}
function buildQuery() {
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
const p = [];
if (from) p.push('date_from=' + from);
if (to) p.push('date_to=' + to);
return p.length ? '?' + p.join('&') : '';
}
function showStatus(msg, type='info') {
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.style.display = 'block';
el.className = 'status-msg alert alert-' + type;
}
function hideStatus() { document.getElementById('statusMsg').style.display = 'none'; }
async function downloadFull() {
const btn = document.getElementById('fullBtn');
btn.disabled = true;
btn.textContent = '⏳ 생성 중...';
showStatus('엑셀 파일을 생성 중입니다. 데이터량에 따라 수 초가 걸릴 수 있습니다.', 'info');
try {
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
const period = (from || to) ? `${from||'전체'}~${to||'전체'}` : '전체기간';
await API.download('/export/full' + buildQuery(), `EV_AS_통합이력_${period}.xlsx`);
showStatus('✅ 다운로드가 완료되었습니다.', 'success');
} catch(e) {
showStatus('❌ 오류: ' + e.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '📥 통합 엑셀 다운로드';
setTimeout(hideStatus, 5000);
}
}
async function downloadIndividual(type) {
const names = {
reports: 'AS신고이력',
repairs: '조치이력',
improvements: '개선항목',
costs: '출장비정산',
};
try {
await API.download('/export/' + type + buildQuery(), `${names[type]}.xlsx`);
} catch(e) { alert('다운로드 오류: ' + e.message); }
}
// 기본값: 이번 달
setThisMonth();
</script>
</body></html>

View File

@@ -1,8 +1,40 @@
<!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>
.imp-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
.info-dl { display:grid; grid-template-columns:80px 1fr; gap:8px 14px; font-size:13px; align-items:start; }
.info-dl dt { color:var(--gray4); font-weight:600; padding-top:1px; }
.info-dl dd { word-break:break-word; }
.report-link { display:flex; align-items:center; gap:10px; padding:10px 12px;
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
cursor:pointer; font-size:13px; color:var(--navy); text-decoration:none; transition:background .15s; }
.report-link:hover { background:var(--gray1); }
.report-link-num { font-weight:700; color:var(--blue); flex-shrink:0; }
.file-link { display:flex; align-items:center; gap:8px; padding:9px 12px;
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
color:var(--navy); text-decoration:none; font-size:13px; transition:background .15s; overflow:hidden; }
.file-link:hover { background:var(--gray1); }
.file-link span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.page-header { display:flex; align-items:center; gap:10px; margin-bottom:18px; }
.page-header h2 { font-size:17px; font-weight:700; color:var(--navy); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.status-form { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
@media(max-width:768px) {
.imp-grid { grid-template-columns:1fr; }
.status-form { grid-template-columns:1fr; }
.info-dl { grid-template-columns:72px 1fr; gap:7px 10px; }
}
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -14,105 +46,139 @@
<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/export.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 class="page-header">
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm" style="flex-shrink:0">← 목록</a>
<h2 id="pageTitle">개선항목 상세</h2>
</div>
<div id="content"></div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
const id = new URLSearchParams(location.search).get('id');
const CAT={sw:'SW개선',hw:'HW개선',ui:'UI개선',firmware:'펌웨어',other:'기타'};
const CAT = {hardware:'하드웨어',software:'소프트웨어',firmware:'펌웨어',installation:'설치환경',ui:'UI 개선',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
const STATUS_OPTIONS = ['registered','reviewing','developing','deployed','done'];
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
async function load() {
const imp = await API.get('/improvements/'+id);
document.getElementById('pageTitle').textContent = `개선항목 #${imp.id}`;
document.getElementById('pageTitle').textContent = `#${imp.id} ${imp.title}`;
document.getElementById('content').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="imp-grid">
<!-- 기본 정보 -->
<div class="card">
<div class="card-title">📋 기본 정보</div>
<table class="no-hover" style="font-size:13px">
<tr><td style="color:var(--gray4);width:90px">제목</td><td><strong>${imp.title}</strong></td></tr>
<tr><td style="color:var(--gray4)">분류</td><td>${CAT[imp.category]||imp.category}</td></tr>
<tr><td style="color:var(--gray4)">우선순위</td><td>${imp.priority}</td></tr>
<tr><td style="color:var(--gray4)">관련 부품</td><td>${imp.part_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">담당 제조사</td><td><strong>${imp.manufacturer_company||'-'}</strong><br>${imp.manufacturer_name||''}</td></tr>
<tr><td style="color:var(--gray4)">등록자</td><td>${imp.created_by_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
<tr><td style="color:var(--gray4)">실제 배포일</td><td>${imp.sw_deployed_at||'-'}</td></tr>
<tr><td style="color:var(--gray4)">현재 상태</td><td>${Auth.statusBadge(imp.status)}</td></tr>
</table>
<div style="margin-top:12px">
<dl class="info-dl">
<dt>제목</dt> <dd><strong>${imp.title}</strong></dd>
<dt>분류</dt> <dd>${CAT[imp.category]||imp.category}</dd>
<dt>우선순위</dt> <dd>${PRI[imp.priority]||imp.priority}</dd>
<dt>관련 부품</dt> <dd>${imp.part_name||'-'}</dd>
<dt>담당 업체</dt> <dd><strong>${imp.manufacturer_name||'-'}</strong></dd>
<dt>등록자</dt> <dd>${imp.created_by_name||'-'}</dd>
<dt>등록일시</dt> <dd>${Auth.fmtDt(imp.created_at)}</dd>
<dt>배포 목표일</dt><dd>${imp.sw_deploy_target||'-'}</dd>
<dt>실제 배포일</dt><dd>${imp.sw_deployed_at||'-'}</dd>
<dt>상태</dt> <dd>${Auth.statusBadge(imp.status)}</dd>
</dl>
<div style="margin-top:14px">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">개선 내용</div>
<div style="background:var(--gray1);padding:12px;border-radius:6px;font-size:13px;white-space:pre-wrap">${imp.description}</div>
<div style="background:var(--gray1);padding:12px;border-radius:6px;font-size:13px;white-space:pre-wrap;line-height:1.7">${imp.description}</div>
</div>
${imp.manufacturer_memo?`<div style="margin-top:12px"><div style="font-size:12px;font-weight:700;color:var(--orange);margin-bottom:6px">제조사 메모</div><div style="background:#FFF5E6;padding:12px;border-radius:6px;font-size:13px">${imp.manufacturer_memo}</div></div>`:''}
${imp.manufacturer_memo ? `
<div style="margin-top:12px">
<div style="font-size:12px;font-weight:700;color:var(--orange);margin-bottom:6px">제조사 메모</div>
<div style="background:#FFF5E6;padding:12px;border-radius:6px;font-size:13px;line-height:1.6">${imp.manufacturer_memo}</div>
</div>` : ''}
</div>
<div class="card">
<div class="card-title">📎 연결된 AS 신고</div>
${imp.report_ids.length ? imp.report_ids.map(rid=>`
<div onclick="location.href='/pages/admin/report-detail.html?id=${rid}'"
style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;cursor:pointer;font-size:13px">
신고 #${rid}
</div>`).join('') : '<div class="alert alert-info">연결된 신고 없음</div>'}
<div class="card-title" style="margin-top:16px">📁 첨부 파일</div>
${imp.attachments.length ? imp.attachments.map(a=>`
<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-bottom:6px;display:block">
📄 ${a.name||a.path.split('/').pop()}
</a>`).join('') : '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
<!-- 연결 AS + 첨부 -->
<div>
<div class="card">
<div class="card-title">📎 연결된 AS 신고</div>
${(imp.report_links||[]).length
? (imp.report_links||[]).map(r => `
<a class="report-link" href="/pages/admin/report-detail.html?id=${r.id}">
<span class="report-link-num">#${r.seq}</span>
<span style="color:var(--gray4);font-size:12px">신고 상세 보기 →</span>
</a>`).join('')
: '<div class="alert alert-info" style="margin:0">연결된 신고 없음</div>'}
</div>
<div class="card">
<div class="card-title">📁 첨부 파일</div>
${imp.attachments.length
? imp.attachments.map(a => `
<a class="file-link" href="${a.path}" target="_blank">
<span style="flex-shrink:0">📄</span>
<span>${a.name||a.path.split('/').pop()}</span>
</a>`).join('')
: '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
</div>
</div>
</div>
<!-- 상태 변경 -->
<div class="card" style="margin-top:0">
<div class="card">
<div class="card-title">🔄 상태 변경</div>
<div class="form-row">
<div class="form-group">
<label>상태 변경</label>
<div class="status-form">
<div class="form-group" style="margin:0">
<label>상태</label>
<select id="newStatus">
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>SW 실제 배포일 (배포완료 시)</label>
<div class="form-group" style="margin:0">
<label>SW 실제 배포일 <span style="color:var(--gray4);font-weight:400">(배포완료 시)</span></label>
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
</div>
</div>
<div class="form-group"><label>변경 메모</label><input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모"></div>
<button class="btn btn-primary" onclick="changeStatus()">상태 저장</button>
<div class="form-group" style="margin-top:12px">
<label>변경 메모</label>
<input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모">
</div>
<button class="btn btn-primary" onclick="changeStatus()">저장</button>
</div>
<!-- 이력 로그 -->
<div class="card" style="margin-top:0">
<!-- 변경 이력 -->
<div class="card">
<div class="card-title">📜 변경 이력</div>
${imp.logs.length ? `<div class="timeline">${imp.logs.map(l=>`
<div class="tl-item">
<div class="tl-time">${Auth.fmtDt(l.changed_at)}${l.by||'시스템'}</div>
<div class="tl-text">${l.old_status?`${STATUS_LABEL[l.old_status]||l.old_status}`:''}${STATUS_LABEL[l.new_status]||l.new_status}${l.memo?` / ${l.memo}`:''}</div>
</div>`).join('')}</div>` : '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
${imp.logs.length
? `<div class="timeline">${imp.logs.map(l=>`
<div class="tl-item">
<div class="tl-time">${Auth.fmtDt(l.changed_at)} ${l.by||'시스템'}</div>
<div class="tl-text">${l.old_status?`${STATUS_LABEL[l.old_status]||l.old_status}`:''}${STATUS_LABEL[l.new_status]||l.new_status}${l.memo?` / ${l.memo}`:''}</div>
</div>`).join('')}</div>`
: '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
</div>
`;
}
async function changeStatus() {
const status = document.getElementById('newStatus').value;
const memo = document.getElementById('changeMemo').value;
const date = document.getElementById('deployedAt').value;
const fd = new FormData();
fd.append('status', status); fd.append('memo', memo);
fd.append('status', document.getElementById('newStatus').value);
fd.append('memo', document.getElementById('changeMemo').value);
const date = document.getElementById('deployedAt').value;
if (date) fd.append('sw_deployed_at', date);
await API.patch('/improvements/'+id+'/status', fd);
load();
}
load();
</script></div></div></body></html>
</script>
</body></html>

View File

@@ -6,9 +6,10 @@
#btnDelete { display:none; }
</style></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -20,6 +21,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
@@ -62,9 +64,9 @@
<div class="form-group">
<label>분류 <span class="req">*</span></label>
<select id="mCat">
<option value="sw">SW 개선</option><option value="hw">HW 개선</option>
<option value="ui">UI 개선</option><option value="firmware">펌웨어</option>
<option value="other">기타</option>
<option value="hardware">하드웨어</option><option value="software">소프트웨어</option>
<option value="firmware">펌웨어</option><option value="installation">설치환경</option>
<option value="ui">UI 개선</option><option value="other">기타</option>
</select>
</div>
<div class="form-group">
@@ -123,20 +125,25 @@ async function bulkDelete() {
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
const CAT = {hardware:'하드웨어',software:'소프트웨어',firmware:'펌웨어',installation:'설치환경',ui:'UI',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
const selectedReports = new Set();
let allReports = [];
async function load() {
const st = document.getElementById('fStatus').value;
const mfr = document.getElementById('fMfr').value;
let impUrl = '/improvements?';
if (st) impUrl += 'status=' + st + '&';
if (mfr) impUrl += 'manufacturer_id=' + mfr + '&';
const [mfrs, imps] = await Promise.all([
API.get('/accounts?role=manufacturer'),
API.get('/improvements?status='+document.getElementById('fStatus').value+'&manufacturer_id='+document.getElementById('fMfr').value)
API.get('/manufacturers'),
API.get(impUrl)
]);
// 제조사 필터 드롭다운
const mfrSel = document.getElementById('fMfr');
if (mfrSel.options.length <= 1)
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=`${m.company||''} / ${m.name}`; mfrSel.appendChild(o); });
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=m.name; mfrSel.appendChild(o); });
document.getElementById('chkAll').checked = false;
updateDeleteBtn();
@@ -151,7 +158,7 @@ async function load() {
<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">${i.manufacturer_name||'-'}</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>
@@ -161,9 +168,9 @@ async function load() {
async function openModal() {
document.getElementById('modal').classList.remove('hidden');
const mfrs = await API.get('/accounts?role=manufacturer');
document.getElementById('mMfr').innerHTML = '<option value="">제조사 선택</option>' +
mfrs.map(m=>`<option value="${m.id}">${m.company||''} / ${m.name}</option>`).join('');
const mfrs = await API.get('/manufacturers');
document.getElementById('mMfr').innerHTML = '<option value="">업체 선택</option>' +
mfrs.map(m=>`<option value="${m.id}">${m.name}</option>`).join('');
allReports = await API.get('/reports');
renderReportList('');
}
@@ -171,12 +178,12 @@ function closeModal() { document.getElementById('modal').classList.add('hidden')
function searchReports() { renderReportList(document.getElementById('mReportSearch').value.toLowerCase()); }
function renderReportList(q) {
const filtered = allReports.filter(r => !q || String(r.id).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
const filtered = allReports.filter(r => !q || String(r.seq).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
document.getElementById('mReportList').innerHTML = filtered.map(r => `
<label style="display:flex;gap:8px;align-items:center;padding:5px;cursor:pointer;${selectedReports.has(r.id)?'background:#E3EDFF;border-radius:4px':''}">
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}" style="accent-color:var(--accent);flex-shrink:0"
onchange="${selectedReports.has(r.id)?'selectedReports.delete':'selectedReports.add'}(${r.id}); renderReportList('${q}')">
<span><strong>#${r.id}</strong> ${r.charger_id||''}${(r.issue_types||[]).join(', ')}</span>
<span><strong>#${r.seq}</strong> ${r.charger_id||''}${(r.issue_types||[]).join(', ')}</span>
</label>`).join('') || '<div style="color:var(--gray4)">검색 결과 없음</div>';
}

View File

@@ -59,9 +59,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -73,6 +74,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -1,8 +1,9 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>QR 생성</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -14,6 +15,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -114,15 +114,29 @@
.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; }
/* 충전기 검색 드롭다운 */
.ec-opt { padding:9px 12px; cursor:pointer; font-size:13px; border-bottom:1px solid #F1F5F9; }
.ec-opt:hover { background:var(--gray1); }
.ec-opt:last-child { border-bottom:none; }
@media(max-width:768px) {
.cost-summary-grid { grid-template-columns:1fr !important; }
.issue-chk-grid { grid-template-columns:1fr !important; }
.no-hover td:first-child { white-space:nowrap; }
.no-hover td { word-break:break-word; }
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
@@ -134,12 +148,13 @@
<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/export.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 style="margin-bottom:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm" style="flex-shrink:0">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy);min-width:0;word-break:break-word;">신고 상세</h2>
</div>
<div id="content"></div>
</div>
@@ -152,12 +167,19 @@
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
const params = new URLSearchParams(location.search);
const reportId = params.get('id');
const PARTY_LABEL = {
cpo: 'CPO (운영사)',
manufacturer: '제조사',
manufacturer: '업체',
self: '자체 부담',
user: '사용자 과실',
other: '기타',
@@ -175,23 +197,36 @@ const COST_STATUS_ICON = {
settled: '✅',
};
let editOpen = false;
function toggleEdit() {
editOpen = !editOpen;
const wrap = document.getElementById('costEditWrap');
const btn = document.getElementById('editToggleBtn');
if (editOpen) {
wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
wrap.classList.remove('collapsed');
btn.innerHTML = '▲ 수정 접기';
function toggleCostEdit(repairId) {
const wrap = document.getElementById('costEditWrap_' + repairId);
const btn = document.getElementById('editToggleBtn_' + repairId);
if (!wrap) return;
const isOpen = wrap.style.display !== 'none';
if (isOpen) {
wrap.style.display = 'none';
if (btn) btn.innerHTML = '✏️ 수정하기';
} else {
wrap.style.maxHeight = '0';
wrap.classList.add('collapsed');
btn.innerHTML = '✏️ 수정하기';
wrap.style.display = 'block';
if (btn) btn.innerHTML = '▲ 접기';
}
}
function togglePartySelect(repairId) {
const v = document.getElementById('partyType_' + repairId)?.value;
const mfr = document.getElementById('mfrWrap_' + repairId);
const cus = document.getElementById('customWrap_' + repairId);
if (mfr) mfr.style.display = v === 'manufacturer' ? 'block' : 'none';
if (cus) cus.style.display = v === 'other' ? 'block' : 'none';
}
function toggleRecvPartySelect(repairId) {
const v = document.getElementById('recvPartyType_' + repairId)?.value;
const mfr = document.getElementById('recvMfrWrap_' + repairId);
const cus = document.getElementById('recvCustomWrap_' + repairId);
if (mfr) mfr.style.display = v === 'manufacturer' ? 'block' : 'none';
if (cus) cus.style.display = v === 'other' ? 'block' : 'none';
}
const IMP_CAT_LABEL = {
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
installation:'설치환경', other:'기타'
@@ -201,158 +236,143 @@ async function load() {
const [r, issueTypes, manufacturers, improvements] = await Promise.all([
API.get('/reports/' + reportId),
API.get('/settings/issue-types'),
API.get('/accounts?role=manufacturer'),
API.get('/manufacturers/public'),
API.get('/improvements'),
]);
const repair = r.repair;
const cost = repair?.cost;
const prevRepairs = r.prev_repairs || [];
window._reportData = r; // saveReport 경고용
document.getElementById('pageTitle').textContent = `신고 #${r.seq} 상세`;
// ── 출장비 요약 HTML 생성 ──
let costHtml = '';
if (repair) {
const hasCost = cost && cost.cost_party_type;
const costStatus = cost?.cost_status || 'pending';
// ── 조치별 출장비 HTML 생성 함수 ──
function buildCostHtml(rep, mfrs) {
const c = rep.cost;
const rid = rep.id;
const hasCost = c && c.cost_party_type;
const costStatus = c?.cost_status || 'pending';
const statusLabel = COST_STATUS_LABEL[costStatus] || costStatus;
const statusIcon = COST_STATUS_ICON[costStatus] || '🕐';
// 부담 주체 텍스트
let partyText = '-';
if (cost?.cost_party_type) {
partyText = PARTY_LABEL[cost.cost_party_type] || cost.cost_party_type;
if (cost.cost_party_type === 'manufacturer' && cost.manufacturer_name) {
partyText += ` (${cost.manufacturer_name})`;
}
if (cost.cost_party_type === 'other' && cost.cost_party_custom) {
partyText += `${cost.cost_party_custom}`;
}
if (c?.cost_party_type) {
partyText = PARTY_LABEL[c.cost_party_type] || c.cost_party_type;
if (c.cost_party_type === 'manufacturer' && c.cost_manufacturer_name) partyText += ` (${c.cost_manufacturer_name})`;
if (c.cost_party_type === 'other' && c.cost_party_custom) partyText += ` ${c.cost_party_custom}`;
}
let recvText = '-';
if (c?.recv_party_type) {
recvText = PARTY_LABEL[c.recv_party_type] || c.recv_party_type;
if (c.recv_party_type === 'manufacturer' && c.recv_manufacturer_name) recvText += ` (${c.recv_manufacturer_name})`;
if (c.recv_party_type === 'other' && c.recv_party_custom) recvText += `${c.recv_party_custom}`;
}
// 요약 카드 (처리 내역이 있을 때만 표시)
const summaryHtml = hasCost ? `
<div class="cost-summary s-${costStatus}">
<div class="cost-summary s-${costStatus}" style="margin-bottom:10px;">
<div class="cost-summary-header">
<div class="cost-summary-title">
${statusIcon} 출장비 처리 내역
</div>
<div class="cost-summary-title">${statusIcon} 출장비 처리 내역</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="cost-status-badge csb-${costStatus}">${statusLabel}</span>
<button class="edit-toggle-btn" id="editToggleBtn" onclick="toggleEdit()">✏️ 수정하기</button>
<button class="edit-toggle-btn" id="editToggleBtn_${rid}" onclick="toggleCostEdit(${rid})">✏️ 수정하기</button>
</div>
</div>
<div class="cost-summary-grid">
<div class="cost-summary-item">
<label>출장비 부담 주체</label>
<span>${partyText}</span>
</div>
<div class="cost-summary-item">
<label>출장비 금액</label>
<span class="amount">${(cost.cost_amount || 0).toLocaleString()}원</span>
</div>
<div class="cost-summary-item">
<label>처리 담당자</label>
<span>${cost.reviewed_by_name || '-'}</span>
</div>
<div class="cost-summary-item">
<label>처리 일시</label>
<span>${Auth.fmtDt(cost.reviewed_at)}</span>
</div>
<div class="cost-summary-item"><label>부담 주체</label><span>${partyText}</span></div>
<div class="cost-summary-item"><label>수급 주체</label><span>${recvText}</span></div>
<div class="cost-summary-item"><label>금액</label><span class="amount">${(c.cost_amount||0).toLocaleString()}</span></div>
<div class="cost-summary-item"><label>담당자</label><span>${c.reviewed_by_name||'-'}</span></div>
<div class="cost-summary-item"><label>처리일시</label><span>${Auth.fmtDt(c.reviewed_at)}</span></div>
</div>
${(c.root_cause||c.admin_note) ? `<hr class="cost-summary-divider">
${c.root_cause ? `<div style="margin-bottom:6px;"><div style="font-size:10px;color:var(--gray4);margin-bottom:3px;">문제 원인</div><div class="cost-note-box">${c.root_cause}</div></div>` : ''}
${c.admin_note ? `<div><div style="font-size:10px;color:var(--gray4);margin-bottom:3px;">비고</div><div class="cost-note-box">${c.admin_note}</div></div>` : ''}` : ''}
</div>` : `<div style="font-size:12px;color:var(--gray4);margin-bottom:8px;">💰 출장비 미입력</div>`;
${(cost.root_cause || cost.admin_note) ? `
<hr class="cost-summary-divider">
${cost.root_cause ? `
<div style="margin-bottom:8px;">
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">문제 원인</div>
<div class="cost-note-box">${cost.root_cause}</div>
</div>` : ''}
${cost.admin_note ? `
<div>
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">비고</div>
<div class="cost-note-box">${cost.admin_note}</div>
</div>` : ''}
` : ''}
</div>` : '';
// 수정 폼 (항상 존재, 기존 미처리면 바로 펼쳐져 있음)
const formCollapsed = hasCost ? 'collapsed' : '';
if (!hasCost) editOpen = true;
costHtml = `
<div class="card" style="margin-top:0">
<div class="card-title">💰 출장비 처리${hasCost ? '' : ' (관리자)'}</div>
const formDisplay = hasCost ? 'none' : 'block';
return `
<div style="border-top:1px dashed var(--gray3);margin-top:14px;padding-top:14px;">
<div style="font-size:13px;font-weight:700;color:var(--navy);margin-bottom:10px;">💰 출장비 정산</div>
${summaryHtml}
<!-- 입력 / 수정 폼 -->
<div class="cost-edit-wrap ${formCollapsed}" id="costEditWrap">
<div class="cost-edit-inner">
<div class="form-row">
<div class="form-group">
<label>문제 원인 파악 <span class="req">*</span></label>
<textarea id="rootCause" rows="3"
placeholder="조치 내용 검토 후 원인을 기재하세요.">${cost?.root_cause || ''}</textarea>
</div>
<div class="form-group">
<label>비고</label>
<textarea id="adminNote" rows="3"
placeholder="특이사항, 추가 메모 등">${cost?.admin_note || ''}</textarea>
</div>
<div id="costEditWrap_${rid}" style="display:${formDisplay}">
<div class="form-row">
<div class="form-group">
<label>문제 원인 <span class="req">*</span></label>
<textarea id="rootCause_${rid}" rows="3" placeholder="조치 내용 검토 후 원인 기재">${c?.root_cause||''}</textarea>
</div>
<div class="form-row-3">
<div class="form-group">
<label>출장비 부담 주체 <span class="req">*</span></label>
<select id="partyType" onchange="toggleParty()">
<option value="">선택</option>
<option value="cpo" ${cost?.cost_party_type === 'cpo' ? 'selected' : ''}>CPO (운영사)</option>
<option value="manufacturer" ${cost?.cost_party_type === 'manufacturer' ? 'selected' : ''}>제조사</option>
<option value="self" ${cost?.cost_party_type === 'self' ? 'selected' : ''}>자체 부담</option>
<option value="user" ${cost?.cost_party_type === 'user' ? 'selected' : ''}>사용자 과실</option>
<option value="other" ${cost?.cost_party_type === 'other' ? 'selected' : ''}>기타</option>
</select>
</div>
<div class="form-group" id="mfrWrap"
style="display:${cost?.cost_party_type === 'manufacturer' ? 'block' : 'none'}">
<label>제조사 선택</label>
<select id="partyMfr">
<option value="">선택</option>
${manufacturers.map(m =>
`<option value="${m.id}"
${cost?.cost_party_manufacturer_id == m.id ? 'selected' : ''}>
${m.company || ''} / ${m.name}
</option>`).join('')}
</select>
</div>
<div class="form-group" id="customWrap"
style="display:${cost?.cost_party_type === 'other' ? 'block' : 'none'}">
<label>기타 직접 입력</label>
<input type="text" id="partyCustom" value="${cost?.cost_party_custom || ''}">
</div>
<div class="form-group">
<label>비고</label>
<textarea id="adminNote_${rid}" rows="3" placeholder="특이사항">${c?.admin_note||''}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>출장비 금액 (원)</label>
<input type="number" id="costAmount"
value="${cost?.cost_amount || 0}" min="0" step="1000">
</div>
<div class="form-group">
<label>처리 상태</label>
<select id="costStatus">
<option value="pending" ${(!cost || cost.cost_status === 'pending') ? 'selected' : ''}>미처리</option>
<option value="billed" ${cost?.cost_status === 'billed' ? 'selected' : ''}>청구완료</option>
<option value="waived" ${cost?.cost_status === 'waived' ? 'selected' : ''}>면제</option>
<option value="settled" ${cost?.cost_status === 'settled' ? 'selected' : ''}>정산완료</option>
</select>
</div>
</div>
<div id="costErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="saveCost(${repair.id})">
💾 출장비 처리 저장
</button>
</div>
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;">부담 주체 (비용 부담)</div>
<div class="form-row-3">
<div class="form-group">
<label>유형 <span class="req">*</span></label>
<select id="partyType_${rid}" onchange="togglePartySelect(${rid})">
<option value="">선택</option>
<option value="cpo" ${c?.cost_party_type==='cpo' ?'selected':''}>CPO (운영사)</option>
<option value="manufacturer" ${c?.cost_party_type==='manufacturer' ?'selected':''}>업체</option>
<option value="self" ${c?.cost_party_type==='self' ?'selected':''}>자체 부담</option>
<option value="user" ${c?.cost_party_type==='user' ?'selected':''}>사용자 과실</option>
<option value="other" ${c?.cost_party_type==='other' ?'selected':''}>기타</option>
</select>
</div>
<div class="form-group" id="mfrWrap_${rid}" style="display:${c?.cost_party_type==='manufacturer'?'block':'none'}">
<label>업체</label>
<select id="partyMfr_${rid}">
<option value="">선택</option>
${mfrs.map(m=>`<option value="${m.id}" ${c?.cost_party_manufacturer_id==m.id?'selected':''}>${m.name}</option>`).join('')}
</select>
</div>
<div class="form-group" id="customWrap_${rid}" style="display:${c?.cost_party_type==='other'?'block':'none'}">
<label>기타</label>
<input type="text" id="partyCustom_${rid}" value="${c?.cost_party_custom||''}">
</div>
</div>
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;margin-top:10px;">수급 주체 (비용 수령)</div>
<div class="form-row-3">
<div class="form-group">
<label>유형</label>
<select id="recvPartyType_${rid}" onchange="toggleRecvPartySelect(${rid})">
<option value="">선택</option>
<option value="cpo" ${c?.recv_party_type==='cpo' ?'selected':''}>CPO (운영사)</option>
<option value="manufacturer" ${c?.recv_party_type==='manufacturer' ?'selected':''}>업체</option>
<option value="self" ${c?.recv_party_type==='self' ?'selected':''}>자체 부담</option>
<option value="user" ${c?.recv_party_type==='user' ?'selected':''}>사용자 과실</option>
<option value="other" ${c?.recv_party_type==='other' ?'selected':''}>기타</option>
</select>
</div>
<div class="form-group" id="recvMfrWrap_${rid}" style="display:${c?.recv_party_type==='manufacturer'?'block':'none'}">
<label>업체</label>
<select id="recvPartyMfr_${rid}">
<option value="">선택</option>
${mfrs.map(m=>`<option value="${m.id}" ${c?.recv_party_manufacturer_id==m.id?'selected':''}>${m.name}</option>`).join('')}
</select>
</div>
<div class="form-group" id="recvCustomWrap_${rid}" style="display:${c?.recv_party_type==='other'?'block':'none'}">
<label>기타</label>
<input type="text" id="recvPartyCustom_${rid}" value="${c?.recv_party_custom||''}">
</div>
</div>
<div class="form-row" style="margin-top:10px;">
<div class="form-group">
<label>금액 (원)</label>
<input type="number" id="costAmount_${rid}" value="${c?.cost_amount||0}" min="0" step="1000">
</div>
<div class="form-group">
<label>처리 상태</label>
<select id="costStatus_${rid}">
<option value="pending" ${(!c||c.cost_status==='pending') ?'selected':''}>미처리</option>
<option value="billed" ${c?.cost_status==='billed' ?'selected':''}>청구완료</option>
<option value="waived" ${c?.cost_status==='waived' ?'selected':''}>면제</option>
<option value="settled" ${c?.cost_status==='settled' ?'selected':''}>정산완료</option>
</select>
</div>
</div>
<div id="costErr_${rid}" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="saveCost(${rid})">💾 출장비 저장</button>
</div>
</div>`;
}
@@ -369,8 +389,19 @@ async function load() {
<!-- 보기 모드 -->
<div class="report-view" id="reportView">
${r.report_scope === 'station' ? `
<div style="background:#F5F3FF;border:1px solid #DDD6FE;border-radius:8px;padding:9px 14px;margin-bottom:10px;font-size:13px;color:#5B21B6;font-weight:600;">
🏢 충전소 전체 신고 · <strong>${r.scope_charger_count}대</strong> 대상
</div>` : r.report_scope === 'type' ? `
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:9px 14px;margin-bottom:10px;font-size:13px;color:#1D4ED8;font-weight:600;">
🔧 동일 모델 전체 신고 · <strong>${r.scope_charger_count}대</strong> 대상
</div>` : r.report_scope === 'multi' ? `
<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:8px;padding:9px 14px;margin-bottom:10px;font-size:13px;color:#92400E;font-weight:600;">
📋 충전기 ${r.scope_charger_count}대 선택 신고
${(r.charger_ids||[]).length > 1 ? `<div style="margin-top:6px;font-size:12px;font-weight:400;display:flex;flex-wrap:wrap;gap:4px">${(r.charger_ids||[]).map(cid=>`<span style="background:#FDE68A;padding:2px 8px;border-radius:10px">${cid}</span>`).join('')}</div>` : ''}
</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);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong>${r.report_scope !== 'single' ? ` <span style="font-size:11px;color:var(--gray4)">(대표 충전기)</span>` : ''}</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>
@@ -437,6 +468,60 @@ async function load() {
<!-- 편집 모드 -->
<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)">충전기</label>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--gray1);border-radius:6px;font-size:13px;margin-bottom:6px">
<span id="rEditChargerCurrent"><strong>${r.charger_id}</strong>${r.station_name ? ` · ${r.station_name}` : ''}</span>
<button type="button" onclick="toggleChargerSearch()" class="edit-toggle-btn" style="flex-shrink:0">변경</button>
</div>
<div id="rEditChargerSearch" style="display:none">
<div style="position:relative">
<input type="text" id="rEditChargerInput" autocomplete="off"
placeholder="충전소명 또는 충전기 ID 검색..."
oninput="filterEditChargers(this.value)" onfocus="filterEditChargers(this.value)"
style="width:100%;padding:8px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;box-sizing:border-box;outline:none">
<div id="rEditChargerDropdown"
style="display:none;position:absolute;top:calc(100% + 4px);left:0;right:0;
background:white;border:1px solid var(--gray3);border-radius:7px;
max-height:200px;overflow-y:auto;z-index:20;box-shadow:0 4px 12px rgba(0,0,0,.12)"></div>
</div>
<div id="rEditChargerSelected"
style="display:none;margin-top:6px;padding:6px 10px;background:#EFF6FF;
border:1px solid #BFDBFE;border-radius:6px;font-size:12px;color:var(--navy2);">
<span id="rEditChargerSelectedText"></span>
<button type="button" onclick="clearEditCharger()" style="float:right;background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px;padding:0 2px;margin-left:8px">✕</button>
</div>
</div>
</div>
<!-- 신고 범위 -->
<div class="form-group" style="margin-bottom:10px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 범위</label>
<div style="display:flex;flex-direction:column;gap:7px;margin-top:5px">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="single" style="width:auto;accent-color:var(--accent)"
${!r.report_scope || r.report_scope === 'single' ? 'checked' : ''}>
<span><strong>이 충전기만</strong></span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="station" style="width:auto;accent-color:var(--accent)"
${r.report_scope === 'station' ? 'checked' : ''}>
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">— 같은 충전소의 모든 충전기 대상</span></span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="type" style="width:auto;accent-color:var(--accent)"
${r.report_scope === 'type' ? 'checked' : ''}>
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">— 같은 충전기 종류 전체 대상</span></span>
</label>
${r.report_scope === 'multi' ? `
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="multi" style="width:auto;accent-color:var(--accent)" checked>
<span><strong>선택 충전기 ${r.scope_charger_count}대</strong> <span style="font-size:11px;color:var(--gray4)">— 접수 시 선택한 충전기들</span></span>
</label>` : ''}
</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>
<div class="issue-chk-grid">
@@ -545,6 +630,7 @@ async function load() {
${(pr.photos_after||[]).length ? `<label style="font-size:12px;font-weight:700;color:var(--navy2);margin-top:8px;display:block">조치 후 사진</label>
<div class="photo-preview">${(pr.photos_after||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
</div>` : ''}
${buildCostHtml(pr, manufacturers)}
</div>`).join('');
})() : ''}
@@ -592,6 +678,7 @@ async function load() {
</div>
</div>
${renderLocationMap(repair)}
${buildCostHtml(repair, manufacturers)}
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
repair.linked_improvements && repair.linked_improvements.length ? `
@@ -678,7 +765,7 @@ async function load() {
<select id="impMfr">
<option value="">미지정 (나중에 설정)</option>
${manufacturers.map(m =>
`<option value="${m.id}">${m.company ? m.company+' / ' : ''}${m.name}</option>`
`<option value="${m.id}">${m.name}</option>`
).join('')}
</select>
</div>
@@ -695,7 +782,6 @@ async function load() {
</div>
${costHtml}
`;
// 신고 편집 폼 사진 압축 설정
@@ -705,11 +791,6 @@ async function load() {
// 지도 초기화 (수리 정보가 있을 때만)
if (repair) initRepairMap(repair);
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
if (!editOpen) return;
const wrap = document.getElementById('costEditWrap');
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
}
/* ── 방문 위치 지도 ── */
@@ -829,6 +910,7 @@ function toggleReportEdit() {
edit.classList.remove('active');
view.classList.remove('hidden');
btn.innerHTML = '✏️ 내용 수정';
clearEditCharger();
} else {
view.classList.add('hidden');
edit.classList.add('active');
@@ -836,6 +918,79 @@ function toggleReportEdit() {
}
}
/* ── 충전기 변경 (수정 폼) ── */
let editChargerList = [];
let editChargerFiltered = [];
let editNewChargerId = null;
function _ecEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function toggleChargerSearch() {
const wrap = document.getElementById('rEditChargerSearch');
if (!wrap) return;
const opening = wrap.style.display === 'none';
wrap.style.display = opening ? 'block' : 'none';
if (opening) {
if (!editChargerList.length) {
API.get('/chargers').then(cs => { editChargerList = cs; filterEditChargers(''); });
} else {
filterEditChargers('');
}
setTimeout(() => document.getElementById('rEditChargerInput')?.focus(), 50);
}
}
function filterEditChargers(q) {
const dd = document.getElementById('rEditChargerDropdown');
if (!dd) return;
q = (q || '').trim().toLowerCase();
editChargerFiltered = (q
? editChargerList.filter(c =>
c.station_name.toLowerCase().includes(q) ||
c.name.toLowerCase().includes(q) ||
c.id.toLowerCase().includes(q))
: editChargerList).slice(0, 50);
dd.style.display = editChargerFiltered.length ? 'block' : 'none';
dd.innerHTML = editChargerFiltered.map((c, i) => `
<div class="ec-opt" onclick="selectEditCharger(${i})">
<div style="font-weight:600;color:var(--navy)">${_ecEsc(c.station_name)} · ${_ecEsc(c.name)}</div>
<div style="font-size:11px;color:var(--gray4);margin-top:2px">${_ecEsc(c.id)}</div>
</div>`).join('');
}
function selectEditCharger(idx) {
const c = editChargerFiltered[idx];
if (!c) return;
editNewChargerId = c.id;
document.getElementById('rEditChargerDropdown').style.display = 'none';
document.getElementById('rEditChargerInput').value = '';
document.getElementById('rEditChargerSelectedText').textContent =
`${c.station_name} · ${c.name} (${c.id})`;
document.getElementById('rEditChargerSelected').style.display = 'block';
}
function clearEditCharger() {
editNewChargerId = null;
const sel = document.getElementById('rEditChargerSelected');
if (sel) sel.style.display = 'none';
const inp = document.getElementById('rEditChargerInput');
if (inp) inp.value = '';
const dd = document.getElementById('rEditChargerDropdown');
if (dd) dd.style.display = 'none';
const wrap = document.getElementById('rEditChargerSearch');
if (wrap) wrap.style.display = 'none';
}
document.addEventListener('click', e => {
const inp = document.getElementById('rEditChargerInput');
const dd = document.getElementById('rEditChargerDropdown');
if (inp && dd && !inp.contains(e.target) && !dd.contains(e.target)) {
dd.style.display = 'none';
}
});
async function saveReport(reportId) {
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
if (!issues.length) {
@@ -844,6 +999,14 @@ async function saveReport(reportId) {
err.style.display = 'block';
return;
}
// 재조치 대기 중인데 상태를 pending 이외로 바꾸면 정비사 목록에서 사라짐 → 경고
const selStatus = document.getElementById('rEditStatus').value;
if (selStatus !== 'pending') {
const latestRepair = (window._reportData && window._reportData.repair);
if (latestRepair && latestRepair.re_dispatch_requested && !latestRepair.approved_at) {
if (!confirm('⚠️ 현재 재조치 대기 중인 건입니다.\n상태를 "접수(pending)" 이외로 변경하면 정비사 AS 목록에서 사라집니다.\n\n계속 저장하시겠습니까?')) return;
}
}
document.getElementById('rEditErr').style.display = 'none';
const fd = new FormData();
fd.append('issue_types', JSON.stringify(issues));
@@ -851,8 +1014,11 @@ async function saveReport(reportId) {
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('status', selStatus);
fd.append('ocpp_log', document.getElementById('rEditOcppLog').value);
if (editNewChargerId) fd.append('charger_id', editNewChargerId);
const newScope = document.querySelector('input[name="rEditScope"]:checked')?.value;
if (newScope) fd.append('scope', newScope);
const newPhotos = document.getElementById('rEditPhoto')?.files || [];
Array.from(newPhotos).forEach(f => fd.append('photos', f));
try {
@@ -865,11 +1031,7 @@ async function saveReport(reportId) {
}
}
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 toggleParty() {} // 레거시 — togglePartySelect(repairId) 사용
function toggleApprovePanel() {
const panel = document.getElementById('approvePanel');
@@ -985,28 +1147,31 @@ async function submitClose(id) {
}
async function saveCost(repairId) {
const partyType = document.getElementById('partyType').value;
if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; }
const partyType = document.getElementById('partyType_' + repairId)?.value;
if (!partyType) {
const err = document.getElementById('costErr_' + repairId);
if (err) { err.textContent = '출장비 부담 주체를 선택해 주세요.'; err.style.display = 'block'; }
return;
}
const fd = new FormData();
fd.append('root_cause', document.getElementById('rootCause').value);
fd.append('admin_note', document.getElementById('adminNote').value);
fd.append('cost_party_type', partyType);
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr')?.value || '');
fd.append('cost_party_custom', document.getElementById('partyCustom')?.value || '');
fd.append('cost_amount', document.getElementById('costAmount').value || 0);
fd.append('cost_status', document.getElementById('costStatus').value);
fd.append('root_cause', document.getElementById('rootCause_' + repairId)?.value || '');
fd.append('admin_note', document.getElementById('adminNote_' + repairId)?.value || '');
fd.append('cost_party_type', partyType);
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr_' + repairId)?.value || '');
fd.append('cost_party_custom', document.getElementById('partyCustom_' + repairId)?.value || '');
fd.append('recv_party_type', document.getElementById('recvPartyType_' + repairId)?.value || '');
fd.append('recv_party_manufacturer_id', document.getElementById('recvPartyMfr_' + repairId)?.value || '');
fd.append('recv_party_custom', document.getElementById('recvPartyCustom_'+ repairId)?.value || '');
fd.append('cost_amount', document.getElementById('costAmount_' + repairId)?.value || 0);
fd.append('cost_status', document.getElementById('costStatus_' + repairId)?.value || 'pending');
try {
await API.post(`/costs/repair/${repairId}`, fd);
alert('✅ 출장비 처리가 저장되었습니다.');
editOpen = false;
load();
} catch(e) { showCostErr(e.message); }
}
function showCostErr(msg) {
const el = document.getElementById('costErr');
el.textContent = msg;
el.style.display = 'block';
} catch(e) {
const err = document.getElementById('costErr_' + repairId);
if (err) { err.textContent = e.message; err.style.display = 'block'; }
}
}
load();

View File

@@ -40,9 +40,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
@@ -54,6 +55,7 @@
<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/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
@@ -74,6 +76,7 @@
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending_all">접수 대기 (전체)</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
@@ -130,8 +133,9 @@ 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');
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
let _stationNameFilter = _p.get('station_name') || '';
// ── 뷰 전환 ──
function setView(v) {
@@ -189,8 +193,23 @@ async function load() {
const c = document.getElementById('fCharger').value.trim();
if (s) url += 'status=' + s + '&';
if (c) url += 'charger_id=' + c + '&';
if (_stationNameFilter) url += 'station_name=' + encodeURIComponent(_stationNameFilter) + '&';
allRows = await API.get(url);
// 충전소 필터 배너
const existing = document.getElementById('stationFilterBanner');
if (_stationNameFilter) {
if (!existing) {
const banner = document.createElement('div');
banner.id = 'stationFilterBanner';
banner.style.cssText = 'background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:8px 14px;margin-bottom:12px;font-size:13px;color:var(--navy2);display:flex;justify-content:space-between;align-items:center;';
banner.innerHTML = `<span>🏢 충전소 필터: <strong>${_stationNameFilter}</strong></span><button onclick="_stationNameFilter='';this.closest('#stationFilterBanner').remove();load()" style="background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px;">✕ 해제</button>`;
document.querySelector('.main').insertBefore(banner, document.querySelector('.card'));
}
} else if (existing) {
existing.remove();
}
document.getElementById('resultCount').textContent = allRows.length + '건';
renderTable();
if (curView === 'map') renderReportMap();
@@ -209,7 +228,10 @@ function renderTable() {
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
<span style="font-weight:700">${r.seq}</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">
<strong>${r.charger_id}</strong>
${r.report_scope === 'station' ? `<div style="font-size:11px;color:#7C3AED;font-weight:600;margin-top:2px">🏢 충전소 전체 · ${r.scope_charger_count}대</div>` : r.report_scope === 'type' ? `<div style="font-size:11px;color:#0369A1;font-weight:600;margin-top:2px">🔧 동일모델 전체 · ${r.scope_charger_count}대</div>` : r.report_scope === 'multi' ? `<div style="font-size:11px;color:#B45309;font-weight:600;margin-top:2px">📋 충전기 ${r.scope_charger_count}대 선택</div>` : ''}
</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>

View File

@@ -25,9 +25,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>

View File

@@ -1,8 +1,9 @@
<!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>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS — 제조사</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/manufacturer/dashboard.html" class="active">📋 개선항목 목록</a>
</div>

View File

@@ -1,6 +1,7 @@
<!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>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS — 제조사</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="main" style="max-width:760px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/manufacturer/dashboard.html" class="btn btn-outline btn-sm">← 목록</a>

View File

@@ -47,16 +47,17 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<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" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
@@ -157,7 +158,7 @@ function renderList() {
: `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`;
return `
<tr onclick="location.href='${href}'">
<td>#${r.id}${r.re_dispatch_count > 0 ? ' <span style="font-size:10px;background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:8px;font-weight:700;vertical-align:middle;">🔁조치</span>' : ''}</td>
<td>#${r.id}${r.re_dispatch_count > 0 ? ' <span style="font-size:10px;background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:8px;font-weight:700;vertical-align:middle;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : ''}</td>
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>

View File

@@ -28,16 +28,17 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<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" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
@@ -136,6 +137,7 @@ function render() {
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
</span>
${r.attempt > 1 ? `<span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:2px 8px;border-radius:10px;font-weight:700;">🔁 ${r.attempt}차 조치</span>` : ''}
<span style="font-size:11px;color:var(--gray4)">${RESULT_LABEL[r.result_status] || r.result_status}</span>
</div>
</div>

View File

@@ -10,20 +10,29 @@
.photo-preview{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.photo-preview img{width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);}
.photo-info{font-size:11px;margin-top:4px;min-height:14px;color:var(--gray4);}
@media(max-width:768px){
.upload-area{padding:16px 12px;font-size:13px;}
.photo-preview img{width:88px;height:88px;}
.main > div{max-width:100% !important;padding:0;}
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<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" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
@@ -42,7 +51,8 @@
</div>
<div class="card">
<div class="card-title">🔧 조치 내역 입력</div>
<div class="card-title" id="repairCardTitle">🔧 조치 내역 입력</div>
<div id="attemptBanner"></div>
<div class="form-group">
<label>조치 유형 <span class="req">*</span></label>
@@ -125,6 +135,13 @@
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
const params = new URLSearchParams(location.search);
const repairId = params.get('repair_id'); // 편집 모드
const chargerId = params.get('charger_id'); // 신규 모드
@@ -185,13 +202,24 @@ async function loadCreate() {
' onchange="toggleReport(' + r.id + ',this.checked,this.closest(\'label\'))">' +
'<div>' +
'<div><strong>#' + r.id + '</strong> ' + Auth.statusBadge(r.status) +
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 재조치 ' + r.re_dispatch_count + '회</span>' : '') + '</div>' +
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : '') + '</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>' +
photoHtml +
'</div>' +
'</label>';
}).join('');
// 차수 배너: 대상 신고(initReportId 또는 첫 번째)의 re_dispatch_count 기준
var targetReport = reports.find(function(r) { return r.id === parseInt(initReportId); }) || reports[0];
if (targetReport && targetReport.re_dispatch_count > 0) {
var nth = targetReport.re_dispatch_count + 1;
document.getElementById('repairCardTitle').textContent = '🔧 조치 내역 입력 (' + nth + '차 조치)';
document.getElementById('attemptBanner').innerHTML =
'<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">' +
'🔁 이 건은 <strong>' + nth + '차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)' +
'</div>';
}
} catch(e) {
document.getElementById('chargerCard').innerHTML =
'<div class="alert alert-danger">충전기 정보를 불러오지 못했습니다.<br><small style="opacity:.8">' + e.message + '</small></div>';
@@ -214,8 +242,18 @@ async function loadEdit() {
// 헤더 업데이트
var h2el = document.querySelector('.main > div > h2') || document.querySelector('h2');
if (h2el) h2el.parentNode.removeChild(h2el);
const attemptLabel = repair.attempt > 1 ? ` · ${repair.attempt}차 조치` : '';
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}${attemptLabel}</span>`);
// 차수 배너 (2차 이상일 때)
if (repair.attempt > 1) {
document.getElementById('repairCardTitle').textContent = `🔧 조치 내역 입력 (${repair.attempt}차 조치)`;
document.getElementById('attemptBanner').innerHTML =
`<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">` +
`🔁 이 건은 <strong>${repair.attempt}차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)` +
`</div>`;
}
// 충전기 카드
document.getElementById('chargerCard').innerHTML = `

View File

@@ -10,16 +10,17 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<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" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>

View File

@@ -11,12 +11,13 @@
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html" class="active">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html">📋 신고 목록</a>

View File

@@ -7,12 +7,13 @@
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html" class="active">📋 신고 목록</a>