기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, UI 개선

## 처리시간 지표
- 업무시간 기준(09-18 평일) / 공휴일 제외 24h / 달력 기준 3가지 모드 선택
- 공휴일 DB 관리 (holidays 테이블, 수동 등록·삭제·일괄 추가)
- 2026년 공휴일 등록 지원
- 설정 페이지에서 라디오 버튼으로 모드 선택

## 대시보드 차트
- 월별 평균 처리시간 막대 차트 추가
- 월별 신고 접수 건수 누적 막대 차트 추가
- 월별 → 일별 드릴다운 (막대 클릭 시 해당 월의 일별 차트로 전환)
- 일별 막대 클릭 시 처리 완료/신고 접수 상세 내역 모달
- 충전기별 누적 고장 건수 Top 10 수평 막대 차트 추가

## 신고 목록
- # 컬럼을 DB PK 대신 현재 목록 순서(1, 2, 3…)로 표시
- 엑셀 export 접수번호도 순차번호로 변경

## 모바일 네비게이션 버그 수정
- 모바일에서 가로 오버플로우 시 nav가 body 넓이로 늘어나
  햄버거 버튼이 화면 밖으로 밀리는 문제 수정
- nav를 position:fixed + body padding-top:54px 로 변경 (전체 페이지 적용)
- 충전기 관리·신고 목록 페이지 지도 컨테이너에 isolation:isolate 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byun
2026-05-31 06:52:56 +09:00
parent 05b478372a
commit 2e8751ea6c
35 changed files with 5541 additions and 353 deletions

View File

@@ -6,11 +6,57 @@ from typing import List, Optional
from datetime import datetime
from database import get_db
import models
from auth import require_mechanic, get_current_user
from auth import require_mechanic, require_admin, get_current_user
from utils import save_upload
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
STATUS_MAP = {
"done": "done",
"waiting": "waiting",
"revisit": "revisit",
"in_progress": "in_progress",
}
def _fmt_repair(repair: models.Repair) -> dict:
reports = []
charger_id = None
station_name = None
charger_name = None
for link in repair.report_links:
r = link.report
if r:
if not charger_id and r.charger:
charger_id = r.charger_id
station_name = r.charger.station_name
charger_name = r.charger.name
reports.append({
"id": r.id,
"charger_id": r.charger_id,
"issue_types": r.issue_types,
"status": r.status,
})
return {
"id": repair.id,
"charger_id": charger_id,
"charger_name": charger_name,
"station_name": station_name,
"repair_types": repair.repair_types,
"description": repair.description,
"result_status": repair.result_status,
"mechanic_lat": repair.mechanic_lat,
"mechanic_lng": repair.mechanic_lng,
"started_at": repair.started_at.isoformat(),
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"approved_at": repair.approved_at.isoformat() if repair.approved_at else None,
"approved_by_name": repair.approver.name if repair.approved_by and repair.approver else None,
"photos_before": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "before"],
"photos_after": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "after"],
"reports": reports,
"report_count": len(reports),
}
@router.get("/pending")
def pending_reports(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
@@ -21,6 +67,10 @@ def pending_reports(db: Session = Depends(get_db),
result = []
for r in q.all():
c = r.charger
# in_progress 신고는 연결된 repair_id 포함 → 편집 모드로 연결
repair_id = None
if r.repair_links:
repair_id = r.repair_links[0].repair_id
result.append({
"id": r.id, "charger_id": r.charger_id,
"charger_name": c.name if c else None,
@@ -29,9 +79,23 @@ def pending_reports(db: Session = Depends(get_db),
"issue_types": r.issue_types, "status": r.status,
"reported_at": r.reported_at.isoformat(),
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
"repair_id": repair_id,
"gps_lat": c.gps_lat if c else None,
"gps_lng": c.gps_lng if c else None,
"location_detail": c.location_detail if c else None,
})
return result
@router.get("/my")
def my_repairs(db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)):
repairs = db.query(models.Repair).filter_by(
mechanic_id=current_user.id
).order_by(desc(models.Repair.completed_at)).limit(100).all()
return [_fmt_repair(r) for r in repairs]
@router.get("/charger/{charger_id}/open")
def open_reports_for_charger(charger_id: str, db: Session = Depends(get_db),
_=Depends(require_mechanic)):
@@ -47,72 +111,185 @@ def open_reports_for_charger(charger_id: str, db: Session = Depends(get_db),
"photos": [p.file_path for p in r.photos],
} for r in reports]
@router.get("/{repair_id}")
def get_repair(repair_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
if repair.mechanic_id != current_user.id and current_user.role != "admin":
raise HTTPException(403, "접근 권한이 없습니다.")
return _fmt_repair(repair)
@router.post("")
async def create_repair(
report_ids: str = Form(...), # JSON 배열
repair_types: str = Form(...), # JSON 배열
report_ids: str = Form(...),
repair_types: str = Form(...),
description: str = Form(...),
result_status: str = Form("done"),
mechanic_lat: Optional[float] = Form(None),
mechanic_lng: Optional[float] = Form(None),
photos_before: List[UploadFile] = File(default=[]),
photos_after: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)
):
rids = json.loads(report_ids)
rtypes = json.loads(repair_types)
repair = models.Repair(
mechanic_id=current_user.id,
repair_types=rtypes,
repair_types=json.loads(repair_types),
description=description,
started_at=datetime.now(),
completed_at=datetime.now(),
result_status=result_status,
mechanic_lat=mechanic_lat,
mechanic_lng=mechanic_lng,
)
db.add(repair); db.commit(); db.refresh(repair)
# 신고 연결 및 상태 업데이트
for rid in rids:
r = db.query(models.Report).filter_by(id=rid).first()
if r:
new_status = "done" if result_status == "done" else (
"waiting" if result_status == "waiting" else "revisit"
)
r.status = new_status
r.status = STATUS_MAP.get(result_status, "in_progress")
db.add(models.RepairReport(repair_id=repair.id, report_id=rid))
# 사진 저장
for photo in photos_before:
if photo.filename:
path = save_upload(photo, f"repairs/{repair.id}")
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="before", file_path=path))
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="before",
file_path=save_upload(photo, f"repairs/{repair.id}")))
for photo in photos_after:
if photo.filename:
path = save_upload(photo, f"repairs/{repair.id}")
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="after", file_path=path))
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="after",
file_path=save_upload(photo, f"repairs/{repair.id}")))
db.commit()
return {"id": repair.id}
@router.get("/my")
def my_repairs(db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)):
repairs = db.query(models.Repair).filter_by(
mechanic_id=current_user.id
).order_by(desc(models.Repair.completed_at)).limit(50).all()
result = []
for repair in repairs:
rids = [rr.report_id for rr in repair.report_links]
charger_id = None
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r: charger_id = r.charger_id
result.append({
"id": repair.id, "charger_id": charger_id,
"repair_types": repair.repair_types,
"result_status": repair.result_status,
"started_at": repair.started_at.isoformat(),
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"report_count": len(rids),
})
return result
@router.put("/{repair_id}")
async def update_repair(
repair_id: int,
repair_types: str = Form(...),
description: str = Form(...),
result_status: str = Form("done"),
mechanic_lat: Optional[float] = Form(None),
mechanic_lng: Optional[float] = Form(None),
photos_before: List[UploadFile] = File(default=[]),
photos_after: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)
):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
if repair.mechanic_id != current_user.id:
raise HTTPException(403, "본인 조치 이력만 수정할 수 있습니다.")
if repair.approved_at:
raise HTTPException(403, "관리자가 승인한 조치는 수정할 수 없습니다.")
repair.repair_types = json.loads(repair_types)
repair.description = description
repair.result_status = result_status
repair.completed_at = datetime.now()
if mechanic_lat is not None: repair.mechanic_lat = mechanic_lat
if mechanic_lng is not None: repair.mechanic_lng = mechanic_lng
for link in repair.report_links:
r = link.report
if r: r.status = STATUS_MAP.get(result_status, "in_progress")
for photo in photos_before:
if photo.filename:
db.add(models.RepairPhoto(repair_id=repair_id, photo_type="before",
file_path=save_upload(photo, f"repairs/{repair_id}")))
for photo in photos_after:
if photo.filename:
db.add(models.RepairPhoto(repair_id=repair_id, photo_type="after",
file_path=save_upload(photo, f"repairs/{repair_id}")))
db.commit()
return {"ok": True}
@router.post("/{repair_id}/approve")
def approve_repair(
repair_id: int,
improvement_action: str = Form("none"), # none | link | create
improvement_id: Optional[int] = Form(None),
imp_title: str = Form(""),
imp_category: str = Form(""),
imp_description: str = Form(""),
imp_priority: str = Form("normal"),
imp_manufacturer_id: Optional[int] = Form(None),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)
):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
repair.approved_at = datetime.now()
repair.approved_by = current_user.id
target_imp_id = None
if improvement_action == "link" and improvement_id:
target_imp_id = improvement_id
elif improvement_action == "create":
if not imp_title or not imp_category or not imp_description:
raise HTTPException(400, "개선항목 제목, 분류, 내용을 모두 입력해 주세요.")
imp = models.Improvement(
title=imp_title, category=imp_category,
description=imp_description, priority=imp_priority,
manufacturer_id=imp_manufacturer_id or None,
created_by=current_user.id,
)
db.add(imp); db.flush()
db.add(models.ImprovementLog(
improvement_id=imp.id, changed_by=current_user.id,
old_status=None, new_status="registered",
memo=f"조치 승인 시 생성 (수리 #{repair_id})"
))
target_imp_id = imp.id
if target_imp_id:
for link in repair.report_links:
exists = db.query(models.ImprovementReport).filter_by(
improvement_id=target_imp_id, report_id=link.report_id
).first()
if not exists:
db.add(models.ImprovementReport(
improvement_id=target_imp_id, report_id=link.report_id
))
db.commit()
return {"ok": True, "improvement_id": target_imp_id}
@router.delete("/{repair_id}")
def cancel_repair(
repair_id: int,
db: Session = Depends(get_db),
_=Depends(require_admin)
):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
# 연결된 신고를 접수(pending) 상태로 되돌림
for link in repair.report_links:
if link.report:
link.report.status = "pending"
db.delete(repair) # cascade: RepairReport, RepairPhoto 자동 삭제
db.commit()
return {"ok": True}
@router.delete("/{repair_id}/photos/{photo_id}")
def delete_repair_photo(repair_id: int, photo_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
if repair.mechanic_id != current_user.id and current_user.role != "admin":
raise HTTPException(403)
if repair.approved_at and current_user.role != "admin":
raise HTTPException(403, "승인된 조치는 수정할 수 없습니다.")
photo = db.query(models.RepairPhoto).filter_by(id=photo_id, repair_id=repair_id).first()
if not photo: raise HTTPException(404)
db.delete(photo); db.commit()
return {"ok": True}