초기 커밋 - EV AS 관리 시스템
This commit is contained in:
438
frontend/static/pages/admin/report-detail.html
Normal file
438
frontend/static/pages/admin/report-detail.html
Normal file
@@ -0,0 +1,438 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>신고 상세</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
/* 출장비 요약 카드 */
|
||||
.cost-summary {
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
}
|
||||
.cost-summary.s-pending { background: #FFF8E6; border: 2px solid #FFD600; }
|
||||
.cost-summary.s-billed { background: #E3EDFF; border: 2px solid var(--blue); }
|
||||
.cost-summary.s-waived { background: #F0F0F0; border: 2px solid #aaa; }
|
||||
.cost-summary.s-settled { background: #E8F8F2; border: 2px solid var(--green); }
|
||||
|
||||
.cost-summary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cost-summary-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--navy);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cost-status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.csb-pending { background: #FFF3CD; color: #856404; }
|
||||
.csb-billed { background: #DBEAFE; color: #1565C0; }
|
||||
.csb-waived { background: #F0F0F0; color: #555; }
|
||||
.csb-settled { background: #D1FAE5; color: #065F46; }
|
||||
|
||||
.cost-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.cost-summary-item label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--gray4);
|
||||
text-transform: uppercase;
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.cost-summary-item span {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cost-summary-item span.amount {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
color: var(--orange);
|
||||
}
|
||||
.cost-summary-divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(0,0,0,.08);
|
||||
margin: 12px 0;
|
||||
}
|
||||
.cost-note-box {
|
||||
background: rgba(0,0,0,.04);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.edit-toggle-btn {
|
||||
font-size: 12px;
|
||||
color: var(--blue);
|
||||
background: none;
|
||||
border: 1px solid var(--blue);
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.edit-toggle-btn:hover { background: #E3EDFF; }
|
||||
|
||||
/* 수정 폼 슬라이드 */
|
||||
.cost-edit-wrap {
|
||||
overflow: hidden;
|
||||
transition: max-height .3s ease;
|
||||
}
|
||||
.cost-edit-wrap.collapsed { max-height: 0 !important; }
|
||||
.cost-edit-inner {
|
||||
border-top: 1px dashed var(--gray3);
|
||||
padding-top: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="main" style="max-width:860px;margin:0 auto;">
|
||||
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
||||
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm">← 목록</a>
|
||||
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">신고 상세</h2>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const reportId = params.get('id');
|
||||
|
||||
const PARTY_LABEL = {
|
||||
cpo: 'CPO (운영사)',
|
||||
manufacturer: '제조사',
|
||||
self: '자체 부담',
|
||||
user: '사용자 과실',
|
||||
other: '기타',
|
||||
};
|
||||
const COST_STATUS_LABEL = {
|
||||
pending: '미처리',
|
||||
billed: '청구 완료',
|
||||
waived: '면제',
|
||||
settled: '정산 완료',
|
||||
};
|
||||
const COST_STATUS_ICON = {
|
||||
pending: '🕐',
|
||||
billed: '📨',
|
||||
waived: '🔖',
|
||||
settled: '✅',
|
||||
};
|
||||
|
||||
let editOpen = false;
|
||||
|
||||
function toggleEdit() {
|
||||
editOpen = !editOpen;
|
||||
const wrap = document.getElementById('costEditWrap');
|
||||
const btn = document.getElementById('editToggleBtn');
|
||||
if (editOpen) {
|
||||
wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
|
||||
wrap.classList.remove('collapsed');
|
||||
btn.innerHTML = '▲ 수정 접기';
|
||||
} else {
|
||||
wrap.style.maxHeight = '0';
|
||||
wrap.classList.add('collapsed');
|
||||
btn.innerHTML = '✏️ 수정하기';
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const r = await API.get('/reports/' + reportId);
|
||||
const repair = r.repair;
|
||||
const cost = repair?.cost;
|
||||
const manufacturers = await API.get('/accounts?role=manufacturer');
|
||||
|
||||
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`;
|
||||
|
||||
// ── 출장비 요약 HTML 생성 ──
|
||||
let costHtml = '';
|
||||
if (repair) {
|
||||
const hasCost = cost && cost.cost_party_type;
|
||||
const costStatus = cost?.cost_status || 'pending';
|
||||
const statusLabel = COST_STATUS_LABEL[costStatus] || costStatus;
|
||||
const statusIcon = COST_STATUS_ICON[costStatus] || '🕐';
|
||||
|
||||
// 부담 주체 텍스트
|
||||
let partyText = '-';
|
||||
if (cost?.cost_party_type) {
|
||||
partyText = PARTY_LABEL[cost.cost_party_type] || cost.cost_party_type;
|
||||
if (cost.cost_party_type === 'manufacturer' && cost.manufacturer_name) {
|
||||
partyText += ` (${cost.manufacturer_name})`;
|
||||
}
|
||||
if (cost.cost_party_type === 'other' && cost.cost_party_custom) {
|
||||
partyText += ` — ${cost.cost_party_custom}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 요약 카드 (처리 내역이 있을 때만 표시)
|
||||
const summaryHtml = hasCost ? `
|
||||
<div class="cost-summary s-${costStatus}">
|
||||
<div class="cost-summary-header">
|
||||
<div class="cost-summary-title">
|
||||
${statusIcon} 출장비 처리 내역
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="cost-status-badge csb-${costStatus}">${statusLabel}</span>
|
||||
<button class="edit-toggle-btn" id="editToggleBtn" onclick="toggleEdit()">✏️ 수정하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cost-summary-grid">
|
||||
<div class="cost-summary-item">
|
||||
<label>출장비 부담 주체</label>
|
||||
<span>${partyText}</span>
|
||||
</div>
|
||||
<div class="cost-summary-item">
|
||||
<label>출장비 금액</label>
|
||||
<span class="amount">${(cost.cost_amount || 0).toLocaleString()}원</span>
|
||||
</div>
|
||||
<div class="cost-summary-item">
|
||||
<label>처리 담당자</label>
|
||||
<span>${cost.reviewed_by_name || '-'}</span>
|
||||
</div>
|
||||
<div class="cost-summary-item">
|
||||
<label>처리 일시</label>
|
||||
<span>${Auth.fmtDt(cost.reviewed_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${(cost.root_cause || cost.admin_note) ? `
|
||||
<hr class="cost-summary-divider">
|
||||
${cost.root_cause ? `
|
||||
<div style="margin-bottom:8px;">
|
||||
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">문제 원인</div>
|
||||
<div class="cost-note-box">${cost.root_cause}</div>
|
||||
</div>` : ''}
|
||||
${cost.admin_note ? `
|
||||
<div>
|
||||
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">비고</div>
|
||||
<div class="cost-note-box">${cost.admin_note}</div>
|
||||
</div>` : ''}
|
||||
` : ''}
|
||||
</div>` : '';
|
||||
|
||||
// 수정 폼 (항상 존재, 기존 미처리면 바로 펼쳐져 있음)
|
||||
const formCollapsed = hasCost ? 'collapsed' : '';
|
||||
if (!hasCost) editOpen = true;
|
||||
|
||||
costHtml = `
|
||||
<div class="card" style="margin-top:0">
|
||||
<div class="card-title">💰 출장비 처리${hasCost ? '' : ' (관리자)'}</div>
|
||||
|
||||
${summaryHtml}
|
||||
|
||||
<!-- 입력 / 수정 폼 -->
|
||||
<div class="cost-edit-wrap ${formCollapsed}" id="costEditWrap">
|
||||
<div class="cost-edit-inner">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>문제 원인 파악 <span class="req">*</span></label>
|
||||
<textarea id="rootCause" rows="3"
|
||||
placeholder="조치 내용 검토 후 원인을 기재하세요.">${cost?.root_cause || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비고</label>
|
||||
<textarea id="adminNote" rows="3"
|
||||
placeholder="특이사항, 추가 메모 등">${cost?.admin_note || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div class="form-group">
|
||||
<label>출장비 부담 주체 <span class="req">*</span></label>
|
||||
<select id="partyType" onchange="toggleParty()">
|
||||
<option value="">선택</option>
|
||||
<option value="cpo" ${cost?.cost_party_type === 'cpo' ? 'selected' : ''}>CPO (운영사)</option>
|
||||
<option value="manufacturer" ${cost?.cost_party_type === 'manufacturer' ? 'selected' : ''}>제조사</option>
|
||||
<option value="self" ${cost?.cost_party_type === 'self' ? 'selected' : ''}>자체 부담</option>
|
||||
<option value="user" ${cost?.cost_party_type === 'user' ? 'selected' : ''}>사용자 과실</option>
|
||||
<option value="other" ${cost?.cost_party_type === 'other' ? 'selected' : ''}>기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="mfrWrap"
|
||||
style="display:${cost?.cost_party_type === 'manufacturer' ? 'block' : 'none'}">
|
||||
<label>제조사 선택</label>
|
||||
<select id="partyMfr">
|
||||
<option value="">선택</option>
|
||||
${manufacturers.map(m =>
|
||||
`<option value="${m.id}"
|
||||
${cost?.cost_party_manufacturer_id == m.id ? 'selected' : ''}>
|
||||
${m.company || ''} / ${m.name}
|
||||
</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="customWrap"
|
||||
style="display:${cost?.cost_party_type === 'other' ? 'block' : 'none'}">
|
||||
<label>기타 직접 입력</label>
|
||||
<input type="text" id="partyCustom" value="${cost?.cost_party_custom || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>출장비 금액 (원)</label>
|
||||
<input type="number" id="costAmount"
|
||||
value="${cost?.cost_amount || 0}" min="0" step="1000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>처리 상태</label>
|
||||
<select id="costStatus">
|
||||
<option value="pending" ${(!cost || cost.cost_status === 'pending') ? 'selected' : ''}>미처리</option>
|
||||
<option value="billed" ${cost?.cost_status === 'billed' ? 'selected' : ''}>청구완료</option>
|
||||
<option value="waived" ${cost?.cost_status === 'waived' ? 'selected' : ''}>면제</option>
|
||||
<option value="settled" ${cost?.cost_status === 'settled' ? 'selected' : ''}>정산완료</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="costErr" class="alert alert-danger" style="display:none"></div>
|
||||
<button class="btn btn-primary" onclick="saveCost(${repair.id})">
|
||||
💾 출장비 처리 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
|
||||
|
||||
<!-- 신고 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-title">📋 신고 정보</div>
|
||||
<table class="no-hover" style="font-size:13px;">
|
||||
<tr><td style="color:var(--gray4);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong></td></tr>
|
||||
<tr><td style="color:var(--gray4)">충전기명</td><td>${r.charger_name || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">종류</td><td>${r.charger_type || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">설치일</td><td>${r.installed_at || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">문제유형</td><td>${(r.issue_types || []).join(', ')}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">에러코드</td><td>${r.error_code || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">상세설명</td><td>${r.issue_detail || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">연락처</td><td>${r.contact || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">발생시각</td><td>${Auth.fmtDt(r.occurred_at)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">신고일시</td><td>${Auth.fmtDt(r.reported_at)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">상태</td><td>${Auth.statusBadge(r.status)}</td></tr>
|
||||
</table>
|
||||
${r.status === 'pending_approval' ? `
|
||||
<button class="btn btn-success btn-sm" style="margin-top:12px"
|
||||
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''}
|
||||
<div style="margin-top:12px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 사진</label>
|
||||
<div class="photo-preview">
|
||||
${(r.photos || []).map(p =>
|
||||
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
|
||||
).join('') || '<span style="font-size:12px;color:var(--gray4)">첨부 없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조치 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-title">🔧 조치 정보</div>
|
||||
${repair ? `
|
||||
<table class="no-hover" style="font-size:13px;">
|
||||
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${repair.mechanic_name || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">소속</td><td>${repair.mechanic_company || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">조치유형</td><td>${(repair.repair_types || []).join(', ')}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">조치내용</td><td>${repair.description || '-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">시작시각</td><td>${Auth.fmtDt(repair.started_at)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(repair.completed_at)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">처리결과</td><td>${Auth.statusBadge(repair.result_status)}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:12px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">조치 전 사진</label>
|
||||
<div class="photo-preview">
|
||||
${(repair.photos_before || []).map(p =>
|
||||
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
|
||||
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
|
||||
</div>
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2);margin-top:10px;display:block">조치 후 사진</label>
|
||||
<div class="photo-preview">
|
||||
${(repair.photos_after || []).map(p =>
|
||||
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
|
||||
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
` : '<div class="alert alert-info">아직 정비사가 조치를 입력하지 않았습니다.</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${costHtml}
|
||||
`;
|
||||
|
||||
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
|
||||
if (!editOpen) return;
|
||||
const wrap = document.getElementById('costEditWrap');
|
||||
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
|
||||
}
|
||||
|
||||
function toggleParty() {
|
||||
const v = document.getElementById('partyType').value;
|
||||
document.getElementById('mfrWrap').style.display = v === 'manufacturer' ? 'block' : 'none';
|
||||
document.getElementById('customWrap').style.display = v === 'other' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function approveReport(id) {
|
||||
if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return;
|
||||
await API.patch(`/reports/${id}/approve`);
|
||||
alert('승인되었습니다.');
|
||||
load();
|
||||
}
|
||||
|
||||
async function saveCost(repairId) {
|
||||
const partyType = document.getElementById('partyType').value;
|
||||
if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('root_cause', document.getElementById('rootCause').value);
|
||||
fd.append('admin_note', document.getElementById('adminNote').value);
|
||||
fd.append('cost_party_type', partyType);
|
||||
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr')?.value || '');
|
||||
fd.append('cost_party_custom', document.getElementById('partyCustom')?.value || '');
|
||||
fd.append('cost_amount', document.getElementById('costAmount').value || 0);
|
||||
fd.append('cost_status', document.getElementById('costStatus').value);
|
||||
try {
|
||||
await API.post(`/costs/repair/${repairId}`, fd);
|
||||
alert('✅ 출장비 처리가 저장되었습니다.');
|
||||
editOpen = false;
|
||||
load();
|
||||
} catch(e) { showCostErr(e.message); }
|
||||
}
|
||||
|
||||
function showCostErr(msg) {
|
||||
const el = document.getElementById('costErr');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user