정비사 조치 완료 후 동일 문제 재발 시 관리자가 기존 기록을 보존한 채
재조치를 요청할 수 있는 기능 추가.
- DB: repairs.re_dispatch_requested/at, reports.re_dispatch_count 컬럼 추가
- 재조치 요청 엔드포인트 (POST /repairs/{id}/re-dispatch): 기존 repair에 플래그,
연결 신고를 pending으로 복원, re_dispatch_count 증가
- pending 상태 신고는 새 조치 생성으로 분기 (in_progress만 기존 수정 모드)
- report-detail: 조치승인·취소 사이에 "🔁 재조치 요청" 버튼, 이전 조치 이력 카드
- 정비사 대시보드: 재조치 건에 🔁 뱃지 및 강조 버튼색 표시
- 엑셀 export: 재조치횟수 컬럼 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1021 lines
50 KiB
HTML
1021 lines
50 KiB
HTML
<!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">
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<style>
|
|
/* 출장비 요약 카드 */
|
|
.cost-summary {
|
|
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;
|
|
}
|
|
/* 신고 인라인 편집 */
|
|
.report-view { display:block; }
|
|
.report-edit { display:none; }
|
|
.report-edit.active { display:block; }
|
|
.report-view.hidden { display:none; }
|
|
.issue-chk-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:6px; }
|
|
.issue-chk-item { display:flex; align-items:center; gap:6px; font-size:13px; }
|
|
.issue-chk-item input { width:15px; height:15px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="nav">
|
|
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
|
<div id="navUser"></div>
|
|
</nav>
|
|
<div class="layout">
|
|
<div class="sidebar">
|
|
<div class="sidebar-section">AS 관리</div>
|
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
|
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
|
|
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
|
|
<div class="sidebar-section">시스템</div>
|
|
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
|
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
|
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
|
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
|
</div>
|
|
<div class="main">
|
|
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
|
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm">← 목록</a>
|
|
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">신고 상세</h2>
|
|
</div>
|
|
<div id="content"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/auth.js"></script>
|
|
<script src="/js/imageCompress.js"></script>
|
|
<script>
|
|
Auth.require(['admin']);
|
|
Auth.renderNav(document.getElementById('navUser'));
|
|
|
|
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 = '✏️ 수정하기';
|
|
}
|
|
}
|
|
|
|
const IMP_CAT_LABEL = {
|
|
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
|
|
installation:'설치환경', other:'기타'
|
|
};
|
|
|
|
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('/improvements'),
|
|
]);
|
|
const repair = r.repair;
|
|
const cost = repair?.cost;
|
|
const prevRepairs = r.prev_repairs || [];
|
|
|
|
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 class="detail-grid">
|
|
|
|
<!-- 신고 정보 -->
|
|
<div class="card">
|
|
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;">
|
|
<span>📋 신고 정보</span>
|
|
<button class="edit-toggle-btn" id="reportEditBtn" onclick="toggleReportEdit()">✏️ 내용 수정</button>
|
|
</div>
|
|
|
|
<!-- 보기 모드 -->
|
|
<div class="report-view" id="reportView">
|
|
<table class="no-hover" style="font-size:13px;">
|
|
<tr><td style="color:var(--gray4);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong></td></tr>
|
|
<tr><td style="color:var(--gray4)">충전기명</td><td>${r.charger_name || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">종류</td><td>${r.charger_type || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">설치일</td><td>${r.installed_at || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">문제유형</td><td>${(r.issue_types || []).join(', ')}</td></tr>
|
|
<tr><td style="color:var(--gray4)">에러코드</td><td>${r.error_code || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">상세설명</td><td>${r.issue_detail || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">연락처</td><td>${r.contact || '-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">발생시각</td><td>${Auth.fmtDt(r.occurred_at)}</td></tr>
|
|
<tr><td style="color:var(--gray4)">신고일시</td><td>${Auth.fmtDt(r.reported_at)}</td></tr>
|
|
<tr><td style="color:var(--gray4)">상태</td><td>${Auth.statusBadge(r.status)}</td></tr>
|
|
<tr><td style="color:var(--gray4)">신고 출처</td><td>${r.source === 'dashboard'
|
|
? `<span style="background:#F5F3FF;color:#7C3AED;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">🖥 대시보드 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
|
|
: r.source === 'admin'
|
|
? `<span style="background:#EFF6FF;color:#1565C0;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">⚙️ 관리자 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
|
|
: `<span style="background:#F0FDF4;color:#166534;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">📱 QR 스캔</span>`
|
|
}</td></tr>
|
|
</table>
|
|
${r.ocpp_log ? `
|
|
<div style="margin-top:12px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그</label>
|
|
<pre style="margin-top:6px;background:var(--gray1);border:1px solid var(--gray3);border-radius:6px;padding:10px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;">${escHtmlDetail(r.ocpp_log)}</pre>
|
|
</div>` : ''}
|
|
${r.status === 'pending_approval' ? `
|
|
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
|
<button class="btn btn-success btn-sm"
|
|
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>
|
|
<button class="btn btn-sm" style="background:#64748B;color:white;border:none"
|
|
onclick="toggleClosePanel()">🔚 상황종료</button>
|
|
</div>
|
|
<div id="closurePanel" style="display:none;margin-top:12px;background:#F8FAFC;border:1px solid var(--gray3);border-radius:8px;padding:14px">
|
|
<div style="font-size:13px;font-weight:700;color:var(--navy);margin-bottom:10px">🔚 상황종료 사유 선택</div>
|
|
<div style="display:flex;flex-direction:column;gap:7px;margin-bottom:10px">
|
|
${[
|
|
['natural','증상자연소거'],
|
|
['remote_reset','원격리셋후증상소거'],
|
|
['false_alarm','인지오류'],
|
|
['other','기타']
|
|
].map(([v,l]) => `
|
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
|
<input type="radio" name="closureType" value="${v}" style="width:auto;accent-color:var(--accent)"
|
|
onchange="document.getElementById('closureNoteWrap').style.display=this.value==='other'?'block':'none'">
|
|
${l}
|
|
</label>`).join('')}
|
|
</div>
|
|
<div id="closureNoteWrap" style="display:none;margin-bottom:10px">
|
|
<input type="text" id="closureNote" placeholder="기타 사유를 입력하세요"
|
|
style="width:100%;padding:8px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px">
|
|
</div>
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn btn-sm" style="background:#64748B;color:white;border:none"
|
|
onclick="submitClose(${r.id})">확인</button>
|
|
<button class="btn btn-outline btn-sm" onclick="toggleClosePanel()">취소</button>
|
|
</div>
|
|
</div>` : ''}
|
|
${r.status === 'closed' ? `
|
|
<div style="margin-top:12px;padding:10px 14px;background:#F1F5F9;border-radius:8px;border-left:4px solid #64748B">
|
|
<div style="font-size:12px;font-weight:700;color:#475569;margin-bottom:4px">🔚 상황종료 처리됨</div>
|
|
<div style="font-size:13px;color:var(--text)">사유: <strong>${{'natural':'증상자연소거','remote_reset':'원격리셋후증상소거','false_alarm':'인지오류','other':'기타'}[r.closure_type]||r.closure_type||'-'}</strong>${r.closure_note ? ' — ' + escHtmlDetail(r.closure_note) : ''}</div>
|
|
<div style="font-size:11px;color:var(--gray4);margin-top:3px">${r.closed_by_name||''} · ${r.closed_at ? new Date(r.closed_at).toLocaleString('ko-KR') : ''}</div>
|
|
</div>` : ''}
|
|
</div>
|
|
|
|
<!-- 편집 모드 -->
|
|
<div class="report-edit" id="reportEdit">
|
|
<div class="form-group" style="margin-bottom:10px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">문제 유형 <span class="req">*</span></label>
|
|
<div class="issue-chk-grid">
|
|
${issueTypes.map(i => `
|
|
<label class="issue-chk-item">
|
|
<input type="checkbox" class="r-issue-chk" value="${i.key}"
|
|
${(r.issue_types||[]).includes(i.key) ? 'checked' : ''}>
|
|
${i.label}
|
|
</label>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:10px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">에러 코드</label>
|
|
<input type="text" id="rEditErrorCode" value="${r.error_code||''}" placeholder="에러 코드">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:10px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">상세 설명</label>
|
|
<textarea id="rEditDetail" rows="3" placeholder="문제 상황 설명">${r.issue_detail||''}</textarea>
|
|
</div>
|
|
<div class="form-row" style="margin-bottom:10px">
|
|
<div class="form-group">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">연락처</label>
|
|
<input type="text" id="rEditContact" value="${r.contact||''}" placeholder="연락처">
|
|
</div>
|
|
<div class="form-group">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">발생 시각</label>
|
|
<input type="datetime-local" id="rEditOccurred"
|
|
value="${r.occurred_at ? r.occurred_at.slice(0,16) : ''}">
|
|
</div>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:14px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 상태</label>
|
|
<select id="rEditStatus">
|
|
<option value="pending_approval" ${r.status==='pending_approval'?'selected':''}>승인대기</option>
|
|
<option value="pending" ${r.status==='pending' ?'selected':''}>접수</option>
|
|
<option value="in_progress" ${r.status==='in_progress' ?'selected':''}>처리중</option>
|
|
<option value="done" ${r.status==='done' ?'selected':''}>완료</option>
|
|
<option value="waiting" ${r.status==='waiting' ?'selected':''}>부품대기</option>
|
|
<option value="revisit" ${r.status==='revisit' ?'selected':''}>재방문</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:14px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그 <span style="font-weight:400;color:var(--gray4)">(선택)</span></label>
|
|
<textarea id="rEditOcppLog" rows="5"
|
|
style="width:100%;padding:8px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:12px;font-family:monospace;resize:vertical;box-sizing:border-box;"
|
|
placeholder="OCPP 통신 로그 붙여넣기...">${r.ocpp_log || ''}</textarea>
|
|
<label style="display:flex;align-items:center;gap:8px;margin-top:5px;cursor:pointer;font-size:12px;color:var(--blue);">
|
|
<input type="file" id="rEditOcppFile" accept=".txt,.csv,.log" style="display:none" onchange="readOcppFileEdit(this)">
|
|
📄 파일 선택 (.txt/.csv/.log)
|
|
<span id="rEditOcppFileName" style="color:var(--gray4);font-weight:400"></span>
|
|
</label>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:14px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">사진 관리</label>
|
|
${(r.photos||[]).length ? `
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
|
${(r.photos||[]).map(p => `
|
|
<div style="position:relative;">
|
|
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
|
|
<button onclick="deleteReportPhoto(${r.id},${p.id})"
|
|
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
|
|
</div>`).join('')}
|
|
</div>` : ''}
|
|
<label class="upload-area" for="rEditPhoto" style="padding:10px;font-size:12px;">📷 사진 추가 (선택 · 여러 장)</label>
|
|
<input type="file" id="rEditPhoto" accept="image/*" multiple style="display:none">
|
|
<div class="photo-preview" id="rEditPhotoPreview"></div>
|
|
<div class="photo-info" id="rEditPhotoInfo" style="color:var(--gray4)"></div>
|
|
</div>
|
|
<div id="rEditErr" class="alert alert-danger" style="display:none"></div>
|
|
<div style="display:flex;gap:8px;">
|
|
<button class="btn btn-primary btn-sm" onclick="saveReport(${r.id})">💾 저장</button>
|
|
<button class="btn btn-outline btn-sm" onclick="toggleReportEdit()">취소</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:12px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 사진</label>
|
|
<div class="photo-preview">
|
|
${(r.photos || []).map(p =>
|
|
`<img src="${p.path}" onclick="window.open('${p.path}')" style="cursor:zoom-in">`
|
|
).join('') || '<span style="font-size:12px;color:var(--gray4)">첨부 없음</span>'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 조치 정보 -->
|
|
<div class="card">
|
|
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
|
|
<span>🔧 조치 정보</span>
|
|
${repair ? `
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
|
${repair.approved_at
|
|
? `<span style="font-size:12px;background:#D1FAE5;color:#065F46;padding:3px 12px;border-radius:10px;font-weight:700;">✅ 승인완료 · ${repair.approved_by_name||''}</span>`
|
|
: repair.re_dispatch_requested
|
|
? `<span style="font-size:12px;background:#FEF3C7;color:#92400E;padding:3px 12px;border-radius:10px;font-weight:700;">🔁 재조치 요청됨 · ${Auth.fmtDt(repair.re_dispatch_requested_at)}</span>`
|
|
: `<button onclick="toggleApprovePanel()" id="approvePanelBtn" style="padding:5px 14px;border:none;border-radius:7px;background:var(--green);color:white;font-size:12px;font-weight:700;cursor:pointer;">✅ 조치 승인</button>`
|
|
}
|
|
${!repair.approved_at && !repair.re_dispatch_requested
|
|
? `<button onclick="requestRedispatch(${repair.id})" style="padding:5px 14px;border:none;border-radius:7px;background:#F59E0B;color:white;font-size:12px;font-weight:700;cursor:pointer;">🔁 재조치 요청</button>`
|
|
: ''}
|
|
<button onclick="cancelRepair(${repair.id}, ${!!repair.approved_at})" style="padding:5px 14px;border:none;border-radius:7px;background:#FEE2E2;color:#991B1B;font-size:12px;font-weight:700;cursor:pointer;">🔄 조치취소</button>
|
|
</div>` : ''}
|
|
</div>
|
|
${repair ? `
|
|
<table class="no-hover" style="font-size:13px;">
|
|
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${repair.mechanic_name || '-'}</td></tr>
|
|
<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>
|
|
${repair.re_dispatch_requested ? `
|
|
<tr><td style="color:var(--gray4)">재조치요청</td><td style="color:#92400E;font-weight:700;">🔁 재조치 요청됨 (${Auth.fmtDt(repair.re_dispatch_requested_at)})</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>
|
|
${renderLocationMap(repair)}
|
|
|
|
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
|
|
repair.linked_improvements && repair.linked_improvements.length ? `
|
|
<div style="margin-top:14px;padding:12px 14px;background:#EDE9FE;border-radius:8px;">
|
|
<div style="font-size:12px;font-weight:700;color:#5B21B6;margin-bottom:8px;">🔧 연결된 개선항목</div>
|
|
${repair.linked_improvements.map(i => `
|
|
<a href="/pages/admin/improvement-detail.html?id=${i.id}"
|
|
style="display:flex;align-items:center;gap:8px;font-size:13px;color:#5B21B6;text-decoration:none;padding:4px 0;">
|
|
<span style="background:#DDD6FE;border-radius:4px;padding:1px 7px;font-size:11px;">${IMP_CAT_LABEL[i.category]||i.category}</span>
|
|
<strong>#${i.id}</strong> ${i.title}
|
|
</a>`).join('')}
|
|
</div>` : ''}
|
|
|
|
${/* ── 승인 패널 (미승인 시) ── */
|
|
!repair.approved_at ? `
|
|
<div id="approvePanel" style="display:none;border-top:1px dashed var(--gray3);margin-top:16px;padding-top:16px;">
|
|
<div style="font-size:14px;font-weight:700;color:var(--navy);margin-bottom:14px;">✅ 조치 승인 — 개선항목 연결</div>
|
|
|
|
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:14px;">
|
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
|
<input type="radio" name="impAction" value="none" checked onchange="updateImpSection()">
|
|
<span>개선항목 연결 안 함</span>
|
|
</label>
|
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
|
<input type="radio" name="impAction" value="link" onchange="updateImpSection()">
|
|
<span>기존 개선항목에 연결 — <span style="color:var(--gray4);font-size:12px;">이전에 등록된 항목 선택</span></span>
|
|
</label>
|
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
|
<input type="radio" name="impAction" value="create" onchange="updateImpSection()">
|
|
<span>신규 개선항목 생성 — <span style="color:var(--gray4);font-size:12px;">이번 조치 기반으로 새 항목 등록</span></span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- 기존 항목 연결 -->
|
|
<div id="impLinkSection" style="display:none;margin-bottom:14px;">
|
|
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;">개선항목 검색</div>
|
|
<input type="text" id="impSearch" placeholder="제목으로 검색..."
|
|
style="width:100%;padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;margin-bottom:6px;"
|
|
oninput="filterImpOptions()">
|
|
<select id="impSelect" size="5"
|
|
style="width:100%;border:1px solid var(--gray3);border-radius:6px;font-size:13px;padding:4px;">
|
|
${improvements.length
|
|
? improvements.map(i =>
|
|
`<option value="${i.id}">[${IMP_CAT_LABEL[i.category]||i.category}] #${i.id} ${i.title}</option>`
|
|
).join('')
|
|
: '<option disabled>등록된 개선항목이 없습니다</option>'}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 신규 항목 생성 -->
|
|
<div id="impCreateSection" style="display:none;margin-bottom:14px;padding:14px;background:var(--gray1);border-radius:8px;">
|
|
<div class="form-group" style="margin-bottom:10px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">제목 <span class="req">*</span></label>
|
|
<input type="text" id="impTitle" placeholder="개선항목 제목">
|
|
</div>
|
|
<div class="form-row" style="margin-bottom:10px">
|
|
<div class="form-group">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">분류 <span class="req">*</span></label>
|
|
<select id="impCategory">
|
|
<option value="">선택</option>
|
|
<option value="hardware">하드웨어</option>
|
|
<option value="software">소프트웨어</option>
|
|
<option value="firmware">펌웨어</option>
|
|
<option value="installation">설치환경</option>
|
|
<option value="other">기타</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">우선순위</label>
|
|
<select id="impPriority">
|
|
<option value="low">낮음</option>
|
|
<option value="normal" selected>보통</option>
|
|
<option value="high">높음</option>
|
|
<option value="critical">긴급</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:10px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">내용 <span class="req">*</span></label>
|
|
<textarea id="impDesc" rows="3" placeholder="개선이 필요한 내용을 구체적으로 기술해 주세요."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">담당 제조사</label>
|
|
<select id="impMfr">
|
|
<option value="">미지정 (나중에 설정)</option>
|
|
${manufacturers.map(m =>
|
|
`<option value="${m.id}">${m.company ? m.company+' / ' : ''}${m.name}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="approveErr" class="alert alert-danger" style="display:none;margin-bottom:10px;"></div>
|
|
<div style="display:flex;gap:8px;">
|
|
<button class="btn btn-success btn-sm" onclick="doApproveRepair(${repair.id})">✅ 승인 완료</button>
|
|
<button class="btn btn-outline btn-sm" onclick="toggleApprovePanel()">취소</button>
|
|
</div>
|
|
</div>` : ''}
|
|
` : '<div class="alert alert-info">아직 정비사가 조치를 입력하지 않았습니다.</div>'}
|
|
</div>
|
|
</div>
|
|
|
|
${prevRepairs.length ? `
|
|
<div class="card">
|
|
<div class="card-title">📋 이전 조치 이력 (재조치 전 기록 ${prevRepairs.length}건)</div>
|
|
${prevRepairs.map((pr, idx) => `
|
|
<details style="margin-bottom:10px;border:1px solid var(--gray2);border-radius:8px;overflow:hidden;">
|
|
<summary style="padding:10px 14px;cursor:pointer;font-size:13px;font-weight:700;color:var(--navy2);background:var(--gray1);list-style:none;display:flex;justify-content:space-between;align-items:center;">
|
|
<span>#${pr.id} · ${pr.mechanic_name||'?'} · ${Auth.fmtDt(pr.completed_at)}</span>
|
|
<span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:2px 8px;border-radius:8px;">🔁 재조치 요청됨</span>
|
|
</summary>
|
|
<div style="padding:12px 14px;font-size:13px;">
|
|
<table class="no-hover" style="font-size:13px;">
|
|
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${pr.mechanic_name||'-'} (${pr.mechanic_company||'-'})</td></tr>
|
|
<tr><td style="color:var(--gray4)">조치유형</td><td>${(pr.repair_types||[]).join(', ')||'-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">조치내용</td><td>${pr.description||'-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(pr.completed_at)}</td></tr>
|
|
<tr><td style="color:var(--gray4)">처리결과</td><td>${Auth.statusBadge(pr.result_status)}</td></tr>
|
|
<tr><td style="color:var(--gray4)">재조치요청</td><td style="color:#92400E;font-weight:700;">${Auth.fmtDt(pr.re_dispatch_requested_at)}</td></tr>
|
|
</table>
|
|
${(pr.photos_before||[]).length || (pr.photos_after||[]).length ? `
|
|
<div style="margin-top:10px;">
|
|
${(pr.photos_before||[]).length ? `<div style="font-size:11px;font-weight:700;color:var(--gray4);margin-bottom:4px;">조치 전</div>
|
|
<div class="photo-preview">${(pr.photos_before||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
|
|
${(pr.photos_after||[]).length ? `<div style="font-size:11px;font-weight:700;color:var(--gray4);margin:6px 0 4px;">조치 후</div>
|
|
<div class="photo-preview">${(pr.photos_after||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
|
|
</div>` : ''}
|
|
</div>
|
|
</details>`).join('')}
|
|
</div>` : ''}
|
|
|
|
${costHtml}
|
|
`;
|
|
|
|
// 신고 편집 폼 사진 압축 설정
|
|
if (document.getElementById('rEditPhoto')) {
|
|
ImageCompressor.setupPreview('rEditPhoto', 'rEditPhotoPreview', 'rEditPhotoInfo');
|
|
}
|
|
|
|
// 지도 초기화 (수리 정보가 있을 때만)
|
|
if (repair) initRepairMap(repair);
|
|
|
|
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
|
|
if (!editOpen) return;
|
|
const wrap = document.getElementById('costEditWrap');
|
|
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
|
|
}
|
|
|
|
/* ── 방문 위치 지도 ── */
|
|
function renderLocationMap(repair) {
|
|
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
|
|
const cLat = repair.charger_lat, cLng = repair.charger_lng;
|
|
if (!mLat && !cLat) return '';
|
|
|
|
let distHtml = '';
|
|
if (mLat && cLat) {
|
|
const d = haversineM(mLat, mLng, cLat, cLng);
|
|
const within = d <= 200;
|
|
distHtml = `
|
|
<div style="display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:8px;">
|
|
<span style="padding:3px 10px;border-radius:12px;font-weight:700;
|
|
background:${within ? '#D1FAE5' : '#FEE2E2'};color:${within ? '#065F46' : '#991B1B'}">
|
|
${within ? '✅ 현장 방문 확인' : '⚠️ 현장 거리 초과'}
|
|
</span>
|
|
<span style="color:var(--gray4)">충전기와의 거리: <strong>${d < 1000 ? Math.round(d)+'m' : (d/1000).toFixed(1)+'km'}</strong></span>
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<div style="margin-top:16px">
|
|
<label style="font-size:12px;font-weight:700;color:var(--navy2);display:block;margin-bottom:6px">📍 조치 위치</label>
|
|
${distHtml}
|
|
<div id="repairMap" style="height:220px;border-radius:8px;border:1px solid var(--gray3);overflow:hidden"></div>
|
|
<div style="display:flex;gap:16px;font-size:11px;color:var(--gray4);margin-top:5px;">
|
|
${mLat ? '<span>🔵 정비사 위치</span>' : ''}
|
|
${cLat ? '<span>🔴 충전기 등록 위치</span>' : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function initRepairMap(repair) {
|
|
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
|
|
const cLat = repair.charger_lat, cLng = repair.charger_lng;
|
|
if (!document.getElementById('repairMap')) return;
|
|
|
|
const center = mLat ? [mLat, mLng] : [cLat, cLng];
|
|
const map = L.map('repairMap', { zoomControl: true, scrollWheelZoom: false })
|
|
.setView(center, 16);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
const bounds = [];
|
|
|
|
if (mLat) {
|
|
const icon = L.divIcon({
|
|
html: '<div style="width:14px;height:14px;border-radius:50%;background:#2563EB;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
|
|
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
|
|
});
|
|
L.marker([mLat, mLng], { icon })
|
|
.addTo(map)
|
|
.bindPopup('<b>정비사 위치</b><br>조치 제출 시점 기록')
|
|
.openPopup();
|
|
bounds.push([mLat, mLng]);
|
|
}
|
|
|
|
if (cLat) {
|
|
const icon = L.divIcon({
|
|
html: '<div style="width:14px;height:14px;border-radius:50%;background:#DC2626;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
|
|
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
|
|
});
|
|
L.marker([cLat, cLng], { icon })
|
|
.addTo(map)
|
|
.bindPopup('<b>충전기 등록 위치</b>');
|
|
bounds.push([cLat, cLng]);
|
|
}
|
|
|
|
if (mLat && cLat) {
|
|
L.polyline([[mLat, mLng], [cLat, cLng]], {
|
|
color: '#6366F1', weight: 2, dashArray: '5 5', opacity: 0.7
|
|
}).addTo(map);
|
|
map.fitBounds(bounds, { padding: [30, 30] });
|
|
}
|
|
}
|
|
|
|
function haversineM(lat1, lng1, lat2, lng2) {
|
|
const R = 6371000;
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
|
const a = Math.sin(dLat/2)**2 +
|
|
Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
}
|
|
|
|
function escHtmlDetail(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function readOcppFileEdit(input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
document.getElementById('rEditOcppFileName').textContent = file.name;
|
|
const reader = new FileReader();
|
|
reader.onload = e => { document.getElementById('rEditOcppLog').value = e.target.result; };
|
|
reader.readAsText(file, 'UTF-8');
|
|
}
|
|
|
|
async function deleteReportPhoto(reportId, photoId) {
|
|
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
|
|
try {
|
|
await API.delete(`/reports/${reportId}/photos/${photoId}`);
|
|
load();
|
|
} catch(e) { alert(e.message); }
|
|
}
|
|
|
|
function toggleReportEdit() {
|
|
const view = document.getElementById('reportView');
|
|
const edit = document.getElementById('reportEdit');
|
|
const btn = document.getElementById('reportEditBtn');
|
|
const isEditing = edit.classList.contains('active');
|
|
if (isEditing) {
|
|
edit.classList.remove('active');
|
|
view.classList.remove('hidden');
|
|
btn.innerHTML = '✏️ 내용 수정';
|
|
} else {
|
|
view.classList.add('hidden');
|
|
edit.classList.add('active');
|
|
btn.innerHTML = '✕ 취소';
|
|
}
|
|
}
|
|
|
|
async function saveReport(reportId) {
|
|
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
|
|
if (!issues.length) {
|
|
const err = document.getElementById('rEditErr');
|
|
err.textContent = '문제 유형을 1개 이상 선택해 주세요.';
|
|
err.style.display = 'block';
|
|
return;
|
|
}
|
|
document.getElementById('rEditErr').style.display = 'none';
|
|
const fd = new FormData();
|
|
fd.append('issue_types', JSON.stringify(issues));
|
|
fd.append('issue_detail', document.getElementById('rEditDetail').value);
|
|
fd.append('error_code', document.getElementById('rEditErrorCode').value);
|
|
fd.append('contact', document.getElementById('rEditContact').value);
|
|
fd.append('occurred_at', document.getElementById('rEditOccurred').value);
|
|
fd.append('status', document.getElementById('rEditStatus').value);
|
|
fd.append('ocpp_log', document.getElementById('rEditOcppLog').value);
|
|
const newPhotos = document.getElementById('rEditPhoto')?.files || [];
|
|
Array.from(newPhotos).forEach(f => fd.append('photos', f));
|
|
try {
|
|
await API.patch(`/reports/${reportId}`, fd);
|
|
load();
|
|
} catch(e) {
|
|
const err = document.getElementById('rEditErr');
|
|
err.textContent = e.message;
|
|
err.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function toggleParty() {
|
|
const v = document.getElementById('partyType').value;
|
|
document.getElementById('mfrWrap').style.display = v === 'manufacturer' ? 'block' : 'none';
|
|
document.getElementById('customWrap').style.display = v === 'other' ? 'block' : 'none';
|
|
}
|
|
|
|
function toggleApprovePanel() {
|
|
const panel = document.getElementById('approvePanel');
|
|
const btn = document.getElementById('approvePanelBtn');
|
|
if (!panel) return;
|
|
const opening = panel.style.display === 'none';
|
|
panel.style.display = opening ? 'block' : 'none';
|
|
if (btn) btn.textContent = opening ? '✕ 취소' : '✅ 조치 승인';
|
|
}
|
|
|
|
function updateImpSection() {
|
|
const action = document.querySelector('input[name="impAction"]:checked')?.value;
|
|
document.getElementById('impLinkSection').style.display = action === 'link' ? 'block' : 'none';
|
|
document.getElementById('impCreateSection').style.display = action === 'create' ? 'block' : 'none';
|
|
}
|
|
|
|
function filterImpOptions() {
|
|
const q = document.getElementById('impSearch').value.toLowerCase();
|
|
[...document.getElementById('impSelect').options].forEach(opt => {
|
|
opt.hidden = q && !opt.text.toLowerCase().includes(q);
|
|
});
|
|
}
|
|
|
|
async function doApproveRepair(repairId) {
|
|
const action = document.querySelector('input[name="impAction"]:checked')?.value || 'none';
|
|
|
|
const fd = new FormData();
|
|
fd.append('improvement_action', action);
|
|
|
|
if (action === 'link') {
|
|
const sel = document.getElementById('impSelect');
|
|
const impId = sel?.value;
|
|
if (!impId) { showApproveErr('연결할 개선항목을 선택해 주세요.'); return; }
|
|
fd.append('improvement_id', impId);
|
|
} else if (action === 'create') {
|
|
const title = document.getElementById('impTitle').value.trim();
|
|
const cat = document.getElementById('impCategory').value;
|
|
const desc = document.getElementById('impDesc').value.trim();
|
|
if (!title || !cat || !desc) { showApproveErr('제목, 분류, 내용을 모두 입력해 주세요.'); return; }
|
|
fd.append('imp_title', title);
|
|
fd.append('imp_category', cat);
|
|
fd.append('imp_description', desc);
|
|
fd.append('imp_priority', document.getElementById('impPriority').value);
|
|
const mfr = document.getElementById('impMfr').value;
|
|
if (mfr) fd.append('imp_manufacturer_id', mfr);
|
|
}
|
|
|
|
if (!confirm('이 조치 내역을 승인하시겠습니까?\n승인 후에는 정비사가 수정할 수 없습니다.')) return;
|
|
|
|
try {
|
|
const res = await API.post(`/repairs/${repairId}/approve`, fd);
|
|
let msg = '✅ 조치가 승인되었습니다.';
|
|
if (res.improvement_id) {
|
|
msg += `\n개선항목 #${res.improvement_id}에 연결되었습니다.`;
|
|
}
|
|
alert(msg);
|
|
load();
|
|
} catch(e) { showApproveErr(e.message); }
|
|
}
|
|
|
|
function showApproveErr(msg) {
|
|
const el = document.getElementById('approveErr');
|
|
if (!el) return;
|
|
el.textContent = msg;
|
|
el.style.display = 'block';
|
|
}
|
|
|
|
async function cancelRepair(repairId, isApproved) {
|
|
const msg = isApproved
|
|
? '⚠️ 승인된 조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?'
|
|
: '조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?';
|
|
if (!confirm(msg)) return;
|
|
try {
|
|
await API.delete('/repairs/' + repairId);
|
|
load();
|
|
} catch(e) { alert('조치취소 오류: ' + e.message); }
|
|
}
|
|
|
|
async function requestRedispatch(repairId) {
|
|
if (!confirm('재조치를 요청합니다.\n\n기존 조치 기록은 유지되며,\n연결된 신고가 정비사 목록에 다시 표시됩니다.\n\n계속하시겠습니까?')) return;
|
|
try {
|
|
await API.post('/repairs/' + repairId + '/re-dispatch', new FormData());
|
|
load();
|
|
} catch(e) { alert('재조치 요청 오류: ' + e.message); }
|
|
}
|
|
|
|
async function approveReport(id) {
|
|
if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return;
|
|
await API.patch(`/reports/${id}/approve`);
|
|
alert('승인되었습니다.');
|
|
load();
|
|
}
|
|
|
|
function toggleClosePanel() {
|
|
const panel = document.getElementById('closurePanel');
|
|
if (!panel) return;
|
|
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
|
}
|
|
|
|
async function submitClose(id) {
|
|
const selected = document.querySelector('input[name="closureType"]:checked');
|
|
if (!selected) { alert('상황종료 사유를 선택해 주세요.'); return; }
|
|
const note = document.getElementById('closureNote')?.value || '';
|
|
if (selected.value === 'other' && !note.trim()) { alert('기타 사유를 입력해 주세요.'); return; }
|
|
if (!confirm('상황종료 처리하시겠습니까?\n정비사 조치 없이 신고를 종결합니다.')) return;
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('closure_type', selected.value);
|
|
fd.append('closure_note', note);
|
|
await API.patch(`/reports/${id}/close`, fd);
|
|
load();
|
|
} catch(e) { alert('오류: ' + e.message); }
|
|
}
|
|
|
|
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>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|