기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, 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:
@@ -1,20 +1,27 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import desc, text
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import os, uuid
|
||||
from database import get_db
|
||||
import models
|
||||
from auth import require_admin, get_current_user
|
||||
from utils import save_upload
|
||||
from auth import require_admin, get_current_user, get_optional_user
|
||||
from utils import save_upload, UPLOAD_DIR
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
def _fmt_report(r: models.Report, db: Session):
|
||||
c = r.charger
|
||||
repair_id = None
|
||||
mechanic_name = None
|
||||
mechanic_company = None
|
||||
if r.repair_links:
|
||||
repair_id = r.repair_links[0].repair_id
|
||||
rep = r.repair_links[0].repair
|
||||
if rep and rep.mechanic:
|
||||
mechanic_name = rep.mechanic.name
|
||||
mechanic_company = rep.mechanic.company
|
||||
return {
|
||||
"id": r.id, "charger_id": r.charger_id,
|
||||
"charger_name": c.name if c else None,
|
||||
@@ -27,9 +34,17 @@ def _fmt_report(r: models.Report, db: Session):
|
||||
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
|
||||
"reported_at": r.reported_at.isoformat() if r.reported_at else None,
|
||||
"gps_lat": r.gps_lat, "gps_lng": r.gps_lng,
|
||||
"charger_lat": c.gps_lat if c else None,
|
||||
"charger_lng": c.gps_lng if c else None,
|
||||
"location_detail": c.location_detail if c else None,
|
||||
"status": r.status,
|
||||
"photos": [p.file_path for p in r.photos],
|
||||
"ocpp_log": r.ocpp_log,
|
||||
"source": r.source or "qr",
|
||||
"reported_by_name": r.reporter.name if r.reporter else None,
|
||||
"photos": [{"id": p.id, "path": p.file_path} for p in r.photos],
|
||||
"repair_id": repair_id,
|
||||
"mechanic_name": mechanic_name,
|
||||
"mechanic_company": mechanic_company,
|
||||
}
|
||||
|
||||
@router.post("")
|
||||
@@ -43,8 +58,11 @@ async def create_report(
|
||||
consent: bool = Form(False),
|
||||
gps_lat: Optional[float] = Form(None),
|
||||
gps_lng: Optional[float] = Form(None),
|
||||
ocpp_log: Optional[str] = Form(None),
|
||||
source: Optional[str] = Form(None),
|
||||
photos: List[UploadFile] = File(default=[]),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[models.User] = Depends(get_optional_user)
|
||||
):
|
||||
import json
|
||||
charger = db.query(models.Charger).filter_by(id=charger_id).first()
|
||||
@@ -55,13 +73,21 @@ async def create_report(
|
||||
policy = setting.value if setting else "immediate"
|
||||
initial_status = "pending_approval" if policy == "admin_approval" else "pending"
|
||||
|
||||
if current_user:
|
||||
source_value = source if source in ("admin", "dashboard") else "admin"
|
||||
else:
|
||||
source_value = "qr"
|
||||
|
||||
issue_list = json.loads(issue_types) if isinstance(issue_types, str) else issue_types
|
||||
r = models.Report(
|
||||
charger_id=charger_id, issue_types=issue_list,
|
||||
issue_detail=issue_detail or None, error_code=error_code or None,
|
||||
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
|
||||
contact=contact or None, consent=consent,
|
||||
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status
|
||||
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status,
|
||||
ocpp_log=ocpp_log or None,
|
||||
source=source_value,
|
||||
reported_by=current_user.id if current_user else None,
|
||||
)
|
||||
db.add(r); db.commit(); db.refresh(r)
|
||||
|
||||
@@ -72,15 +98,95 @@ async def create_report(
|
||||
db.commit()
|
||||
return {"id": r.id, "status": r.status}
|
||||
|
||||
@router.post("/batch")
|
||||
async def create_batch_report(
|
||||
charger_id: str = Form(...),
|
||||
scope: str = Form("single"), # single | station | type
|
||||
issue_types: str = Form(...),
|
||||
issue_detail: str = Form(""),
|
||||
error_code: str = Form(""),
|
||||
occurred_at: Optional[str] = Form(None),
|
||||
contact: str = Form(""),
|
||||
consent: bool = Form(False),
|
||||
gps_lat: Optional[float] = Form(None),
|
||||
gps_lng: Optional[float] = Form(None),
|
||||
ocpp_log: Optional[str] = Form(None),
|
||||
source: Optional[str] = Form(None),
|
||||
photos: List[UploadFile] = File(default=[]),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[models.User] = Depends(get_optional_user)
|
||||
):
|
||||
import json
|
||||
charger = db.query(models.Charger).filter_by(id=charger_id).first()
|
||||
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
|
||||
|
||||
if scope == "station":
|
||||
targets = db.query(models.Charger).filter_by(
|
||||
station_name=charger.station_name, is_active=True).all()
|
||||
elif scope == "type" and charger.charger_type_id:
|
||||
targets = db.query(models.Charger).filter_by(
|
||||
charger_type_id=charger.charger_type_id, is_active=True).all()
|
||||
else:
|
||||
targets = [charger]
|
||||
|
||||
setting = db.query(models.SystemSetting).filter_by(key="report_visibility_policy").first()
|
||||
policy = setting.value if setting else "immediate"
|
||||
initial_status = "pending_approval" if policy == "admin_approval" else "pending"
|
||||
issue_list = json.loads(issue_types) if isinstance(issue_types, str) else issue_types
|
||||
|
||||
if current_user:
|
||||
source_value = source if source in ("admin", "dashboard") else "admin"
|
||||
else:
|
||||
source_value = "qr"
|
||||
|
||||
# Read all photo bytes upfront so they can be written for each target
|
||||
photo_data = []
|
||||
for photo in photos:
|
||||
if photo.filename:
|
||||
photo_data.append((photo.filename, photo.file.read()))
|
||||
|
||||
created_ids = []
|
||||
for target in targets:
|
||||
r = models.Report(
|
||||
charger_id=target.id, issue_types=issue_list,
|
||||
issue_detail=issue_detail or None, error_code=error_code or None,
|
||||
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
|
||||
contact=contact or None, consent=consent,
|
||||
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status,
|
||||
ocpp_log=ocpp_log or None,
|
||||
source=source_value,
|
||||
reported_by=current_user.id if current_user else None,
|
||||
)
|
||||
db.add(r); db.commit(); db.refresh(r)
|
||||
|
||||
for fname, content in photo_data:
|
||||
ext = os.path.splitext(fname)[1].lower() or ".jpg"
|
||||
sub = f"reports/{r.id}"
|
||||
folder = os.path.join(UPLOAD_DIR, sub)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
dest = os.path.join(folder, f"{uuid.uuid4().hex}{ext}")
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
db.add(models.ReportPhoto(report_id=r.id, file_path=f"/uploads/{sub}/{os.path.basename(dest)}"))
|
||||
db.commit()
|
||||
created_ids.append(r.id)
|
||||
|
||||
return {"ids": created_ids, "count": len(created_ids), "primary_id": created_ids[0] if created_ids else None}
|
||||
|
||||
@router.get("")
|
||||
def list_reports(
|
||||
status: Optional[str] = None,
|
||||
charger_id: Optional[str] = None,
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)
|
||||
):
|
||||
q = db.query(models.Report).order_by(desc(models.Report.reported_at))
|
||||
if status: q = q.filter(models.Report.status == status)
|
||||
if status:
|
||||
q = q.filter(models.Report.status == status)
|
||||
elif active_only:
|
||||
q = q.filter(models.Report.status.in_(
|
||||
["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
|
||||
if charger_id: q = q.filter(models.Report.charger_id == charger_id)
|
||||
# 정비사는 공개된 것만 (승인 대기 제외)
|
||||
if current_user.role == "mechanic":
|
||||
@@ -106,6 +212,12 @@ def get_report(report_id: int, db: Session = Depends(get_db),
|
||||
"started_at": repair.started_at.isoformat(),
|
||||
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
|
||||
"result_status": repair.result_status,
|
||||
"mechanic_lat": repair.mechanic_lat,
|
||||
"mechanic_lng": repair.mechanic_lng,
|
||||
"charger_lat": r.charger.gps_lat if r.charger else None,
|
||||
"charger_lng": r.charger.gps_lng if r.charger 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": [p.file_path for p in repair.photos if p.photo_type == "before"],
|
||||
"photos_after": [p.file_path for p in repair.photos if p.photo_type == "after"],
|
||||
"cost": {
|
||||
@@ -116,10 +228,92 @@ def get_report(report_id: int, db: Session = Depends(get_db),
|
||||
"cost_amount": cost.cost_amount,
|
||||
"cost_status": cost.cost_status,
|
||||
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
|
||||
} if cost else None
|
||||
} if cost else None,
|
||||
"linked_improvements": _get_linked_improvements(repair, db),
|
||||
}
|
||||
return result
|
||||
|
||||
def _get_linked_improvements(repair, db):
|
||||
rids = [lk.report_id for lk in repair.report_links]
|
||||
if not rids:
|
||||
return []
|
||||
imp_links = db.query(models.ImprovementReport).filter(
|
||||
models.ImprovementReport.report_id.in_(rids)
|
||||
).all()
|
||||
seen, result = set(), []
|
||||
for il in imp_links:
|
||||
if il.improvement_id not in seen:
|
||||
seen.add(il.improvement_id)
|
||||
imp = db.query(models.Improvement).filter_by(id=il.improvement_id).first()
|
||||
if imp:
|
||||
result.append({"id": imp.id, "title": imp.title,
|
||||
"category": imp.category, "status": imp.status})
|
||||
return result
|
||||
|
||||
@router.delete("/bulk")
|
||||
def bulk_delete_reports(
|
||||
ids: List[int] = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
if not ids:
|
||||
raise HTTPException(400, "삭제할 항목을 선택하세요.")
|
||||
db.execute(text("DELETE FROM repair_reports WHERE report_id = ANY(:ids)"), {"ids": ids})
|
||||
result = db.execute(text("DELETE FROM reports WHERE id = ANY(:ids)"), {"ids": ids})
|
||||
db.commit()
|
||||
return {"deleted": result.rowcount}
|
||||
|
||||
@router.patch("/{report_id}")
|
||||
async def update_report(
|
||||
report_id: int,
|
||||
issue_types: Optional[str] = Form(None),
|
||||
issue_detail: Optional[str] = Form(None),
|
||||
error_code: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
occurred_at: Optional[str] = Form(None),
|
||||
status: Optional[str] = Form(None),
|
||||
ocpp_log: Optional[str] = Form(None),
|
||||
photos: List[UploadFile] = File(default=[]),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
import json
|
||||
r = db.query(models.Report).filter_by(id=report_id).first()
|
||||
if not r: raise HTTPException(404)
|
||||
if issue_types is not None:
|
||||
r.issue_types = json.loads(issue_types)
|
||||
if issue_detail is not None:
|
||||
r.issue_detail = issue_detail or None
|
||||
if error_code is not None:
|
||||
r.error_code = error_code or None
|
||||
if contact is not None:
|
||||
r.contact = contact or None
|
||||
if occurred_at is not None:
|
||||
r.occurred_at = datetime.fromisoformat(occurred_at) if occurred_at else None
|
||||
if status is not None:
|
||||
r.status = status
|
||||
if ocpp_log is not None:
|
||||
r.ocpp_log = ocpp_log or None
|
||||
db.commit()
|
||||
for photo in photos:
|
||||
if photo.filename:
|
||||
path = save_upload(photo, f"reports/{report_id}")
|
||||
db.add(models.ReportPhoto(report_id=report_id, file_path=path))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/{report_id}/photos/{photo_id}")
|
||||
def delete_report_photo(
|
||||
report_id: int, photo_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
photo = db.query(models.ReportPhoto).filter_by(id=photo_id, report_id=report_id).first()
|
||||
if not photo: raise HTTPException(404)
|
||||
db.delete(photo)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.patch("/{report_id}/approve")
|
||||
def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
r = db.query(models.Report).filter_by(id=report_id).first()
|
||||
|
||||
Reference in New Issue
Block a user