403 lines
20 KiB
HTML
403 lines
20 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">
|
|
<style>
|
|
.upload-area{border:2px dashed var(--gray3);border-radius:8px;padding:12px;text-align:center;cursor:pointer;color:var(--gray4);font-size:12px;transition:border-color .15s;margin-bottom:6px;}
|
|
.upload-area:hover{border-color:var(--accent);}
|
|
.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);}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="nav">
|
|
<span class="nav-brand">⚡ EV AS 관리</span>
|
|
<div id="navUser"></div>
|
|
</nav>
|
|
<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-section">메뉴</div>
|
|
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
|
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
|
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
|
</div>
|
|
<div class="main">
|
|
<div style="max-width:640px;margin:0 auto;">
|
|
<div style="margin-bottom:14px;">
|
|
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
|
|
</div>
|
|
<div id="chargerCard" class="card"></div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">📋 동일 충전기 신고 목록 (중복 선택 가능)</div>
|
|
<div id="reportList"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">🔧 조치 내역 입력</div>
|
|
|
|
<div class="form-group">
|
|
<label>조치 유형 <span class="req">*</span></label>
|
|
<div class="check-group" id="repairTypes">
|
|
<div style="color:var(--gray4);font-size:12px">불러오는 중...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>조치 상세 내용 <span class="req">*</span></label>
|
|
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
|
|
</div>
|
|
|
|
<!-- 사진 안내 -->
|
|
<div style="background:#FFF8E6;border:1px solid #FFD600;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;line-height:1.7;">
|
|
📌 <strong>촬영 필수 항목</strong><br>
|
|
· 충전기 <strong>명판(제조사·모델명)</strong> 및 <strong>충전기 식별 ID</strong>가 선명하게 보이도록 촬영해 주세요.<br>
|
|
· 조치 전·후 상태를 각각 촬영하면 검증에 도움이 됩니다.
|
|
</div>
|
|
|
|
<!-- 조치 전 사진 -->
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
|
<label class="upload-area" for="photosBeforeCamera" style="margin:0">📷 카메라 촬영</label>
|
|
<label class="upload-area" for="photosBefore" style="margin:0">🖼 갤러리 선택</label>
|
|
</div>
|
|
<input type="file" id="photosBeforeCamera" accept="image/*" capture="environment" style="display:none">
|
|
<input type="file" id="photosBefore" accept="image/*" multiple style="display:none">
|
|
<div class="photo-preview" id="previewBefore"></div>
|
|
<div class="photo-info" id="infoBefore"></div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
|
<label class="upload-area" for="photosAfterCamera" style="margin:0">📷 카메라 촬영</label>
|
|
<label class="upload-area" for="photosAfter" style="margin:0">🖼 갤러리 선택</label>
|
|
</div>
|
|
<input type="file" id="photosAfterCamera" accept="image/*" capture="environment" style="display:none">
|
|
<input type="file" id="photosAfter" accept="image/*" multiple style="display:none">
|
|
<div class="photo-preview" id="previewAfter"></div>
|
|
<div class="photo-info" id="infoAfter"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row" style="margin-bottom:14px;">
|
|
<div class="form-group" style="margin-bottom:0">
|
|
<label>🕐 조치 시작 시각 <span style="font-size:11px;color:var(--gray4);font-weight:400">(직접 수정 가능)</span></label>
|
|
<input type="datetime-local" id="startedAt" style="width:100%">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0">
|
|
<label>🏁 조치 완료 시각 <span style="font-size:11px;color:var(--gray4);font-weight:400">(직접 수정 가능)</span></label>
|
|
<input type="datetime-local" id="completedAt" style="width:100%">
|
|
</div>
|
|
</div>
|
|
|
|
<div id="gpsStatus" class="alert alert-info" style="margin-bottom:14px;">
|
|
📍 위치 정보 수집 중...
|
|
</div>
|
|
<input type="hidden" id="mechanicLat">
|
|
<input type="hidden" id="mechanicLng">
|
|
|
|
<div id="formErr" class="alert alert-danger" style="display:none"></div>
|
|
|
|
<!-- 저장 버튼 영역 -->
|
|
<div style="background:var(--gray1);border:1px solid var(--gray2);border-radius:10px;padding:16px;margin-top:4px;">
|
|
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:10px;">💾 저장 방식 선택</div>
|
|
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
|
<button class="btn btn-outline btn-lg" id="saveBtn" style="flex:1;min-width:140px;" onclick="submitForm(false)">
|
|
💾 상태 저장
|
|
</button>
|
|
<button class="btn btn-primary btn-lg" id="doneBtn" style="flex:1;min-width:140px;" onclick="submitForm(true)">
|
|
✅ 조치 완료 저장
|
|
</button>
|
|
</div>
|
|
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;">
|
|
<div style="flex:1;min-width:140px;">
|
|
<label style="font-size:11px;color:var(--gray4)">저장 상태 선택</label>
|
|
<select id="resultStatus" style="width:100%;margin-top:4px;font-size:13px;">
|
|
<option value="in_progress">🔧 계속 진행 중</option>
|
|
<option value="waiting">⏳ 부품 대기</option>
|
|
<option value="revisit">🔄 재방문 필요</option>
|
|
</select>
|
|
</div>
|
|
<div style="flex:1;min-width:140px;display:flex;align-items:flex-end;">
|
|
<div style="font-size:11px;color:var(--gray4);padding-bottom:6px;line-height:1.6;">
|
|
✅ <strong>조치 완료 저장</strong>은 처리 완료로 확정됩니다.<br>
|
|
💾 <strong>상태 저장</strong>은 왼쪽 상태로 임시 저장됩니다.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div><!-- max-width wrapper -->
|
|
</div><!-- .main -->
|
|
</div><!-- .layout -->
|
|
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/auth.js"></script>
|
|
<script src="/js/imageCompress.js"></script>
|
|
<script>
|
|
Auth.require(['mechanic','admin']);
|
|
Auth.renderNav(document.getElementById('navUser'));
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const repairId = params.get('repair_id'); // 편집 모드
|
|
const chargerId = params.get('charger_id'); // 신규 모드
|
|
const initReportId = params.get('report_id');
|
|
const isEditMode = !!repairId;
|
|
|
|
// datetime-local 입력값 포맷: "YYYY-MM-DDTHH:mm"
|
|
function toLocalDtInput(date) {
|
|
const d = new Date(date);
|
|
const pad = n => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
const startTime = new Date();
|
|
document.getElementById('startedAt').value = toLocalDtInput(startTime);
|
|
document.getElementById('completedAt').value = toLocalDtInput(startTime);
|
|
|
|
const selectedReports = new Set();
|
|
if (initReportId) selectedReports.add(parseInt(initReportId));
|
|
|
|
// ── 신규 모드 ──
|
|
async function loadCreate() {
|
|
const charger = await API.get('/chargers/' + chargerId);
|
|
document.getElementById('chargerCard').innerHTML = `
|
|
<div class="card-title">⚡ 충전기 정보</div>
|
|
<div class="form-row">
|
|
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${charger.id}</strong></div>
|
|
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${charger.name}</strong></div>
|
|
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${charger.station_name}</strong></div>
|
|
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name||'-'}</strong></div>
|
|
</div>`;
|
|
const reports = await API.get('/repairs/charger/' + chargerId + '/open');
|
|
const list = document.getElementById('reportList');
|
|
if (!reports.length) { list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>'; return; }
|
|
list.innerHTML = reports.map(r => `
|
|
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:${selectedReports.has(r.id)?'#E3EDFF':'white'}">
|
|
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}"
|
|
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
|
|
onchange="toggleReport(${r.id},this.checked,this.closest('label'))">
|
|
<div>
|
|
<div><strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}</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>
|
|
${r.photos.length ? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>` : ''}
|
|
</div>
|
|
</label>`).join('');
|
|
}
|
|
|
|
function toggleReport(id, checked, label) {
|
|
if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; }
|
|
else { selectedReports.delete(id); label.style.background='white'; }
|
|
}
|
|
|
|
// ── 편집 모드 ──
|
|
async function loadEdit() {
|
|
let repair;
|
|
try { repair = await API.get('/repairs/' + repairId); }
|
|
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
|
|
|
|
// 헤더 업데이트
|
|
document.querySelector('h2, .main h2') && (document.querySelector('.main > div > h2') || document.querySelector('h2'))?.remove?.();
|
|
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
|
|
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
|
|
|
|
// 충전기 카드
|
|
document.getElementById('chargerCard').innerHTML = `
|
|
<div class="card-title">⚡ 충전기 정보</div>
|
|
<div class="form-row">
|
|
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${repair.charger_id||'-'}</strong></div>
|
|
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${repair.charger_name||'-'}</strong></div>
|
|
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${repair.station_name||'-'}</strong></div>
|
|
</div>`;
|
|
|
|
// 연결된 신고 (읽기 전용)
|
|
document.getElementById('reportList').innerHTML = (repair.reports||[]).length
|
|
? (repair.reports||[]).map(r => `
|
|
<div style="padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;background:#F8FAFF;">
|
|
<strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}
|
|
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
|
|
</div>`).join('')
|
|
: '<div class="alert alert-info">연결된 신고 없음</div>';
|
|
|
|
// 승인 완료 → 잠금
|
|
if (repair.approved_at) {
|
|
const dt = new Date(repair.approved_at).toLocaleString('ko-KR');
|
|
document.querySelector('.card:last-child').innerHTML = `
|
|
<div class="alert alert-success" style="margin-bottom:0">
|
|
✅ <strong>관리자 승인 완료</strong> (${repair.approved_by_name||''} · ${dt})<br>
|
|
<span style="font-size:12px;">승인된 조치는 수정할 수 없습니다.</span>
|
|
</div>
|
|
${renderRepairView(repair)}`;
|
|
return;
|
|
}
|
|
|
|
// 폼 미리채우기 — 조치유형 동적 로드 후 체크 복원
|
|
await loadRepairTypes(repair.repair_types || []);
|
|
document.getElementById('description').value = repair.description || '';
|
|
if (repair.started_at) document.getElementById('startedAt').value = toLocalDtInput(repair.started_at);
|
|
if (repair.completed_at) document.getElementById('completedAt').value = toLocalDtInput(repair.completed_at);
|
|
const sel = document.getElementById('resultStatus');
|
|
if (repair.result_status && sel.querySelector(`option[value="${repair.result_status}"]`))
|
|
sel.value = repair.result_status;
|
|
|
|
// 기존 사진 표시
|
|
renderExistingPhotos(repair);
|
|
}
|
|
|
|
function renderRepairView(r) {
|
|
const LABEL = {done:'✅ 완료',in_progress:'🔧 진행중',waiting:'⏳ 부품대기',revisit:'🔄 재방문'};
|
|
const photoHtml = (type, list) => (list||[]).length
|
|
? `<div style="margin-top:8px"><label style="font-size:11px;font-weight:700;color:var(--navy2)">${type}</label>
|
|
<div class="photo-preview">${(list||[]).map(p=>`<img src="${p.path||p}" onclick="window.open('${p.path||p}')" style="cursor:zoom-in">`).join('')}</div></div>`
|
|
: '';
|
|
return `<div style="padding:14px 0">
|
|
<table style="font-size:13px;width:100%">
|
|
<tr><td style="color:var(--gray4);width:90px">조치유형</td><td>${(r.repair_types||[]).join(', ')}</td></tr>
|
|
<tr><td style="color:var(--gray4)">조치내용</td><td>${r.description||'-'}</td></tr>
|
|
<tr><td style="color:var(--gray4)">처리결과</td><td>${LABEL[r.result_status]||r.result_status}</td></tr>
|
|
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(r.completed_at)}</td></tr>
|
|
</table>
|
|
${photoHtml('조치 전 사진', r.photos_before)}
|
|
${photoHtml('조치 후 사진', r.photos_after)}
|
|
</div>`;
|
|
}
|
|
|
|
function renderExistingPhotos(repair) {
|
|
const mkGrid = (list, type) => {
|
|
if (!list || !list.length) return '';
|
|
return `<div style="display:flex;flex-wrap:wrap;gap:7px;margin-bottom:8px;">
|
|
${list.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="deleteRepairPhoto(${repair.id},${p.id},'${type}')"
|
|
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>`;
|
|
};
|
|
const bWrap = document.getElementById('previewBefore');
|
|
const aWrap = document.getElementById('previewAfter');
|
|
if (repair.photos_before?.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
|
|
if (repair.photos_after?.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
|
|
}
|
|
|
|
async function deleteRepairPhoto(rId, pId) {
|
|
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
|
|
try {
|
|
await API.delete(`/repairs/${rId}/photos/${pId}`);
|
|
location.reload();
|
|
} catch(e) { alert(e.message); }
|
|
}
|
|
|
|
// GPS 수집
|
|
navigator.geolocation?.getCurrentPosition(
|
|
pos => {
|
|
document.getElementById('mechanicLat').value = pos.coords.latitude;
|
|
document.getElementById('mechanicLng').value = pos.coords.longitude;
|
|
document.getElementById('gpsStatus').className = 'alert alert-success';
|
|
document.getElementById('gpsStatus').innerHTML =
|
|
`📍 위치 수집 완료 <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
|
|
},
|
|
() => {
|
|
document.getElementById('gpsStatus').className = 'alert alert-warn';
|
|
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다.';
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 10000 }
|
|
);
|
|
|
|
// 이미지 압축 + 다중 선택 프리뷰
|
|
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
|
|
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
|
|
ImageCompressor.setupCameraAppend('photosBeforeCamera', 'photosBefore');
|
|
ImageCompressor.setupCameraAppend('photosAfterCamera', 'photosAfter');
|
|
|
|
async function submitForm(isDone) {
|
|
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
|
|
if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; }
|
|
const desc = document.getElementById('description').value.trim();
|
|
if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; }
|
|
|
|
if (!isEditMode) {
|
|
const rids = [...selectedReports];
|
|
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
|
|
}
|
|
|
|
const saveBtn = document.getElementById('saveBtn');
|
|
const doneBtn = document.getElementById('doneBtn');
|
|
saveBtn.disabled = doneBtn.disabled = true;
|
|
(isDone ? doneBtn : saveBtn).textContent = '저장 중...';
|
|
|
|
const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value;
|
|
const lat = document.getElementById('mechanicLat').value;
|
|
const lng = document.getElementById('mechanicLng').value;
|
|
|
|
const fd = new FormData();
|
|
fd.append('repair_types', JSON.stringify(types));
|
|
fd.append('description', desc);
|
|
fd.append('result_status', resultStatus);
|
|
const startedAtVal = document.getElementById('startedAt').value;
|
|
const completedAtVal = document.getElementById('completedAt').value;
|
|
if (startedAtVal) fd.append('started_at_input', startedAtVal);
|
|
if (completedAtVal) fd.append('completed_at_input', completedAtVal);
|
|
if (lat) fd.append('mechanic_lat', lat);
|
|
if (lng) fd.append('mechanic_lng', lng);
|
|
Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f));
|
|
Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f));
|
|
|
|
try {
|
|
if (isEditMode) {
|
|
await API.put('/repairs/' + repairId, fd);
|
|
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
|
|
location.href = '/pages/mechanic/history.html';
|
|
} else {
|
|
fd.append('report_ids', JSON.stringify([...selectedReports]));
|
|
await API.post('/repairs', fd);
|
|
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
|
|
location.href = '/pages/mechanic/history.html';
|
|
}
|
|
} catch(e) {
|
|
showErr(e.message);
|
|
saveBtn.disabled = doneBtn.disabled = false;
|
|
saveBtn.textContent = '💾 상태 저장';
|
|
doneBtn.textContent = '✅ 조치 완료 저장';
|
|
}
|
|
}
|
|
|
|
function showErr(msg) {
|
|
const el = document.getElementById('formErr');
|
|
el.textContent = msg; el.style.display = 'block';
|
|
}
|
|
|
|
async function loadRepairTypes(preChecked = []) {
|
|
try {
|
|
const types = await API.get('/settings/repair-types');
|
|
document.getElementById('repairTypes').innerHTML = types.map(t => `
|
|
<label class="check-item">
|
|
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
|
|
${t.label}
|
|
</label>`).join('');
|
|
} catch(e) {
|
|
document.getElementById('repairTypes').innerHTML =
|
|
'<div class="alert alert-danger" style="margin:0">조치유형을 불러오지 못했습니다.</div>';
|
|
}
|
|
}
|
|
|
|
if (isEditMode) {
|
|
loadEdit();
|
|
} else {
|
|
loadRepairTypes();
|
|
loadCreate();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|