|
|
|
|
@@ -109,41 +109,18 @@
|
|
|
|
|
<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>
|
|
|
|
|
<button class="btn btn-primary btn-lg" id="doneBtn" style="width:100%;margin-top:4px;" onclick="submitForm(true)">
|
|
|
|
|
✅ 조치 완료 저장
|
|
|
|
|
</button>
|
|
|
|
|
<input type="hidden" id="resultStatus" value="done">
|
|
|
|
|
</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 src="/js/api.js?v=20260603"></script>
|
|
|
|
|
<script src="/js/auth.js?v=20260603"></script>
|
|
|
|
|
<script src="/js/imageCompress.js?v=20260603"></script>
|
|
|
|
|
<script>
|
|
|
|
|
Auth.require(['mechanic','admin']);
|
|
|
|
|
Auth.renderNav(document.getElementById('navUser'));
|
|
|
|
|
@@ -164,35 +141,62 @@ const startTime = new Date();
|
|
|
|
|
document.getElementById('startedAt').value = toLocalDtInput(startTime);
|
|
|
|
|
document.getElementById('completedAt').value = toLocalDtInput(startTime);
|
|
|
|
|
|
|
|
|
|
// 조치 시작시각 변경 시 완료시각이 시작보다 이전이면 자동 보정
|
|
|
|
|
document.getElementById('startedAt').addEventListener('change', function () {
|
|
|
|
|
const started = document.getElementById('startedAt').value;
|
|
|
|
|
const completed = document.getElementById('completedAt').value;
|
|
|
|
|
if (started && completed && completed < started) {
|
|
|
|
|
document.getElementById('completedAt').value = started;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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('');
|
|
|
|
|
try {
|
|
|
|
|
const charger = await API.get('/chargers/' + chargerId);
|
|
|
|
|
if (!charger) return; // 401 → 로그아웃 리다이렉트 진행 중
|
|
|
|
|
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 || !reports.length) {
|
|
|
|
|
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
list.innerHTML = reports.map(function(r) {
|
|
|
|
|
var bg = selectedReports.has(r.id) ? '#E3EDFF' : 'white';
|
|
|
|
|
var checked = selectedReports.has(r.id) ? 'checked' : '';
|
|
|
|
|
var photoHtml = r.photos && r.photos.length
|
|
|
|
|
? '<div class="photo-preview">' + r.photos.map(function(p) { return '<img src="' + p + '">'; }).join('') + '</div>'
|
|
|
|
|
: '';
|
|
|
|
|
return '<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:' + bg + '">' +
|
|
|
|
|
'<input type="checkbox" ' + 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) +
|
|
|
|
|
(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>' +
|
|
|
|
|
'<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('');
|
|
|
|
|
} catch(e) {
|
|
|
|
|
document.getElementById('chargerCard').innerHTML =
|
|
|
|
|
'<div class="alert alert-danger">충전기 정보를 불러오지 못했습니다.<br><small style="opacity:.8">' + e.message + '</small></div>';
|
|
|
|
|
document.getElementById('reportList').innerHTML = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleReport(id, checked, label) {
|
|
|
|
|
@@ -205,9 +209,11 @@ async function loadEdit() {
|
|
|
|
|
let repair;
|
|
|
|
|
try { repair = await API.get('/repairs/' + repairId); }
|
|
|
|
|
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
|
|
|
|
|
if (!repair) return; // 401 → 로그아웃 리다이렉트 진행 중
|
|
|
|
|
|
|
|
|
|
// 헤더 업데이트
|
|
|
|
|
document.querySelector('h2, .main h2') && (document.querySelector('.main > div > h2') || document.querySelector('h2'))?.remove?.();
|
|
|
|
|
var h2el = document.querySelector('.main > div > h2') || document.querySelector('h2');
|
|
|
|
|
if (h2el) h2el.parentNode.removeChild(h2el);
|
|
|
|
|
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
|
|
|
|
|
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
|
|
|
|
|
|
|
|
|
|
@@ -246,15 +252,6 @@ async function loadEdit() {
|
|
|
|
|
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 === 'done') {
|
|
|
|
|
const opt = document.createElement('option');
|
|
|
|
|
opt.value = 'done'; opt.textContent = '✅ 완료';
|
|
|
|
|
sel.insertBefore(opt, sel.firstChild);
|
|
|
|
|
sel.value = 'done';
|
|
|
|
|
} else if (sel.querySelector(`option[value="${repair.result_status}"]`)) {
|
|
|
|
|
sel.value = repair.result_status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기존 사진 표시
|
|
|
|
|
renderExistingPhotos(repair);
|
|
|
|
|
@@ -292,8 +289,8 @@ function renderExistingPhotos(repair) {
|
|
|
|
|
};
|
|
|
|
|
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'));
|
|
|
|
|
if (repair.photos_before && repair.photos_before.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
|
|
|
|
|
if (repair.photos_after && repair.photos_after.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteRepairPhoto(rId, pId) {
|
|
|
|
|
@@ -304,21 +301,47 @@ async function deleteRepairPhoto(rId, pId) {
|
|
|
|
|
} catch(e) { alert(e.message); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GPS 수집
|
|
|
|
|
navigator.geolocation?.getCurrentPosition(
|
|
|
|
|
pos => {
|
|
|
|
|
// GPS 수집 — 1단계: 저정밀(WiFi/셀) 즉시, 2단계: 고정밀(GPS) 백그라운드
|
|
|
|
|
(function acquireGPS() {
|
|
|
|
|
if (!navigator.geolocation) {
|
|
|
|
|
document.getElementById('gpsStatus').className = 'alert alert-warn';
|
|
|
|
|
document.getElementById('gpsStatus').textContent = '⚠️ 이 기기는 위치 정보를 지원하지 않습니다.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
function applyPos(pos, label) {
|
|
|
|
|
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>`;
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
`📍 위치 수집 완료${label} <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
|
|
|
|
|
}
|
|
|
|
|
function failGPS() {
|
|
|
|
|
if (document.getElementById('mechanicLat').value) return; // 이미 성공
|
|
|
|
|
document.getElementById('gpsStatus').className = 'alert alert-warn';
|
|
|
|
|
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다.';
|
|
|
|
|
},
|
|
|
|
|
{ enableHighAccuracy: true, timeout: 10000 }
|
|
|
|
|
);
|
|
|
|
|
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다. (저장은 가능)';
|
|
|
|
|
}
|
|
|
|
|
// 1단계: 캐시 허용 + 저정밀 → 5초 내 응답 (WiFi/셀 기반, 실내에서도 동작)
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
|
|
pos => {
|
|
|
|
|
applyPos(pos, '');
|
|
|
|
|
// 2단계: 고정밀 GPS 백그라운드로 시도해 더 정확한 좌표로 업데이트
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
|
|
pos2 => applyPos(pos2, ' (고정밀)'),
|
|
|
|
|
() => {}, // 실패해도 1단계 좌표 유지
|
|
|
|
|
{ enableHighAccuracy: true, timeout: 30000 }
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
// 저정밀도 실패 시 고정밀 한 번 더 시도
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
|
|
pos => applyPos(pos, ''),
|
|
|
|
|
failGPS,
|
|
|
|
|
{ enableHighAccuracy: true, timeout: 30000 }
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
|
|
|
|
|
);
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
// 이미지 압축 + 다중 선택 프리뷰
|
|
|
|
|
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
|
|
|
|
|
@@ -337,12 +360,11 @@ async function submitForm(isDone) {
|
|
|
|
|
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 = '저장 중...';
|
|
|
|
|
doneBtn.disabled = true;
|
|
|
|
|
doneBtn.textContent = '저장 중...';
|
|
|
|
|
|
|
|
|
|
const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value;
|
|
|
|
|
const resultStatus = 'done';
|
|
|
|
|
const lat = document.getElementById('mechanicLat').value;
|
|
|
|
|
const lng = document.getElementById('mechanicLng').value;
|
|
|
|
|
|
|
|
|
|
@@ -362,18 +384,17 @@ async function submitForm(isDone) {
|
|
|
|
|
try {
|
|
|
|
|
if (isEditMode) {
|
|
|
|
|
await API.put('/repairs/' + repairId, fd);
|
|
|
|
|
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
|
|
|
|
|
alert('✅ 조치 완료로 저장되었습니다.');
|
|
|
|
|
location.href = '/pages/mechanic/history.html';
|
|
|
|
|
} else {
|
|
|
|
|
fd.append('report_ids', JSON.stringify([...selectedReports]));
|
|
|
|
|
await API.post('/repairs', fd);
|
|
|
|
|
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
|
|
|
|
|
alert('✅ 조치 완료로 저장되었습니다.');
|
|
|
|
|
location.href = '/pages/mechanic/history.html';
|
|
|
|
|
}
|
|
|
|
|
} catch(e) {
|
|
|
|
|
showErr(e.message);
|
|
|
|
|
saveBtn.disabled = doneBtn.disabled = false;
|
|
|
|
|
saveBtn.textContent = '💾 상태 저장';
|
|
|
|
|
doneBtn.disabled = false;
|
|
|
|
|
doneBtn.textContent = '✅ 조치 완료 저장';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -383,18 +404,39 @@ function showErr(msg) {
|
|
|
|
|
el.textContent = msg; el.style.display = 'block';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_REPAIR_TYPES = [
|
|
|
|
|
{key:'부품교체',label:'🔩 부품 교체'},
|
|
|
|
|
{key:'재시작', label:'🔄 재시작'},
|
|
|
|
|
{key:'설정변경',label:'⚙️ 설정 변경'},
|
|
|
|
|
{key:'청소', label:'🧹 청소'},
|
|
|
|
|
{key:'배선정리',label:'🔌 배선 정리'},
|
|
|
|
|
{key:'펌웨어', label:'💾 펌웨어 업데이트'},
|
|
|
|
|
{key:'기타', label:'📋 기타'},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function renderRepairTypeList(types, preChecked) {
|
|
|
|
|
const el = document.getElementById('repairTypes');
|
|
|
|
|
if (!el) return;
|
|
|
|
|
el.innerHTML = types.map(t => `
|
|
|
|
|
<label class="check-item">
|
|
|
|
|
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
|
|
|
|
|
${t.label}
|
|
|
|
|
</label>`).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadRepairTypes(preChecked = []) {
|
|
|
|
|
// 기본값 즉시 표시 — 네트워크 대기 없이 바로 사용 가능
|
|
|
|
|
renderRepairTypeList(DEFAULT_REPAIR_TYPES, preChecked);
|
|
|
|
|
// API에서 커스텀 유형 로드해 덮어쓰기 (실패해도 기본값 유지)
|
|
|
|
|
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>';
|
|
|
|
|
}
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const tid = setTimeout(() => controller.abort(), 8000);
|
|
|
|
|
const res = await fetch('/api/settings/repair-types', { signal: controller.signal });
|
|
|
|
|
clearTimeout(tid);
|
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
const types = await res.json();
|
|
|
|
|
if (Array.isArray(types) && types.length) renderRepairTypeList(types, preChecked);
|
|
|
|
|
} catch(_) { /* 기본값 유지 */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isEditMode) {
|
|
|
|
|
|