초기 커밋 - EV AS 관리 시스템

This commit is contained in:
root
2026-04-18 06:18:58 +09:00
commit 7a5c397983
52 changed files with 6044 additions and 0 deletions

View File

@@ -0,0 +1,522 @@
<!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>
body { background: var(--gray1); }
.report-wrap { max-width: 480px; margin: 0 auto; padding: 20px 16px 40px; }
/* ── 충전기 정보 헤더 ── */
.charger-info {
background: var(--navy);
color: white;
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 14px;
}
.charger-info h2 { font-size: 16px; margin-bottom: 8px; color: var(--accent); }
.charger-info .row {
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255,255,255,.75); margin-top: 4px;
}
/* ── 현황 섹션 ── */
.status-section {
border-radius: 10px;
overflow: hidden;
margin-bottom: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.status-header {
background: #1A2B4A;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.status-header-left { display: flex; align-items: center; gap: 8px; }
.status-header h3 { font-size: 14px; font-weight: 700; color: white; }
.status-badge-count {
background: var(--orange);
color: white;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
}
.status-toggle-icon { font-size: 12px; color: rgba(255,255,255,.5); transition: transform .2s; }
.status-toggle-icon.open { transform: rotate(180deg); }
.status-body { background: white; }
/* ── 개별 신고 현황 카드 ── */
.report-status-card {
padding: 14px 16px;
border-bottom: 1px solid var(--gray2);
position: relative;
}
.report-status-card:last-child { border-bottom: none; }
.rsc-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.rsc-num { font-size: 12px; font-weight: 700; color: var(--navy2); }
.rsc-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
}
.rsc-badge.pending_approval { background: #FFF3CD; color: #856404; }
.rsc-badge.pending { background: #DBEAFE; color: #1565C0; }
.rsc-badge.in_progress { background: #FEF3C7; color: #B45309; }
.rsc-badge.waiting { background: #FFE4E4; color: #C0392B; }
.rsc-badge.revisit { background: #EDE9FE; color: #5B21B6; }
.rsc-issues {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 8px;
}
.rsc-issue-tag {
background: var(--gray1);
color: var(--text2);
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--gray2);
}
.rsc-meta {
font-size: 11px;
color: var(--gray4);
display: flex;
flex-direction: column;
gap: 3px;
}
.rsc-mechanic {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--green);
font-weight: 600;
margin-top: 6px;
padding: 6px 10px;
background: #E8F8F2;
border-radius: 6px;
}
.rsc-progress-bar {
height: 4px;
background: var(--gray2);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.rsc-progress-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width .4s;
}
/* ── 접기/펼치기 버튼 ── */
.collapse-body { overflow: hidden; transition: max-height .3s ease; }
.collapse-body.collapsed { max-height: 0 !important; }
/* ── 신규 신고 폼 ── */
.section {
background: white;
border-radius: 10px;
padding: 18px;
margin-bottom: 14px;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.section h3 {
font-size: 14px; font-weight: 700; color: var(--navy);
border-left: 3px solid var(--accent); padding-left: 9px; margin-bottom: 12px;
}
.issue-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.issue-btn {
padding: 10px; border: 1px solid var(--gray3); border-radius: 7px;
background: white; cursor: pointer; font-size: 13px; text-align: center;
transition: all .15s;
}
.issue-btn.sel { background: #E3EDFF; border-color: var(--accent); font-weight: 700; color: var(--blue); }
.upload-area {
border: 2px dashed var(--gray3); border-radius: 8px; padding: 14px;
text-align: center; cursor: pointer; color: var(--gray4); font-size: 13px;
transition: border-color .15s; margin-bottom: 6px; display: block;
}
.upload-area:hover { border-color: var(--accent); }
.photo-preview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.photo-preview img {
width: 80px; height: 80px; object-fit: cover;
border-radius: 6px; border: 1px solid var(--gray3);
}
.photo-info { font-size: 11px; margin-top: 4px; min-height: 16px; }
/* ── 결과 화면 ── */
#resultBox {
background: var(--navy); color: white;
border-radius: 10px; padding: 24px; text-align: center; display: none;
}
#resultBox h2 { color: var(--green); font-size: 20px; margin-bottom: 10px; }
</style>
</head>
<body>
<div class="report-wrap">
<!-- 충전기 정보 -->
<div id="chargerInfo" class="charger-info">
<h2>⚡ 충전기 정보 로딩 중...</h2>
</div>
<!-- ★ 현재 접수 현황 섹션 -->
<div class="status-section" id="statusSection" style="display:none">
<div class="status-header" onclick="toggleStatus()">
<div class="status-header-left">
<span>🔔</span>
<h3>현재 접수된 신고 현황</h3>
<span class="status-badge-count" id="statusCount">0</span>
</div>
<span class="status-toggle-icon open" id="statusToggleIcon"></span>
</div>
<div class="collapse-body" id="statusBody">
<div class="status-body" id="statusList"></div>
<div style="padding:12px 16px;background:#F4F7FB;border-top:1px solid var(--gray2);">
<p style="font-size:12px;color:var(--gray4);line-height:1.7">
📌 동일 고장이 이미 접수되어 처리 중인 경우 추가 신고는 필요 없습니다.<br>
신고가 미처리 상태이거나 다른 문제가 있다면 아래에서 새로 신고해 주세요.
</p>
</div>
</div>
</div>
<!-- 신고 없음 안내 -->
<div id="noReportNotice" style="display:none;background:#E8F8F2;border:1px solid var(--green);border-radius:10px;padding:12px 16px;margin-bottom:14px;font-size:13px;color:#00531A;">
✅ 이 충전기에 현재 접수된 신고가 없습니다. 고장이 확인되면 아래에서 신고해 주세요.
</div>
<!-- 신고 폼 -->
<div id="mainForm">
<div class="section">
<h3>📍 신고 위치</h3>
<div id="gpsStatus" class="alert alert-info">위치 정보 수집 중...</div>
<input type="hidden" id="gpsLat">
<input type="hidden" id="gpsLng">
</div>
<div class="section">
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
<div class="issue-grid" id="issueGrid"></div>
<div id="errorCodeWrap" style="margin-top:10px;display:none;">
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">
</div>
<div id="etcWrap" style="margin-top:10px;display:none;">
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
</div>
</div>
<div class="section">
<h3>🕐 문제 발생 시각</h3>
<input type="datetime-local" id="occurredAt">
<div style="font-size:11px;color:var(--gray4);margin-top:4px">언제부터 문제가 발생했나요? (선택)</div>
</div>
<div class="section">
<h3>📷 사진 첨부</h3>
<div class="form-group">
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
충전기 사진 <span style="color:var(--red)">*필수</span>
<span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span>
</label>
<label class="upload-area" for="chargerPhoto">
📷 탭하여 촬영하거나 앨범에서 선택<br>
<span style="font-size:11px">여러 장 동시 선택 가능</span>
</label>
<input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="chargerPreview"></div>
<div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div>
</div>
<div class="form-group" style="margin-top:14px">
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span>
</label>
<label class="upload-area" for="carPhoto">📷 탭하여 촬영하거나 앨범에서 선택</label>
<input type="file" id="carPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="carPreview"></div>
<div class="photo-info" id="carInfo" style="color:var(--gray4)"></div>
</div>
</div>
<div class="section">
<h3>📝 상세 설명 (선택)</h3>
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
</div>
<div class="section">
<h3>📞 연락처 (선택)</h3>
<input type="tel" id="contact" placeholder="010-0000-0000">
<div style="margin-top:10px;display:flex;align-items:flex-start;gap:8px;">
<input type="checkbox" id="consent" style="width:auto;margin-top:2px;accent-color:var(--accent)">
<label for="consent" style="font-size:12px;color:var(--gray4);cursor:pointer">
개인정보(연락처)를 AS 처리 목적으로 수집·이용하는 것에 동의합니다.
</label>
</div>
</div>
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">신고 접수하기</button>
</div>
<div id="resultBox">
<h2>✅ 신고 접수 완료</h2>
<p id="resultMsg"></p>
<p style="margin-top:12px;font-size:13px;color:rgba(255,255,255,.6)">빠른 시간 내에 처리하겠습니다.</p>
<button onclick="location.reload()"
style="margin-top:16px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.3);color:white;padding:8px 20px;border-radius:8px;font-size:13px;cursor:pointer;">
🔄 현황 다시 보기
</button>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/imageCompress.js"></script>
<script>
const ISSUES = [
{key:'충전불가', label:'⚡ 충전 불가'},
{key:'화면오류', label:'🖥 화면 오류'},
{key:'케이블불량',label:'🔌 케이블 불량'},
{key:'결제오류', label:'💳 결제 오류'},
{key:'외관손상', label:'🔨 외관 손상'},
{key:'에러발생', label:'⚠️ 에러 발생'},
{key:'기타', label:'📋 기타'},
];
const STATUS_ICON = {
pending_approval: '🕐',
pending: '📋',
in_progress: '🔧',
waiting: '⏳',
revisit: '🔄',
};
const PROGRESS_PCT = {
pending_approval: 10,
pending: 25,
in_progress: 65,
waiting: 50,
revisit: 80,
};
const selected = new Set();
const chargerId = location.pathname.split('/').pop();
let isStatusOpen = true;
// ── 충전기 정보 로드 ──
async function loadCharger() {
try {
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
document.getElementById('chargerInfo').innerHTML = `
<h2>⚡ ${c.name}</h2>
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
<div class="row"><span>종류</span><span>${c.charger_type || '-'}</span></div>
<div class="row"><span>CPO</span><span>${c.cpo_name || '-'}</span></div>
<div class="row"><span>설치일</span><span>${c.installed_at || '-'}</span></div>
`;
} catch {
document.getElementById('chargerInfo').innerHTML =
'<h2 style="color:#ff8888">충전기 정보를 불러올 수 없습니다.</h2>';
}
}
// ── 현재 접수 현황 로드 ──
async function loadStatus() {
try {
const reports = await fetch('/api/reports/public/' + chargerId).then(r => r.json());
if (!reports.length) {
document.getElementById('noReportNotice').style.display = 'block';
return;
}
document.getElementById('statusSection').style.display = 'block';
document.getElementById('statusCount').textContent = reports.length + '건';
const list = document.getElementById('statusList');
list.innerHTML = reports.map(r => {
const pct = PROGRESS_PCT[r.status] || 20;
const icon = STATUS_ICON[r.status] || '📋';
const dt = r.reported_at
? new Date(r.reported_at).toLocaleString('ko-KR',
{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
: '';
const mechHtml = r.mechanic_name
? `<div class="rsc-mechanic">🔧 ${r.mechanic_name} 정비사가 처리 중입니다</div>`
: '';
const detailHtml = r.issue_detail
? `<div style="font-size:12px;color:var(--text2);margin-top:4px;padding:6px 8px;background:var(--gray1);border-radius:5px;">${r.issue_detail}</div>`
: '';
return `
<div class="report-status-card">
<div class="rsc-top">
<span class="rsc-num">#${r.id} · ${dt}</span>
<span class="rsc-badge ${r.status}">${icon} ${r.status_label}</span>
</div>
<div class="rsc-issues">
${(r.issue_types || []).map(t =>
`<span class="rsc-issue-tag">${t}</span>`).join('')}
</div>
${detailHtml}
${r.photo_count > 0
? `<div style="font-size:11px;color:var(--gray4);margin-top:4px;">📷 사진 ${r.photo_count}장 첨부됨</div>`
: ''}
${mechHtml}
<div class="rsc-progress-bar">
<div class="rsc-progress-fill" style="width:${pct}%"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--gray4);margin-top:4px;">
<span>접수</span><span>처리중</span><span>완료</span>
</div>
</div>`;
}).join('');
} catch(e) {
console.warn('현황 로드 실패:', e);
}
}
// ── 현황 접기/펼치기 ──
function toggleStatus() {
isStatusOpen = !isStatusOpen;
const body = document.getElementById('statusBody');
const icon = document.getElementById('statusToggleIcon');
if (isStatusOpen) {
body.style.maxHeight = body.scrollHeight + 'px';
icon.classList.add('open');
} else {
body.style.maxHeight = '0';
icon.classList.remove('open');
}
}
// 초기 펼침 높이 설정
function initCollapseHeight() {
const body = document.getElementById('statusBody');
if (body) body.style.maxHeight = body.scrollHeight + 'px';
}
// ── GPS ──
navigator.geolocation?.getCurrentPosition(
pos => {
document.getElementById('gpsLat').value = pos.coords.latitude;
document.getElementById('gpsLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').textContent =
`📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`;
document.getElementById('gpsStatus').className = 'alert alert-success';
},
() => {
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
document.getElementById('gpsStatus').className = 'alert alert-warn';
}
);
// ── 문제 유형 버튼 ──
const grid = document.getElementById('issueGrid');
ISSUES.forEach(issue => {
const btn = document.createElement('button');
btn.className = 'issue-btn';
btn.textContent = issue.label;
btn.type = 'button';
btn.onclick = () => {
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
else { selected.add(issue.key); btn.classList.add('sel'); }
document.getElementById('errorCodeWrap').style.display =
selected.has('에러발생') ? 'block' : 'none';
document.getElementById('etcWrap').style.display =
selected.has('기타') ? 'block' : 'none';
};
grid.appendChild(btn);
});
// ── 이미지 압축 + 다중 선택 ──
ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2');
ImageCompressor.setupPreview('carPhoto', 'carPreview', 'carInfo');
// ── 신고 제출 ──
document.getElementById('submitBtn').addEventListener('click', async () => {
const issues = [...selected];
if (!issues.length) { showErr('문제 유형을 1개 이상 선택해 주세요.'); return; }
const chargerFiles = document.getElementById('chargerPhoto').files;
if (!chargerFiles.length) { showErr('충전기 사진을 1장 이상 첨부해 주세요.'); return; }
const contact = document.getElementById('contact').value.trim();
const consent = document.getElementById('consent').checked;
if (contact && !consent) {
showErr('연락처를 입력한 경우 개인정보 수집 동의가 필요합니다.'); return;
}
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...';
const fd = new FormData();
fd.append('charger_id', chargerId);
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('detail').value);
fd.append('error_code', document.getElementById('errorCode').value);
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
fd.append('contact', contact);
fd.append('consent', consent);
fd.append('gps_lat', document.getElementById('gpsLat').value || '');
fd.append('gps_lng', document.getElementById('gpsLng').value || '');
Array.from(chargerFiles).forEach(f => fd.append('photos', f));
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports', { method: 'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
document.getElementById('mainForm').style.display = 'none';
document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`;
// 현황 새로고침
document.getElementById('statusSection').style.display = 'none';
document.getElementById('noReportNotice').style.display = 'none';
await loadStatus();
initCollapseHeight();
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '신고 접수하기';
}
});
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
el.scrollIntoView({ behavior: 'smooth' });
}
// ── 초기 로드 ──
(async () => {
await Promise.all([loadCharger(), loadStatus()]);
initCollapseHeight();
})();
</script>
</body>
</html>