기능 추가 — 신고 상황종료 처리

- DB: reports 테이블에 closure_type, closure_note, closed_at, closed_by 컬럼 추가
- 백엔드: PATCH /reports/{id}/close 엔드포인트 (사유 4종: natural/remote_reset/false_alarm/other)
- 신고상세: 승인대기 상태에서 [상황종료] 버튼 추가, 인라인 패널에서 사유 선택
- 상황종료 후 상세 화면에 사유·메모·처리자·일시 표시
- 엑셀 AS신고목록에 상황종료 4개 컬럼 추가
- 신고목록 필터·지도 상태 목록에 closed 추가, CSS 뱃지 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byun
2026-06-01 09:39:50 +09:00
parent b59569ca11
commit af7e47529c
7 changed files with 108 additions and 6 deletions

View File

@@ -59,10 +59,15 @@ class Report(Base):
source = Column(String(20), default="qr") # qr | admin source = Column(String(20), default="qr") # qr | admin
reported_by = Column(Integer, ForeignKey("users.id"), nullable=True) reported_by = Column(Integer, ForeignKey("users.id"), nullable=True)
reported_at = Column(TIMESTAMP, server_default=func.now()) reported_at = Column(TIMESTAMP, server_default=func.now())
closure_type = Column(String(30)) # natural|remote_reset|false_alarm|other
closure_note = Column(Text)
closed_at = Column(TIMESTAMP)
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
charger = relationship("Charger", back_populates="reports") charger = relationship("Charger", back_populates="reports")
photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan") photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan")
repair_links = relationship("RepairReport", back_populates="report") repair_links = relationship("RepairReport", back_populates="report")
reporter = relationship("User", foreign_keys=[reported_by]) reporter = relationship("User", foreign_keys=[reported_by])
closer = relationship("User", foreign_keys=[closed_by])
class ReportPhoto(Base): class ReportPhoto(Base):
__tablename__ = "report_photos" __tablename__ = "report_photos"

View File

@@ -74,6 +74,12 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
ws.title = "AS신고목록" ws.title = "AS신고목록"
ws.freeze_panes = "A2" ws.freeze_panes = "A2"
CLOSURE_LABEL = {
"natural": "증상자연소거",
"remote_reset": "원격리셋후증상소거",
"false_alarm": "인지오류",
"other": "기타",
}
headers = [ headers = [
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일", "접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명", "신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
@@ -81,12 +87,14 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
"담당정비사","정비사소속","조치유형","조치내용", "담당정비사","정비사소속","조치유형","조치내용",
"조치시작","조치완료","작업소요시간","신고→완료소요시간", "조치시작","조치완료","작업소요시간","신고→완료소요시간",
"문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태", "문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태",
"처리담당자","처리일시","연결개선항목번호" "처리담당자","처리일시","연결개선항목번호",
"상황종료사유","상황종료메모","상황종료일시","상황종료처리자"
] ]
style_header(ws, headers) style_header(ws, headers)
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12, col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18] 12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18,
18,24,16,14]
for i, w in enumerate(col_widths, 1): for i, w in enumerate(col_widths, 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w ws.column_dimensions[ws.cell(1, i).column_letter].width = w
@@ -136,6 +144,10 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
cost.reviewer.name if cost and cost.reviewer else "", cost.reviewer.name if cost and cost.reviewer else "",
fmt_dt(cost.reviewed_at) if cost else "", fmt_dt(cost.reviewed_at) if cost else "",
", ".join(str(i) for i in imp_ids) if imp_ids else "", ", ".join(str(i) for i in imp_ids) if imp_ids else "",
CLOSURE_LABEL.get(r.closure_type, "") if r.closure_type else "",
r.closure_note or "",
fmt_dt(r.closed_at),
r.closer.name if r.closer else "",
] ]
for col, val in enumerate(row_data, 1): for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val) ws.cell(row=row_num, column=col, value=val)

View File

@@ -45,6 +45,10 @@ def _fmt_report(r: models.Report, db: Session):
"repair_id": repair_id, "repair_id": repair_id,
"mechanic_name": mechanic_name, "mechanic_name": mechanic_name,
"mechanic_company": mechanic_company, "mechanic_company": mechanic_company,
"closure_type": r.closure_type,
"closure_note": r.closure_note,
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
"closed_by_name": r.closer.name if r.closer else None,
} }
@router.post("") @router.post("")
@@ -321,6 +325,29 @@ def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(requ
r.status = "pending"; db.commit() r.status = "pending"; db.commit()
return {"ok": True} return {"ok": True}
CLOSURE_TYPES = {"natural", "remote_reset", "false_alarm", "other"}
@router.patch("/{report_id}/close")
def close_report(
report_id: int,
closure_type: str = Form(...),
closure_note: str = Form(""),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)
):
if closure_type not in CLOSURE_TYPES:
raise HTTPException(400, "유효하지 않은 상황종료 사유입니다.")
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
from datetime import datetime
r.status = "closed"
r.closure_type = closure_type
r.closure_note = closure_note.strip() or None
r.closed_at = datetime.now()
r.closed_by = current_user.id
db.commit()
return {"ok": True}
@router.patch("/{report_id}/status") @router.patch("/{report_id}/status")
def update_status(report_id: int, status: str = Form(...), def update_status(report_id: int, status: str = Form(...),
db: Session = Depends(get_db), _=Depends(require_admin)): db: Session = Depends(get_db), _=Depends(require_admin)):

View File

@@ -114,6 +114,7 @@ textarea{resize:vertical;min-height:80px;}
.s-cost-billed{background:#DBEAFE;color:#1565C0;} .s-cost-billed{background:#DBEAFE;color:#1565C0;}
.s-cost-waived{background:#F0F0F0;color:#555;} .s-cost-waived{background:#F0F0F0;color:#555;}
.s-cost-settled{background:#D1FAE5;color:#065F46;} .s-cost-settled{background:#D1FAE5;color:#065F46;}
.s-closed{background:#F1F5F9;color:#475569;}
/* ── ALERTS ── */ /* ── ALERTS ── */
.alert{padding:12px 16px;border-radius:6px;margin-bottom:14px;font-size:13px;} .alert{padding:12px 16px;border-radius:6px;margin-bottom:14px;font-size:13px;}

View File

@@ -73,7 +73,7 @@ const Auth = (() => {
function statusBadge(status) { function statusBadge(status) {
const map = { const map = {
pending_approval: '승인대기', pending: '접수', in_progress: '처리중', pending_approval: '승인대기', pending: '접수', in_progress: '처리중',
done: '완료', waiting: '부품대기', revisit: '재방문', done: '완료', waiting: '부품대기', revisit: '재방문', closed: '상황종료',
registered: '등록', reviewing: '검토중', developing: '개발중', registered: '등록', reviewing: '검토중', developing: '개발중',
deployed: '배포완료', deployed: '배포완료',
}; };

View File

@@ -395,8 +395,43 @@ async function load() {
<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> <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>` : ''} </div>` : ''}
${r.status === 'pending_approval' ? ` ${r.status === 'pending_approval' ? `
<button class="btn btn-success btn-sm" style="margin-top:12px" <div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''} <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>
<!-- 편집 모드 --> <!-- 편집 모드 -->
@@ -886,6 +921,27 @@ async function approveReport(id) {
load(); 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) { async function saveCost(repairId) {
const partyType = document.getElementById('partyType').value; const partyType = document.getElementById('partyType').value;
if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; } if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; }

View File

@@ -80,6 +80,7 @@
<option value="done">완료</option> <option value="done">완료</option>
<option value="waiting">부품대기</option> <option value="waiting">부품대기</option>
<option value="revisit">재방문</option> <option value="revisit">재방문</option>
<option value="closed">상황종료</option>
</select> </select>
<input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px"> <input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px">
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button> <button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
@@ -245,7 +246,7 @@ const STATUS_CLASS = {
pending: 'pending', pending_approval: 'pending', pending: 'pending', pending_approval: 'pending',
in_progress: 'in_progress', in_progress: 'in_progress',
waiting: 'waiting', revisit: 'revisit', waiting: 'waiting', revisit: 'revisit',
done: 'done', done: 'done', closed: 'done',
}; };
// ── 지도 마커 렌더 ── // ── 지도 마커 렌더 ──