diff --git a/backend/auth.py b/backend/auth.py index cf2a08b..3727457 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -13,7 +13,8 @@ SECRET_KEY = os.getenv("SECRET_KEY", "changeme") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") +oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) def hash_password(password: str) -> str: """비밀번호 bcrypt 해시 생성""" @@ -58,6 +59,24 @@ def get_current_user( raise credentials_exception return user +def get_optional_user( + token: Optional[str] = Depends(oauth2_scheme_optional), + db: Session = Depends(get_db) +) -> Optional[models.User]: + if not token: + return None + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if not user_id: + return None + return db.query(models.User).filter( + models.User.id == int(user_id), + models.User.is_active == True + ).first() + except Exception: + return None + def require_role(*roles): def checker(current_user: models.User = Depends(get_current_user)): if current_user.role not in roles: diff --git a/backend/main.py b/backend/main.py index 90587ef..18b6f45 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,13 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from datetime import datetime, timedelta +from collections import defaultdict +import calendar as cal_module import os -from routers import auth_router, chargers, reports, repairs, costs, improvements, accounts, settings, export +from routers import auth_router, chargers, reports, repairs, costs, improvements, accounts, settings, export, manufacturers +from routers import holidays app = FastAPI(title="EV 충전기 AS 관리 시스템", version="1.0.0") @@ -25,6 +29,49 @@ app.include_router(improvements.router) app.include_router(accounts.router) app.include_router(settings.router) app.include_router(export.router) +app.include_router(manufacturers.router) +app.include_router(holidays.router) + +def _calc_business_hours(start: datetime, end: datetime, holiday_dates: set, + work_start: int = 9, work_end: int = 18) -> float: + """업무시간(평일 09:00-18:00, 공휴일 제외) 기준 경과 시간 계산.""" + if not start or not end or end <= start: + return 0.0 + s = start.replace(tzinfo=None) + e = end.replace(tzinfo=None) + total = 0.0 + cur_date = s.date() + end_date = e.date() + while cur_date <= end_date: + if cur_date.weekday() < 5 and cur_date not in holiday_dates: + day_ws = datetime(cur_date.year, cur_date.month, cur_date.day, work_start) + day_we = datetime(cur_date.year, cur_date.month, cur_date.day, work_end) + seg_start = max(s, day_ws) + seg_end = min(e, day_we) + if seg_end > seg_start: + total += (seg_end - seg_start).total_seconds() / 3600 + cur_date += timedelta(days=1) + return round(total, 1) + +def _calc_holiday_excluded_hours(start: datetime, end: datetime, holiday_dates: set) -> float: + """공휴일을 제외하고 나머지 날(주말 포함)은 24시간 전체 카운트.""" + if not start or not end or end <= start: + return 0.0 + s = start.replace(tzinfo=None) + e = end.replace(tzinfo=None) + total = 0.0 + cur_date = s.date() + end_date = e.date() + while cur_date <= end_date: + if cur_date not in holiday_dates: + day_s = datetime(cur_date.year, cur_date.month, cur_date.day) + day_e = day_s + timedelta(days=1) + seg_start = max(s, day_s) + seg_end = min(e, day_e) + if seg_end > seg_start: + total += (seg_end - seg_start).total_seconds() / 3600 + cur_date += timedelta(days=1) + return round(total, 1) @app.get("/api/health") def health(): @@ -33,8 +80,8 @@ def health(): @app.get("/api/stats") def stats(db=None): from database import SessionLocal - from sqlalchemy import func - from models import Report, Repair, RepairCost, Improvement + from sqlalchemy import func, text + from models import Report, Repair, RepairCost, Improvement, SystemSetting, Holiday db = SessionLocal() try: total = db.query(Report).count() @@ -44,10 +91,452 @@ def stats(db=None): cost_pend = db.query(RepairCost).filter(RepairCost.cost_status == "pending").count() imp_open = db.query(Improvement).filter( Improvement.status.in_(["registered","reviewing","developing"])).count() + + # ── 설정 읽기 ── + def _setting(key, default): + r = db.query(SystemSetting).filter_by(key=key).first() + return r.value if r else default + + _base = _setting("time_metric_base", "occurred") + + # 구버전 "true"/"false" → 신버전 mode 문자열로 정규화 + _raw_mode = _setting("time_metric_worktime", "off") + if _raw_mode == "true": _raw_mode = "worktime" + elif _raw_mode == "false": _raw_mode = "off" + _mode = _raw_mode # "off" | "holiday_24h" | "worktime" + + if _base == "reported": + t_join = "rep.reported_at" + t_plain = "reported_at" + else: + t_join = "COALESCE(rep.occurred_at, rep.reported_at)" + t_plain = "COALESCE(occurred_at, reported_at)" + + if _mode in ("worktime", "holiday_24h"): + # ── Python 기반 계산 (공휴일 테이블 활용) ── + holiday_dates = {r.holiday_date for r in db.query(Holiday).all()} + calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours + + def _avg_py(interval_days): + rows = db.execute(text(f""" + SELECT {t_join} AS start_t, r.completed_at + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + WHERE r.completed_at IS NOT NULL + AND r.completed_at >= NOW() - INTERVAL '{interval_days} days' + """)).fetchall() + h_list = [calc_fn(row[0], row[1], holiday_dates) + for row in rows if row[0] and row[1]] + return round(sum(h_list) / len(h_list), 1) if h_list else None + + avg_30d = _avg_py(30) + avg_7d = _avg_py(7) + + pending_rows = db.execute(text(f""" + SELECT {t_plain} AS start_t + FROM reports + WHERE status IN ('pending','pending_approval','in_progress','waiting','revisit') + AND {t_plain} IS NOT NULL + """)).fetchall() + + now = datetime.now() + h_pending = [calc_fn(row[0], now, holiday_dates) for row in pending_rows if row[0]] + over_24h = sum(1 for h in h_pending if h > 24) + over_72h = sum(1 for h in h_pending if h > 72) + longest_h = max(h_pending, default=0.0) + else: + # ── 단순 경과시간 기준 계산 (SQL) ── + avg_30d = db.execute(text(f""" + SELECT ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1) + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + WHERE r.completed_at IS NOT NULL + AND r.completed_at >= NOW() - INTERVAL '30 days' + """)).scalar() + + avg_7d = db.execute(text(f""" + SELECT ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1) + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + WHERE r.completed_at IS NOT NULL + AND r.completed_at >= NOW() - INTERVAL '7 days' + """)).scalar() + avg_30d = float(avg_30d) if avg_30d else None + avg_7d = float(avg_7d) if avg_7d else None + + row = db.execute(text(f""" + SELECT + COUNT(*) FILTER (WHERE EXTRACT(EPOCH FROM (NOW()-{t_plain}))/3600 > 24) AS over_24h, + COUNT(*) FILTER (WHERE EXTRACT(EPOCH FROM (NOW()-{t_plain}))/3600 > 72) AS over_72h, + COALESCE(MAX(ROUND(EXTRACT(EPOCH FROM (NOW()-{t_plain}))/3600, 1)), 0) AS longest_h + FROM reports + WHERE status IN ('pending','pending_approval','in_progress','waiting','revisit') + """)).fetchone() + over_24h = int(row.over_24h) + over_72h = int(row.over_72h) + longest_h = float(row.longest_h) + return { "total": total, "pending": pending, "in_progress": in_prog, "done": done, "cost_pending": cost_pend, "improvement_open": imp_open, + "time_metric_base": _base, + "time_metric_worktime": _mode, + "avg_resolution_hours_30d": avg_30d, + "avg_resolution_hours_7d": avg_7d, + "pending_over_24h": over_24h, + "pending_over_72h": over_72h, + "longest_pending_hours": longest_h, } finally: db.close() + + +def _month_range(n: int = 13): + """최근 n개월 목록 생성 (YYYY-MM 형식).""" + now = datetime.now() + result = [] + for i in range(n - 1, -1, -1): + m = now.month - i + y = now.year + while m <= 0: + m += 12 + y -= 1 + result.append(f"{y:04d}-{m:02d}") + return result + + +@app.get("/api/stats/monthly") +def stats_monthly(months: int = 13): + from database import SessionLocal + from sqlalchemy import text + from models import SystemSetting, Holiday + db = SessionLocal() + try: + def _setting(key, default): + r = db.query(SystemSetting).filter_by(key=key).first() + return r.value if r else default + + _base = _setting("time_metric_base", "occurred") + _raw = _setting("time_metric_worktime", "off") + if _raw == "true": _raw = "worktime" + elif _raw == "false": _raw = "off" + _mode = _raw + + if _base == "reported": + t_join = "rep.reported_at" + else: + t_join = "COALESCE(rep.occurred_at, rep.reported_at)" + + all_months = _month_range(months) + + if _mode in ("worktime", "holiday_24h"): + holiday_dates = {r.holiday_date for r in db.query(Holiday).all()} + calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours + + rows = db.execute(text(f""" + SELECT TO_CHAR(r.completed_at, 'YYYY-MM') AS month, + {t_join} AS start_t, + r.completed_at + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + WHERE r.completed_at IS NOT NULL + AND r.completed_at >= NOW() - INTERVAL '{months} months' + """)).fetchall() + + by_month = defaultdict(list) + for row in rows: + if row[1] and row[2]: + by_month[row[0]].append(calc_fn(row[1], row[2], holiday_dates)) + + data = {} + for m, vals in by_month.items(): + data[m] = {"avg": round(sum(vals) / len(vals), 1), "count": len(vals)} + else: + rows = db.execute(text(f""" + SELECT TO_CHAR(r.completed_at, 'YYYY-MM') AS month, + ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1) AS avg_h, + COUNT(*) AS cnt + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + WHERE r.completed_at IS NOT NULL + AND r.completed_at >= NOW() - INTERVAL '{months} months' + GROUP BY month + ORDER BY month + """)).fetchall() + data = {row[0]: {"avg": float(row[1]) if row[1] else None, "count": int(row[2])} for row in rows} + + # ── 월별 신고 접수 건수 ── + rpt_rows = db.execute(text(f""" + SELECT TO_CHAR(reported_at, 'YYYY-MM') AS month, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'done') AS done_cnt + FROM reports + WHERE reported_at >= NOW() - INTERVAL '{months} months' + GROUP BY month + ORDER BY month + """)).fetchall() + rpt_map = {row[0]: {"total": int(row[1]), "done": int(row[2])} for row in rpt_rows} + + result = [] + for m in all_months: + d = data.get(m) + r = rpt_map.get(m) + result.append({ + "month": m, + "avg_hours": d["avg"] if d else None, + "count": d["count"] if d else 0, + "report_total": r["total"] if r else 0, + "report_done": r["done"] if r else 0, + }) + + return {"data": result, "time_metric_worktime": _mode, "time_metric_base": _base} + finally: + db.close() + + +@app.get("/api/stats/daily") +def stats_daily(month: str): + """month: YYYY-MM. Returns day-by-day processing time and report counts.""" + from database import SessionLocal + from sqlalchemy import text + from models import SystemSetting, Holiday + try: + year, mon = int(month[:4]), int(month[5:7]) + except (ValueError, IndexError): + raise HTTPException(400, "month must be YYYY-MM format") + + db = SessionLocal() + try: + def _setting(key, default): + r = db.query(SystemSetting).filter_by(key=key).first() + return r.value if r else default + + _base = _setting("time_metric_base", "occurred") + _raw = _setting("time_metric_worktime", "off") + if _raw == "true": _raw = "worktime" + elif _raw == "false": _raw = "off" + _mode = _raw + + if _base == "reported": + t_join = "rep.reported_at" + t_plain = "reported_at" + else: + t_join = "COALESCE(rep.occurred_at, rep.reported_at)" + t_plain = "COALESCE(occurred_at, reported_at)" + + _, days_in_month = cal_module.monthrange(year, mon) + all_days = [f"{year:04d}-{mon:02d}-{d:02d}" for d in range(1, days_in_month + 1)] + + if _mode in ("worktime", "holiday_24h"): + holiday_dates = {r.holiday_date for r in db.query(Holiday).all()} + calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours + + rows = db.execute(text(f""" + SELECT TO_CHAR(r.completed_at, 'YYYY-MM-DD') AS day, + {t_join} AS start_t, + r.completed_at + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + WHERE r.completed_at IS NOT NULL + AND TO_CHAR(r.completed_at, 'YYYY-MM') = :month + """), {"month": month}).fetchall() + + by_day = defaultdict(list) + for row in rows: + if row[1] and row[2]: + by_day[row[0]].append(calc_fn(row[1], row[2], holiday_dates)) + + data = {} + for d, vals in by_day.items(): + data[d] = {"avg": round(sum(vals) / len(vals), 1), "count": len(vals)} + else: + rows = db.execute(text(f""" + SELECT TO_CHAR(r.completed_at, 'YYYY-MM-DD') AS day, + ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1) AS avg_h, + COUNT(*) AS cnt + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + WHERE r.completed_at IS NOT NULL + AND TO_CHAR(r.completed_at, 'YYYY-MM') = :month + GROUP BY day + ORDER BY day + """), {"month": month}).fetchall() + data = {row[0]: {"avg": float(row[1]) if row[1] else None, "count": int(row[2])} for row in rows} + + rpt_rows = db.execute(text(f""" + SELECT TO_CHAR(reported_at, 'YYYY-MM-DD') AS day, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'done') AS done_cnt + FROM reports + WHERE TO_CHAR(reported_at, 'YYYY-MM') = :month + GROUP BY day + ORDER BY day + """), {"month": month}).fetchall() + rpt_map = {row[0]: {"total": int(row[1]), "done": int(row[2])} for row in rpt_rows} + + result = [] + for day in all_days: + d = data.get(day) + r = rpt_map.get(day) + result.append({ + "day": day, + "avg_hours": d["avg"] if d else None, + "count": d["count"] if d else 0, + "report_total": r["total"] if r else 0, + "report_done": r["done"] if r else 0, + }) + + return {"data": result, "time_metric_worktime": _mode, "time_metric_base": _base, "month": month} + finally: + db.close() + + +@app.get("/api/stats/daily/detail") +def stats_daily_detail(day: str): + """day: YYYY-MM-DD. Returns per-repair and per-report detail for that day.""" + import json as _json + from database import SessionLocal + from sqlalchemy import text + from models import SystemSetting, Holiday + try: + datetime.strptime(day, "%Y-%m-%d") + except ValueError: + raise HTTPException(400, "day must be YYYY-MM-DD format") + + db = SessionLocal() + try: + def _setting(key, default): + r = db.query(SystemSetting).filter_by(key=key).first() + return r.value if r else default + + _base = _setting("time_metric_base", "occurred") + _raw = _setting("time_metric_worktime", "off") + if _raw == "true": _raw = "worktime" + elif _raw == "false": _raw = "off" + _mode = _raw + + if _base == "reported": + t_join = "rep.reported_at" + else: + t_join = "COALESCE(rep.occurred_at, rep.reported_at)" + + if _mode in ("worktime", "holiday_24h"): + holiday_dates = {r.holiday_date for r in db.query(Holiday).all()} + calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours + + # ── 처리 완료 내역 ── + repair_rows = db.execute(text(f""" + SELECT r.id AS repair_id, + rep.id AS report_id, + rep.charger_id, + c.station_name, + rep.issue_types, + {t_join} AS start_t, + r.completed_at, + u.name AS mechanic_name + FROM repairs r + JOIN repair_reports rr ON rr.repair_id = r.id + JOIN reports rep ON rep.id = rr.report_id + LEFT JOIN users u ON u.id = r.mechanic_id + LEFT JOIN chargers c ON c.id = rep.charger_id + WHERE r.completed_at IS NOT NULL + AND TO_CHAR(r.completed_at, 'YYYY-MM-DD') = :day + ORDER BY r.completed_at + """), {"day": day}).fetchall() + + repairs = [] + for row in repair_rows: + start_t, completed = row[5], row[6] + if _mode in ("worktime", "holiday_24h") and start_t and completed: + h = calc_fn(start_t, completed, holiday_dates) + elif start_t and completed: + h = round((completed - start_t).total_seconds() / 3600, 1) + else: + h = None + try: + issues = _json.loads(row[4]) if row[4] else [] + except Exception: + issues = [] + repairs.append({ + "repair_id": row[0], + "report_id": row[1], + "charger_id": row[2] or "", + "station_name": row[3] or "", + "issue_types": issues, + "start_t": start_t.isoformat() if start_t else None, + "completed_at": completed.isoformat() if completed else None, + "processing_hours": h, + "mechanic_name": row[7] or "", + }) + + # ── 신고 접수 내역 ── + rpt_rows = db.execute(text(""" + SELECT rep.id, rep.charger_id, c.station_name, + rep.issue_types, rep.status, rep.reported_at + FROM reports rep + LEFT JOIN chargers c ON c.id = rep.charger_id + WHERE TO_CHAR(rep.reported_at, 'YYYY-MM-DD') = :day + ORDER BY rep.reported_at + """), {"day": day}).fetchall() + + reports = [] + for row in rpt_rows: + try: + issues = _json.loads(row[3]) if row[3] else [] + except Exception: + issues = [] + reports.append({ + "id": row[0], + "charger_id": row[1] or "", + "station_name": row[2] or "", + "issue_types": issues, + "status": row[4], + "reported_at": row[5].isoformat() if row[5] else None, + }) + + return {"day": day, "repairs": repairs, "reports": reports} + finally: + db.close() + + +@app.get("/api/stats/top-chargers") +def stats_top_chargers(limit: int = 10): + """충전기별 누적 고장 신고 건수 Top N.""" + from database import SessionLocal + from sqlalchemy import text + db = SessionLocal() + try: + rows = db.execute(text(""" + SELECT rep.charger_id, + COALESCE(c.station_name, rep.charger_id) AS station_name, + COALESCE(c.name, '') AS charger_name, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE rep.status = 'done') AS done_cnt, + COUNT(*) FILTER (WHERE rep.status != 'done') AS active_cnt + FROM reports rep + LEFT JOIN chargers c ON c.id = rep.charger_id + GROUP BY rep.charger_id, c.station_name, c.name + ORDER BY total DESC + LIMIT :lim + """), {"lim": limit}).fetchall() + return [ + { + "charger_id": row[0], + "station_name": row[1], + "charger_name": row[2], + "total": int(row[3]), + "done": int(row[4]), + "active": int(row[5]), + } + for row in rows + ] + finally: + db.close() diff --git a/backend/models.py b/backend/models.py index 5d435c5..a37271c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -9,6 +9,8 @@ class ChargerType(Base): description = Column(Text) created_at = Column(TIMESTAMP, server_default=func.now()) chargers = relationship("Charger", back_populates="charger_type") + errors = relationship("ChargerTypeError", back_populates="charger_type", + cascade="all, delete-orphan", order_by="ChargerTypeError.display_order") class User(Base): __tablename__ = "users" @@ -21,6 +23,7 @@ class User(Base): phone = Column(String(20)) email = Column(String(100)) is_active = Column(Boolean, default=True) + is_pending = Column(Boolean, default=False) created_at = Column(TIMESTAMP, server_default=func.now()) class Charger(Base): @@ -52,10 +55,14 @@ class Report(Base): gps_lat = Column(Float) gps_lng = Column(Float) status = Column(String(30), default="pending") + ocpp_log = Column(Text) + source = Column(String(20), default="qr") # qr | admin + reported_by = Column(Integer, ForeignKey("users.id"), nullable=True) reported_at = Column(TIMESTAMP, server_default=func.now()) charger = relationship("Charger", back_populates="reports") photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan") repair_links = relationship("RepairReport", back_populates="report") + reporter = relationship("User", foreign_keys=[reported_by]) class ReportPhoto(Base): __tablename__ = "report_photos" @@ -74,7 +81,12 @@ class Repair(Base): started_at = Column(TIMESTAMP, nullable=False) completed_at = Column(TIMESTAMP) result_status = Column(String(20), default="done") + mechanic_lat = Column(Float) + mechanic_lng = Column(Float) + approved_at = Column(TIMESTAMP) + approved_by = Column(Integer, ForeignKey("users.id")) mechanic = relationship("User", foreign_keys=[mechanic_id]) + approver = relationship("User", foreign_keys=[approved_by]) report_links = relationship("RepairReport", back_populates="repair", cascade="all, delete-orphan") photos = relationship("RepairPhoto", back_populates="repair", cascade="all, delete-orphan") cost = relationship("RepairCost", back_populates="repair", uselist=False) @@ -161,8 +173,36 @@ class ImprovementLog(Base): improvement = relationship("Improvement", back_populates="logs") changer = relationship("User") +class ChargerTypeError(Base): + __tablename__ = "charger_type_errors" + id = Column(Integer, primary_key=True) + charger_type_id = Column(Integer, ForeignKey("charger_types.id", ondelete="CASCADE"), nullable=False) + error_code = Column(String(20), nullable=False) + error_name = Column(String(100), nullable=False) + range_condition = Column(String(200)) + description = Column(Text) + auto_recovery = Column(Boolean, default=True) + display_order = Column(Integer, default=0) + charger_type = relationship("ChargerType", back_populates="errors") + +class Manufacturer(Base): + __tablename__ = "manufacturers" + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + representative_name = Column(String(100)) + business_number = Column(String(50)) + phone = Column(String(30)) + address = Column(Text) + is_active = Column(Boolean, default=True) + created_at = Column(TIMESTAMP, server_default=func.now()) + class SystemSetting(Base): __tablename__ = "system_settings" key = Column(String(100), primary_key=True) value = Column(Text, nullable=False) updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) + +class Holiday(Base): + __tablename__ = "holidays" + holiday_date = Column(Date, primary_key=True) + name = Column(String(100), nullable=False) diff --git a/backend/routers/accounts.py b/backend/routers/accounts.py index 88d215a..e708b52 100644 --- a/backend/routers/accounts.py +++ b/backend/routers/accounts.py @@ -1,6 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, Form +from fastapi import APIRouter, Depends, HTTPException, Form, Body from sqlalchemy.orm import Session -from typing import Optional +from typing import List, Optional from database import get_db import models from auth import require_admin, hash_password, get_current_user @@ -15,6 +15,7 @@ def list_users(role: Optional[str] = None, db: Session = Depends(get_db), _=Depe "id": u.id, "username": u.username, "role": u.role, "company": u.company, "name": u.name, "phone": u.phone, "email": u.email, "is_active": u.is_active, + "is_pending": getattr(u, 'is_pending', False), "created_at": u.created_at.isoformat(), } for u in q.order_by(models.User.id).all()] @@ -53,6 +54,29 @@ def update_user( db.commit() return {"ok": True} +@router.patch("/{user_id}/approve") +def approve_user(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)): + u = db.query(models.User).filter_by(id=user_id).first() + if not u: raise HTTPException(404) + u.is_active = True + u.is_pending = False + db.commit() + return {"ok": True} + +@router.delete("/bulk") +def bulk_delete_accounts( + ids: List[int] = Body(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(require_admin) +): + if not ids: + raise HTTPException(400, "삭제할 항목을 선택하세요.") + safe_ids = [i for i in ids if i != current_user.id] + count = db.query(models.User).filter(models.User.id.in_(safe_ids)).update( + {"is_active": False}, synchronize_session=False) + db.commit() + return {"deactivated": count} + @router.delete("/{user_id}") def delete_user(user_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(require_admin)): diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index bd60eaf..eb1c488 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -1,20 +1,24 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Form from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session +from typing import Optional from database import get_db import models -from auth import verify_password, create_access_token, get_current_user +from auth import verify_password, create_access_token, get_current_user, hash_password router = APIRouter(prefix="/api/auth", tags=["auth"]) @router.post("/login") def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): user = db.query(models.User).filter( - models.User.username == form.username, - models.User.is_active == True + models.User.username == form.username ).first() if not user or not verify_password(form.password, user.password_hash): raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다.") + if getattr(user, 'is_pending', False): + raise HTTPException(status_code=403, detail="가입 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다.") + if not user.is_active: + raise HTTPException(status_code=403, detail="비활성화된 계정입니다. 관리자에게 문의하세요.") token = create_access_token({"sub": str(user.id)}) return { "access_token": token, @@ -24,6 +28,30 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get "user_id": user.id } +@router.post("/register") +def register( + username: str = Form(...), + password: str = Form(...), + name: str = Form(...), + phone: str = Form(""), + company: str = Form(""), + db: Session = Depends(get_db) +): + if db.query(models.User).filter_by(username=username).first(): + raise HTTPException(400, "이미 사용 중인 아이디입니다.") + user = models.User( + username=username, + password_hash=hash_password(password), + role="mechanic", + name=name, + phone=phone or None, + company=company or None, + is_active=False, + is_pending=True, + ) + db.add(user); db.commit() + return {"ok": True} + @router.get("/me") def me(current_user: models.User = Depends(get_current_user)): return { diff --git a/backend/routers/chargers.py b/backend/routers/chargers.py index ae06bb3..6ec365f 100644 --- a/backend/routers/chargers.py +++ b/backend/routers/chargers.py @@ -1,8 +1,8 @@ import os -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body from fastapi.responses import JSONResponse from sqlalchemy.orm import Session -from typing import Optional +from typing import List, Optional from database import get_db import models from auth import require_admin, get_current_user @@ -41,7 +41,80 @@ def delete_type(type_id: int, db: Session = Depends(get_db), _=Depends(require_a db.delete(t); db.commit() return {"ok": True} +# ── 충전기 종류별 에러 코드 ────────────────────────── +@router.get("/types/{type_id}/errors") +def list_type_errors(type_id: int, db: Session = Depends(get_db)): + errors = (db.query(models.ChargerTypeError) + .filter_by(charger_type_id=type_id) + .order_by(models.ChargerTypeError.display_order) + .all()) + return [{"id": e.id, "error_code": e.error_code, "error_name": e.error_name, + "range_condition": e.range_condition, "description": e.description, + "auto_recovery": e.auto_recovery, "display_order": e.display_order} + for e in errors] + +@router.post("/types/{type_id}/errors") +def create_type_error( + type_id: int, + error_code: str = Form(...), error_name: str = Form(...), + range_condition: str = Form(""), description: str = Form(""), + auto_recovery: bool = Form(True), display_order: int = Form(0), + db: Session = Depends(get_db), _=Depends(require_admin) +): + if not db.query(models.ChargerType).filter_by(id=type_id).first(): + raise HTTPException(404) + e = models.ChargerTypeError( + charger_type_id=type_id, error_code=error_code, error_name=error_name, + range_condition=range_condition or None, description=description or None, + auto_recovery=auto_recovery, display_order=display_order + ) + db.add(e); db.commit(); db.refresh(e) + return {"id": e.id} + +@router.put("/types/{type_id}/errors/{error_id}") +def update_type_error( + type_id: int, error_id: int, + error_code: str = Form(...), error_name: str = Form(...), + range_condition: str = Form(""), description: str = Form(""), + auto_recovery: bool = Form(True), display_order: int = Form(0), + db: Session = Depends(get_db), _=Depends(require_admin) +): + e = db.query(models.ChargerTypeError).filter_by(id=error_id, charger_type_id=type_id).first() + if not e: raise HTTPException(404) + e.error_code = error_code; e.error_name = error_name + e.range_condition = range_condition or None; e.description = description or None + e.auto_recovery = auto_recovery; e.display_order = display_order + db.commit() + return {"ok": True} + +@router.delete("/types/{type_id}/errors/{error_id}") +def delete_type_error( + type_id: int, error_id: int, + db: Session = Depends(get_db), _=Depends(require_admin) +): + e = db.query(models.ChargerTypeError).filter_by(id=error_id, charger_type_id=type_id).first() + if not e: raise HTTPException(404) + db.delete(e); db.commit() + return {"ok": True} + # ── 충전기 ────────────────────────────────────────── +@router.delete("/bulk") +def bulk_delete_chargers( + ids: List[str] = Body(...), + db: Session = Depends(get_db), + _=Depends(require_admin) +): + if not ids: + raise HTTPException(400, "삭제할 항목을 선택하세요.") + has_reports = db.query(models.Report.charger_id).filter( + models.Report.charger_id.in_(ids)).distinct().all() + if has_reports: + blocked = [r[0] for r in has_reports] + raise HTTPException(400, f"신고 내역이 있는 충전기는 삭제할 수 없습니다: {', '.join(blocked)}") + result = db.query(models.Charger).filter(models.Charger.id.in_(ids)).delete(synchronize_session=False) + db.commit() + return {"deleted": result} + @router.get("") def list_chargers(db: Session = Depends(get_db)): chargers = db.query(models.Charger).order_by(models.Charger.id).all() @@ -62,6 +135,19 @@ def list_chargers(db: Session = Depends(get_db)): }) return result +@router.get("/{charger_id}/errors") +def get_charger_errors(charger_id: str, db: Session = Depends(get_db)): + c = db.query(models.Charger).filter_by(id=charger_id).first() + if not c: raise HTTPException(404) + if not c.charger_type_id: return [] + errors = (db.query(models.ChargerTypeError) + .filter_by(charger_type_id=c.charger_type_id) + .order_by(models.ChargerTypeError.display_order) + .all()) + return [{"id": e.id, "error_code": e.error_code, "error_name": e.error_name, + "range_condition": e.range_condition, "auto_recovery": e.auto_recovery} + for e in errors] + @router.get("/{charger_id}") def get_charger(charger_id: str, db: Session = Depends(get_db)): c = db.query(models.Charger).filter_by(id=charger_id).first() diff --git a/backend/routers/costs.py b/backend/routers/costs.py index c3b8b90..46b9508 100644 --- a/backend/routers/costs.py +++ b/backend/routers/costs.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, Form +from fastapi import APIRouter, Depends, HTTPException, Form, Body from sqlalchemy.orm import Session -from sqlalchemy import desc -from typing import Optional +from sqlalchemy import desc, text +from typing import List, Optional from datetime import datetime from database import get_db import models @@ -48,6 +48,18 @@ def list_costs( }) return result +@router.delete("/bulk") +def bulk_delete_costs( + ids: List[int] = Body(...), + db: Session = Depends(get_db), + _=Depends(require_admin) +): + if not ids: + raise HTTPException(400, "삭제할 항목을 선택하세요.") + result = db.execute(text("DELETE FROM repair_costs WHERE id = ANY(:ids)"), {"ids": ids}) + db.commit() + return {"deleted": result.rowcount} + @router.get("/stats") def cost_stats(db: Session = Depends(get_db), _=Depends(require_admin)): from sqlalchemy import func, extract diff --git a/backend/routers/export.py b/backend/routers/export.py index 2c4a382..df1f6c2 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -77,7 +77,7 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): headers = [ "접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일", "신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명", - "신고자연락처","문제발생시각","신고일시","처리상태", + "신고자연락처","문제발생시각","신고일시","신고출처","신고자","처리상태", "담당정비사","정비사소속","조치유형","조치내용", "조치시작","조치완료","작업소요시간","신고→완료소요시간", "문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태", @@ -85,7 +85,7 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): ] style_header(ws, headers) - col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,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] for i, w in enumerate(col_widths, 1): ws.column_dimensions[ws.cell(1, i).column_letter].width = w @@ -99,9 +99,10 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): ir.improvement_id for ir in db.query(models.ImprovementReport).filter_by(report_id=r.id).all() ] + seq_no = row_num - 1 # 순차번호 (1부터 시작) row_data = [ - r.id, + seq_no, r.charger_id, c.charger_type.name if c and c.charger_type else "", c.name if c else "", @@ -116,6 +117,8 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): r.contact or "", fmt_dt(r.occurred_at), fmt_dt(r.reported_at), + {"qr": "QR스캔", "admin": "관리자접수", "dashboard": "대시보드접수"}.get(r.source or "qr", r.source or "qr"), + r.reporter.name if r.reporter else "", r.status, repair.mechanic.name if repair and repair.mechanic else "", repair.mechanic.company if repair and repair.mechanic else "", @@ -211,17 +214,30 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)) headers = [ "번호","제목","분류","우선순위","개선내용","관련부품", - "담당제조사","담당자","연락처","연결AS건수","연결AS번호", + "담당제조사","담당자","연락처","연결AS건수","연결AS번호","연결AS신고자", "진행상태","SW배포목표일","SW실제배포일","제조사메모", "등록관리자","등록일시" ] style_header(ws, headers) - for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,12,14,14,24,12,16], 1): + for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,24,12,14,14,24,12,16], 1): ws.column_dimensions[ws.cell(1, i).column_letter].width = w imps = db.query(models.Improvement).order_by(desc(models.Improvement.created_at)).all() for row_num, imp in enumerate(imps, 2): rids = [ir.report_id for ir in imp.report_links] + + reporters = [] + for ir in imp.report_links: + r = ir.report + if not r: + continue + if r.source == "admin" and r.reporter: + reporters.append(f"#{r.id} {r.reporter.name}(관리자)") + elif r.contact: + reporters.append(f"#{r.id} {r.contact}(QR)") + else: + reporters.append(f"#{r.id} 익명(QR)") + row_data = [ imp.id, imp.title, imp.category, imp.priority, imp.description, imp.part_name or "", @@ -230,6 +246,7 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)) imp.manufacturer.phone if imp.manufacturer else "", len(rids), ", ".join(str(i) for i in rids), + "\n".join(reporters), imp.status, fmt_d(imp.sw_deploy_target), fmt_d(imp.sw_deployed_at), diff --git a/backend/routers/holidays.py b/backend/routers/holidays.py new file mode 100644 index 0000000..1436e05 --- /dev/null +++ b/backend/routers/holidays.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends, HTTPException, Form, Request +from sqlalchemy.orm import Session +from sqlalchemy import extract +from database import get_db +from auth import require_admin +import models +from datetime import date as date_type + +router = APIRouter(prefix="/api/holidays", tags=["holidays"]) + +@router.get("") +def list_holidays(year: int = None, db: Session = Depends(get_db), _=Depends(require_admin)): + q = db.query(models.Holiday) + if year: + q = q.filter(extract('year', models.Holiday.holiday_date) == year) + rows = q.order_by(models.Holiday.holiday_date).all() + return [{"date": str(r.holiday_date), "name": r.name} for r in rows] + +@router.post("") +def add_holiday( + holiday_date: str = Form(...), + name: str = Form(...), + db: Session = Depends(get_db), + _=Depends(require_admin), +): + try: + d = date_type.fromisoformat(holiday_date) + except ValueError: + raise HTTPException(400, "날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).") + if db.query(models.Holiday).filter_by(holiday_date=d).first(): + raise HTTPException(400, "이미 등록된 날짜입니다.") + db.add(models.Holiday(holiday_date=d, name=name)) + db.commit() + return {"ok": True} + +@router.post("/bulk") +async def bulk_add_holidays(request: Request, db: Session = Depends(get_db), _=Depends(require_admin)): + """JSON body: [{"date": "YYYY-MM-DD", "name": "공휴일명"}, ...]""" + items = await request.json() + if not isinstance(items, list): + raise HTTPException(400, "배열 형식이어야 합니다.") + added = 0 + for item in items: + try: + d = date_type.fromisoformat(item["date"]) + except (KeyError, ValueError): + continue + if not db.query(models.Holiday).filter_by(holiday_date=d).first(): + db.add(models.Holiday(holiday_date=d, name=item.get("name", "공휴일"))) + added += 1 + db.commit() + return {"ok": True, "added": added} + +@router.delete("/{holiday_date}") +def delete_holiday( + holiday_date: str, + db: Session = Depends(get_db), + _=Depends(require_admin), +): + try: + d = date_type.fromisoformat(holiday_date) + except ValueError: + raise HTTPException(400, "날짜 형식이 올바르지 않습니다.") + h = db.query(models.Holiday).filter_by(holiday_date=d).first() + if not h: + raise HTTPException(404, "등록된 공휴일이 아닙니다.") + db.delete(h); db.commit() + return {"ok": True} diff --git a/backend/routers/improvements.py b/backend/routers/improvements.py index aae454b..199ad24 100644 --- a/backend/routers/improvements.py +++ b/backend/routers/improvements.py @@ -1,7 +1,7 @@ import json -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 from database import get_db @@ -86,6 +86,18 @@ async def create_improvement( db.commit() return {"id": imp.id} +@router.delete("/bulk") +def bulk_delete_improvements( + ids: List[int] = Body(...), + db: Session = Depends(get_db), + _=Depends(require_admin) +): + if not ids: + raise HTTPException(400, "삭제할 항목을 선택하세요.") + result = db.execute(text("DELETE FROM improvements WHERE id = ANY(:ids)"), {"ids": ids}) + db.commit() + return {"deleted": result.rowcount} + @router.patch("/{imp_id}/status") def update_status( imp_id: int, status: str = Form(...), memo: str = Form(""), diff --git a/backend/routers/manufacturers.py b/backend/routers/manufacturers.py new file mode 100644 index 0000000..b778c50 --- /dev/null +++ b/backend/routers/manufacturers.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter, Depends, HTTPException, Form +from sqlalchemy.orm import Session +from typing import Optional +from database import get_db +import models +from auth import require_admin + +router = APIRouter(prefix="/api/manufacturers", tags=["manufacturers"]) + +def _fmt(m: models.Manufacturer) -> dict: + return { + "id": m.id, "name": m.name, + "representative_name": m.representative_name, + "business_number": m.business_number, + "phone": m.phone, "address": m.address, + "is_active": m.is_active, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + +@router.get("/public") +def list_public(db: Session = Depends(get_db)): + """인증 없이 활성 제조사 목록 반환 (회원가입 화면용)""" + rows = db.query(models.Manufacturer).filter_by(is_active=True).order_by(models.Manufacturer.name).all() + return [{"id": m.id, "name": m.name} for m in rows] + +@router.get("") +def list_manufacturers(db: Session = Depends(get_db), _=Depends(require_admin)): + rows = db.query(models.Manufacturer).order_by(models.Manufacturer.name).all() + return [_fmt(m) for m in rows] + +@router.post("") +def create_manufacturer( + name: str = Form(...), + representative_name: str = Form(""), + business_number: str = Form(""), + phone: str = Form(""), + address: str = Form(""), + db: Session = Depends(get_db), + _=Depends(require_admin) +): + if db.query(models.Manufacturer).filter_by(name=name).first(): + raise HTTPException(400, "이미 등록된 회사명입니다.") + m = models.Manufacturer( + name=name, + representative_name=representative_name or None, + business_number=business_number or None, + phone=phone or None, + address=address or None, + ) + db.add(m); db.commit(); db.refresh(m) + return _fmt(m) + +@router.put("/{mfr_id}") +def update_manufacturer( + mfr_id: int, + name: str = Form(...), + representative_name: str = Form(""), + business_number: str = Form(""), + phone: str = Form(""), + address: str = Form(""), + is_active: str = Form("true"), + db: Session = Depends(get_db), + _=Depends(require_admin) +): + m = db.query(models.Manufacturer).filter_by(id=mfr_id).first() + if not m: raise HTTPException(404) + dup = db.query(models.Manufacturer).filter( + models.Manufacturer.name == name, models.Manufacturer.id != mfr_id + ).first() + if dup: raise HTTPException(400, "이미 사용 중인 회사명입니다.") + m.name = name + m.representative_name = representative_name or None + m.business_number = business_number or None + m.phone = phone or None + m.address = address or None + m.is_active = is_active == "true" + db.commit() + return _fmt(m) + +@router.delete("/{mfr_id}") +def delete_manufacturer(mfr_id: int, db: Session = Depends(get_db), _=Depends(require_admin)): + m = db.query(models.Manufacturer).filter_by(id=mfr_id).first() + if not m: raise HTTPException(404) + db.delete(m); db.commit() + return {"ok": True} diff --git a/backend/routers/repairs.py b/backend/routers/repairs.py index 3238a79..8f8d752 100644 --- a/backend/routers/repairs.py +++ b/backend/routers/repairs.py @@ -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} diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 6832a2e..f6c3ab3 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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() diff --git a/backend/routers/settings.py b/backend/routers/settings.py index b3f62df..381510d 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -1,9 +1,9 @@ -from fastapi import APIRouter, Depends, Form +from fastapi import APIRouter, Depends, Form, Request from sqlalchemy.orm import Session from datetime import datetime from typing import Optional from database import get_db -import models +import models, json from auth import require_admin router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -47,13 +47,69 @@ def get_settings(db: Session = Depends(get_db), _=Depends(require_admin)): result[r.key] = r.value return result -# ── 관리자 설정 저장 (신고공개정책 + 이미지설정 통합) +DEFAULT_ISSUE_TYPES = [ + {"key": "충전불가", "label": "⚡ 충전 불가"}, + {"key": "화면오류", "label": "🖥 화면 오류"}, + {"key": "케이블불량", "label": "🔌 케이블 불량"}, + {"key": "결제오류", "label": "💳 결제 오류"}, + {"key": "외관손상", "label": "🔨 외관 손상"}, + {"key": "에러발생", "label": "⚠️ 에러 발생"}, + {"key": "기타", "label": "📋 기타"}, +] + +@router.get("/issue-types") +def get_issue_types(db: Session = Depends(get_db)): + row = db.query(models.SystemSetting).filter_by(key="issue_types").first() + if row: + return json.loads(row.value) + return DEFAULT_ISSUE_TYPES + +@router.put("/issue-types") +async def update_issue_types(request: Request, db: Session = Depends(get_db), _=Depends(require_admin)): + items = await request.json() + if not isinstance(items, list): + from fastapi import HTTPException + raise HTTPException(400, "배열 형식이어야 합니다.") + upsert(db, "issue_types", json.dumps(items, ensure_ascii=False)) + db.commit() + return {"ok": True} + +DEFAULT_REPAIR_TYPES = [ + {"key": "부품교체", "label": "🔩 부품 교체"}, + {"key": "재시작", "label": "🔄 재시작"}, + {"key": "설정변경", "label": "⚙️ 설정 변경"}, + {"key": "청소", "label": "🧹 청소"}, + {"key": "배선정리", "label": "🔌 배선 정리"}, + {"key": "펌웨어", "label": "💾 펌웨어 업데이트"}, + {"key": "기타", "label": "📋 기타"}, +] + +@router.get("/repair-types") +def get_repair_types(db: Session = Depends(get_db)): + row = db.query(models.SystemSetting).filter_by(key="repair_types").first() + if row: + return json.loads(row.value) + return DEFAULT_REPAIR_TYPES + +@router.put("/repair-types") +async def update_repair_types(request: Request, db: Session = Depends(get_db), _=Depends(require_admin)): + items = await request.json() + if not isinstance(items, list): + from fastapi import HTTPException + raise HTTPException(400, "배열 형식이어야 합니다.") + upsert(db, "repair_types", json.dumps(items, ensure_ascii=False)) + db.commit() + return {"ok": True} + +# ── 관리자 설정 저장 (신고공개정책 + 이미지설정 + 처리시간기준 통합) @router.put("") def update_settings( report_visibility_policy: str = Form(...), image_compress_enabled: str = Form("true"), image_max_px: str = Form("1024"), image_quality: str = Form("85"), + time_metric_base: str = Form("occurred"), + time_metric_worktime: str = Form("false"), db: Session = Depends(get_db), _ = Depends(require_admin) ): @@ -62,6 +118,8 @@ def update_settings( ("image_compress_enabled", image_compress_enabled), ("image_max_px", image_max_px), ("image_quality", image_quality), + ("time_metric_base", time_metric_base), + ("time_metric_worktime", time_metric_worktime), ] for key, val in pairs: upsert(db, key, val) diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css index 645c405..4cfa73a 100644 --- a/frontend/static/css/style.css +++ b/frontend/static/css/style.css @@ -17,12 +17,21 @@ body{font-family:'Noto Sans KR',sans-serif;background:var(--gray1);color:var(--text);font-size:14px;min-height:100vh;} /* ── NAV ── */ -.nav{background:var(--navy);color:white;padding:0 24px;height:54px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.3);} +.nav{background:var(--navy);color:white;padding:0 24px;height:54px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:400;box-shadow:0 2px 8px rgba(0,0,0,.3);} .nav-brand{font-size:16px;font-weight:700;color:var(--accent);} .nav-user{font-size:13px;color:rgba(255,255,255,.7);display:flex;align-items:center;gap:12px;} .nav-user a{color:rgba(255,255,255,.7);text-decoration:none;cursor:pointer;} .nav-user a:hover{color:white;} +/* ── 햄버거 버튼 (데스크톱 숨김) ── */ +.nav-hamburger{display:none;background:none;border:none;color:white;font-size:22px; + cursor:pointer;padding:4px 10px;margin-right:2px;border-radius:6px;line-height:1;} +.nav-hamburger:hover{background:rgba(255,255,255,.12);} + +/* ── 모바일 오버레이 ── */ +.mobile-nav-overlay{display:none;position:fixed;inset:54px 0 0 0; + background:rgba(0,0,0,.45);z-index:299;} + /* ── SIDEBAR (admin/mechanic) ── */ .layout{display:flex;min-height:calc(100vh - 54px);} .sidebar{background:var(--navy2);width:200px;flex-shrink:0;padding:16px 0;} @@ -139,10 +148,38 @@ textarea{resize:vertical;min-height:80px;} .spinner{display:inline-block;width:18px;height:18px;border:2px solid var(--gray3);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;} @keyframes spin{to{transform:rotate(360deg);}} +/* ── 2컬럼 상세 그리드 ── */ +.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;} + /* ── RESPONSIVE ── */ @media(max-width:768px){ .form-row,.form-row-3{grid-template-columns:1fr;} - .sidebar{display:none;} .main{padding:16px;} .stats{grid-template-columns:repeat(2,1fr);} + .detail-grid{grid-template-columns:1fr;} + + /* nav: sticky → fixed (가로 오버플로우 시 body 넓이에 끌려 햄버거 버튼이 밀려나는 문제 방지) */ + .nav{position:fixed;left:0;width:100%;box-sizing:border-box;} + body{padding-top:54px;} + + /* 사이드바 → 슬라이드 드로어 */ + .sidebar{ + position:fixed;top:54px;left:0;bottom:0; + width:220px;z-index:300;overflow-y:auto; + transform:translateX(-100%); + transition:transform .25s ease; + box-shadow:none; + } + .sidebar.mobile-open{ + transform:translateX(0); + box-shadow:4px 0 28px rgba(0,0,0,.45); + } + .mobile-nav-overlay.show{display:block;} + .nav-hamburger{display:inline-flex;align-items:center;} } + +/* ── 정비사 모바일 탭 바 ── */ +.mech-tab-bar{display:none;background:var(--navy2);position:sticky;top:54px;z-index:200;border-bottom:1px solid rgba(255,255,255,.15);} +.mech-tab-bar a{flex:1;display:flex;flex-direction:column;align-items:center;padding:8px 4px 7px;color:rgba(255,255,255,.6);text-decoration:none;font-size:11px;border-bottom:3px solid transparent;transition:all .15s;gap:1px;line-height:1.4;} +.mech-tab-bar a:hover,.mech-tab-bar a.active{color:white;border-bottom-color:var(--accent);background:rgba(255,255,255,.06);} +@media(max-width:768px){.mech-tab-bar{display:flex;}} diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index 70b824d..cd51dd4 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -66,7 +66,7 @@ const API = (() => { post: (path, body) => req('POST', path, body, body instanceof FormData), put: (path, body) => req('PUT', path, body, body instanceof FormData), patch: (path, body) => req('PATCH', path, body, body instanceof FormData), - delete: (path) => req('DELETE', path), + delete: (path, body) => req('DELETE', path, body ?? null), download, }; })(); diff --git a/frontend/static/js/auth.js b/frontend/static/js/auth.js index fd3233a..58e4968 100644 --- a/frontend/static/js/auth.js +++ b/frontend/static/js/auth.js @@ -33,10 +33,41 @@ const Auth = (() => { function renderNav(el) { if (!el) return; el.innerHTML = ` + ${name()} [${role()}] 로그아웃 `; + + // 모바일 오버레이 삽입 (중복 방지) + if (!document.getElementById('mobileNavOverlay')) { + const ov = document.createElement('div'); + ov.id = 'mobileNavOverlay'; + ov.className = 'mobile-nav-overlay'; + ov.addEventListener('click', closeMobileNav); + document.body.appendChild(ov); + } + + // 사이드바 링크 클릭 시 드로어 닫기 + setTimeout(() => { + document.querySelectorAll('.sidebar a').forEach(a => { + a.addEventListener('click', closeMobileNav); + }); + }, 0); + } + + function toggleMobileNav() { + const sidebar = document.querySelector('.sidebar'); + const overlay = document.getElementById('mobileNavOverlay'); + if (!sidebar) return; + const opening = !sidebar.classList.contains('mobile-open'); + sidebar.classList.toggle('mobile-open', opening); + overlay?.classList.toggle('show', opening); + } + + function closeMobileNav() { + document.querySelector('.sidebar')?.classList.remove('mobile-open'); + document.getElementById('mobileNavOverlay')?.classList.remove('show'); } function statusBadge(status) { @@ -59,5 +90,6 @@ const Auth = (() => { return new Date(dt).toLocaleString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); } - return { save, token, role, name, uid, logout, require, renderNav, statusBadge, costStatusBadge, fmtDt }; + return { save, token, role, name, uid, logout, require, renderNav, + toggleMobileNav, closeMobileNav, statusBadge, costStatusBadge, fmtDt }; })(); diff --git a/frontend/static/pages/admin/accounts.html b/frontend/static/pages/admin/accounts.html index 7742b25..9be1b26 100644 --- a/frontend/static/pages/admin/accounts.html +++ b/frontend/static/pages/admin/accounts.html @@ -1,4 +1,10 @@ -계정 관리 +계정 관리 +
@@ -11,15 +17,40 @@ 🔧 개선항목 ⚡ 충전기 관리 🏷 충전기 종류 + 📝 유형관리 📷 QR 생성 👥 계정 관리 ⚙️ 설정
-
+

계정 관리

- +
+ + +
+ + + +
- + + + +
ID아이디역할이름회사/제조사전화번호상태수정
ID아이디역할이름회사/제조사전화번호상태수정
@@ -70,16 +104,86 @@ diff --git a/frontend/static/pages/admin/charger-types.html b/frontend/static/pages/admin/charger-types.html index 694f259..41cd05a 100644 --- a/frontend/static/pages/admin/charger-types.html +++ b/frontend/static/pages/admin/charger-types.html @@ -4,6 +4,14 @@ 충전기 종류 관리 +
- +
등록된 충전기 종류
@@ -57,6 +66,7 @@ 종류명 설명 충전기 수 + 에러코드 수정 삭제 @@ -68,6 +78,62 @@ 등록된 충전기 종류가 없습니다.
+ + +
+
+
에러코드 관리
+ +
+ + +
+ + + + + + + + + + + + + +
코드에러명진단조건자동복구순서수정삭제
+
+ + + +
+
+ 에러코드 추가
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
@@ -77,6 +143,9 @@ Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser')); +let selectedTypeId = null; +let selectedTypeName = ''; + async function load() { const types = await API.get('/chargers/types'); const tbody = document.getElementById('tbody'); @@ -87,6 +156,11 @@ async function load() { ${t.name} ${t.description || '-'} ${t.charger_count}개 + + + + + `; +} + +function startEditError(id, code, name, cond, autoRec, order) { + document.getElementById(`err-row-${id}`).outerHTML = ` + + + + + + + + + + + `; +} + +async function saveEditError(id) { + const fd = new FormData(); + fd.append('error_code', document.getElementById(`ec-code-${id}`).value.trim()); + fd.append('error_name', document.getElementById(`ec-name-${id}`).value.trim()); + fd.append('range_condition',document.getElementById(`ec-cond-${id}`).value.trim()); + fd.append('auto_recovery', document.getElementById(`ec-auto-${id}`).checked); + fd.append('display_order', document.getElementById(`ec-order-${id}`).value); + if (!fd.get('error_code') || !fd.get('error_name')) { alert('코드와 에러명은 필수입니다.'); return; } + await API.put(`/chargers/types/${selectedTypeId}/errors/${id}`, fd); + await loadErrors(); +} + +async function deleteError(id) { + if (!confirm('에러코드를 삭제하시겠습니까?')) return; + await API.delete(`/chargers/types/${selectedTypeId}/errors/${id}`); + await loadErrors(); +} + +async function addError() { + const code = document.getElementById('newCode').value.trim(); + const name = document.getElementById('newName').value.trim(); + const cond = document.getElementById('newCond').value.trim(); + const order = document.getElementById('newOrder').value; + const auto = document.getElementById('newAutoRecovery').checked; + const errEl = document.getElementById('errFormErr'); + errEl.style.display = 'none'; + if (!code || !name) { errEl.textContent = '코드와 에러명은 필수입니다.'; errEl.style.display = 'block'; return; } + const fd = new FormData(); + fd.append('error_code', code); fd.append('error_name', name); + fd.append('range_condition', cond); fd.append('auto_recovery', auto); + fd.append('display_order', order); + try { + await API.post(`/chargers/types/${selectedTypeId}/errors`, fd); + document.getElementById('newCode').value = ''; + document.getElementById('newName').value = ''; + document.getElementById('newCond').value = ''; + document.getElementById('newOrder').value = '0'; + document.getElementById('newAutoRecovery').checked = true; + await loadErrors(); + } catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; } +} + +/* ── 종류 수정 모드 진입 ── */ function startEdit(id, name, desc) { document.getElementById('editId').value = id; document.getElementById('typeName').value = name; @@ -114,12 +283,10 @@ function startEdit(id, name, desc) { document.getElementById('submitBtn').className = 'btn btn-accent'; document.getElementById('cancelBtn').style.display = 'inline-flex'; document.getElementById('formErr').style.display = 'none'; - // 폼으로 스크롤 document.getElementById('typeName').focus(); window.scrollTo({ top: 0, behavior: 'smooth' }); } -/* ── 수정 취소 ── */ function cancelEdit() { document.getElementById('editId').value = ''; document.getElementById('typeName').value = ''; @@ -131,47 +298,26 @@ function cancelEdit() { document.getElementById('formErr').style.display = 'none'; } -/* ── 추가 / 수정 공통 제출 ── */ async function submitForm() { const id = document.getElementById('editId').value; const name = document.getElementById('typeName').value.trim(); const desc = document.getElementById('typeDesc').value.trim(); const errEl = document.getElementById('formErr'); errEl.style.display = 'none'; - - if (!name) { - errEl.textContent = '종류명을 입력하세요.'; - errEl.style.display = 'block'; - return; - } - + if (!name) { errEl.textContent = '종류명을 입력하세요.'; errEl.style.display = 'block'; return; } const fd = new FormData(); - fd.append('name', name); - fd.append('description', desc); - + fd.append('name', name); fd.append('description', desc); try { - if (id) { - await API.put('/chargers/types/' + id, fd); - } else { - await API.post('/chargers/types', fd); - } - cancelEdit(); - load(); - } catch(e) { - errEl.textContent = e.message; - errEl.style.display = 'block'; - } + if (id) { await API.put('/chargers/types/' + id, fd); } + else { await API.post('/chargers/types', fd); } + cancelEdit(); load(); + } catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; } } -/* ── 삭제 ── */ async function del(id) { if (!confirm('삭제하시겠습니까?')) return; - try { - await API.delete('/chargers/types/' + id); - load(); - } catch(e) { - alert(e.message); - } + try { await API.delete('/chargers/types/' + id); load(); } + catch(e) { alert(e.message); } } load(); diff --git a/frontend/static/pages/admin/chargers.html b/frontend/static/pages/admin/chargers.html index 506bb08..aced9e1 100644 --- a/frontend/static/pages/admin/chargers.html +++ b/frontend/static/pages/admin/chargers.html @@ -4,6 +4,37 @@ 충전기 관리 + + @@ -17,23 +48,54 @@ 🔧 개선항목 ⚡ 충전기 관리 🏷 충전기 종류 + 📝 유형관리 📷 QR 생성 👥 계정 관리 ⚙️ 설정
-
+

충전기 관리

- +
+ +
+ + +
+ +
-
+ + +
+
+ + +
- + + + +
ID종류충전기명충전소CPO설치일미처리QR수정
ID종류충전기명충전소CPO설치일미처리QR수정
+ + +
+
+
+
@@ -65,17 +127,89 @@
+ + diff --git a/frontend/static/pages/admin/costs.html b/frontend/static/pages/admin/costs.html index a3682fb..a77b7ad 100644 --- a/frontend/static/pages/admin/costs.html +++ b/frontend/static/pages/admin/costs.html @@ -4,6 +4,12 @@ 출장비 관리 + @@ -17,6 +23,7 @@ 🔧 개선항목 ⚡ 충전기 관리 🏷 충전기 종류 + 📝 유형관리 📷 QR 생성 👥 계정 관리 ⚙️ 설정 @@ -24,7 +31,10 @@

출장비 관리

- +
+ + +
@@ -48,7 +58,10 @@
- + + + +
신고#충전기충전소정비사부담주체금액상태처리일시
신고#충전기충전소정비사부담주체금액상태처리일시
@@ -59,6 +72,28 @@ + + + + + + + + + + diff --git a/frontend/static/pages/admin/improvement-detail.html b/frontend/static/pages/admin/improvement-detail.html index c5ecca0..50b1d13 100644 --- a/frontend/static/pages/admin/improvement-detail.html +++ b/frontend/static/pages/admin/improvement-detail.html @@ -1,12 +1,28 @@ 개선항목 상세 -
-
- ← 목록 -

개선항목 상세

+ +
diff --git a/frontend/static/pages/admin/improvements.html b/frontend/static/pages/admin/improvements.html index 0cb0409..1d2dd52 100644 --- a/frontend/static/pages/admin/improvements.html +++ b/frontend/static/pages/admin/improvements.html @@ -1,4 +1,10 @@ -개선항목 관리 +개선항목 관리 +
@@ -11,6 +17,7 @@ 🔧 개선항목 ⚡ 충전기 관리 🏷 충전기 종류 + 📝 유형관리 📷 QR 생성 👥 계정 관리 ⚙️ 설정 @@ -18,7 +25,8 @@

개선항목 관리

-
+
+
@@ -35,7 +43,10 @@
- + + + +
#제목분류우선순위담당제조사연결AS상태등록일SW배포일
#제목분류우선순위담당제조사연결AS상태등록일SW배포일
@@ -90,6 +101,28 @@ + + + diff --git a/frontend/static/pages/admin/qr.html b/frontend/static/pages/admin/qr.html index 2686548..9f01eb3 100644 --- a/frontend/static/pages/admin/qr.html +++ b/frontend/static/pages/admin/qr.html @@ -11,6 +11,7 @@ 🔧 개선항목 ⚡ 충전기 관리 🏷 충전기 종류 + 📝 유형관리 📷 QR 생성 👥 계정 관리 ⚙️ 설정 diff --git a/frontend/static/pages/admin/report-detail.html b/frontend/static/pages/admin/report-detail.html index 3c5a8d7..af5ddec 100644 --- a/frontend/static/pages/admin/report-detail.html +++ b/frontend/static/pages/admin/report-detail.html @@ -4,6 +4,8 @@ 신고 상세 + + @@ -111,16 +121,33 @@ ⚡ EV AS 관리 — 관리자 -
-
- ← 목록 -

신고 상세

+ + +
+
diff --git a/frontend/static/pages/admin/reports.html b/frontend/static/pages/admin/reports.html index 1f3a826..5be9362 100644 --- a/frontend/static/pages/admin/reports.html +++ b/frontend/static/pages/admin/reports.html @@ -4,6 +4,40 @@ 신고 목록 + + @@ -17,6 +51,7 @@ 🔧 개선항목 ⚡ 충전기 관리 🏷 충전기 종류 + 📝 유형관리 📷 QR 생성 👥 계정 관리 ⚙️ 설정 @@ -24,10 +59,19 @@

AS 신고 목록

- +
+ + +
+ + +
+
-
-
+ + +
+
+
+
+ + +
- + + + + + +
#충전기ID충전소종류문제유형신고일시상태정비사
#충전기ID충전소종류문제유형신고일시신고자상태정비사
- + +
+ + +
+
+ + diff --git a/frontend/static/pages/admin/settings.html b/frontend/static/pages/admin/settings.html index efaee82..a5bd2b5 100644 --- a/frontend/static/pages/admin/settings.html +++ b/frontend/static/pages/admin/settings.html @@ -36,6 +36,7 @@ 🔧 개선항목 ⚡ 충전기 관리 🏷 충전기 종류 + 📝 유형관리 📷 QR 생성 👥 계정 관리 ⚙️ 설정 @@ -69,6 +70,103 @@
+ +
+
⏱ 처리시간 지표 기준
+
+ 대시보드의 처리시간 평균대기 심각도 지표를 계산할 때
+ 시작 시점으로 사용할 기준을 선택합니다. +
+
+ + +
+
+ + +
+
🏢 처리시간 집계 방식
+
+ 대기·처리시간 지표를 산출할 때 공휴일·주말을 처리하는 방식을 선택합니다. +
+
+ + + +
+ + + +
+ + + +
🖼️ 사진 업로드 압축 설정
@@ -203,9 +301,20 @@ function updateEffect() { async function load() { const s = await API.get('/settings'); const policy = s.report_visibility_policy || 'immediate'; - document.querySelector(`input[value="${policy}"]`).checked = true; + document.querySelector(`input[name="policy"][value="${policy}"]`).checked = true; updateLabels(); + const timeBase = s.time_metric_base || 'occurred'; + document.querySelector(`input[name="timeBase"][value="${timeBase}"]`).checked = true; + updateTimeBaseLabels(); + + const wtMode = ['off','holiday_24h','worktime'].includes(s.time_metric_worktime) + ? s.time_metric_worktime + : (s.time_metric_worktime === 'true' ? 'worktime' : 'off'); + const wtRadio = document.querySelector(`input[name="worktimeMode"][value="${wtMode}"]`); + if (wtRadio) wtRadio.checked = true; + updateWorktimeModeLabels(); + const enabled = s.image_compress_enabled !== 'false'; document.getElementById('compressEnabled').checked = enabled; @@ -227,12 +336,22 @@ function updateLabels() { lbl.style.background = r.checked ? '#E3EDFF' : 'white'; }); } +function updateTimeBaseLabels() { + document.querySelectorAll('input[name="timeBase"]').forEach(r => { + const lbl = r.closest('label'); + lbl.style.borderColor = r.checked ? 'var(--accent)' : 'var(--gray3)'; + lbl.style.background = r.checked ? '#E3EDFF' : 'white'; + }); +} document.querySelectorAll('input[name="policy"]').forEach(r => r.addEventListener('change', updateLabels)); +document.querySelectorAll('input[name="timeBase"]').forEach(r => r.addEventListener('change', updateTimeBaseLabels)); document.getElementById('compressEnabled').addEventListener('change', updateEffect); async function saveAll() { const fd = new FormData(); fd.append('report_visibility_policy', document.querySelector('input[name="policy"]:checked').value); + fd.append('time_metric_base', document.querySelector('input[name="timeBase"]:checked').value); + fd.append('time_metric_worktime', document.querySelector('input[name="worktimeMode"]:checked').value); fd.append('image_compress_enabled', document.getElementById('compressEnabled').checked ? 'true' : 'false'); fd.append('image_max_px', document.getElementById('maxPx').value); fd.append('image_quality', document.getElementById('quality').value); @@ -263,6 +382,100 @@ async function changePw() { } catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; } } +// ── 처리시간 집계 방식 ── +function updateWorktimeModeLabels() { + document.querySelectorAll('input[name="worktimeMode"]').forEach(r => { + const lbl = r.closest('label'); + lbl.style.borderColor = r.checked ? 'var(--accent)' : 'var(--gray3)'; + lbl.style.background = r.checked ? '#E3EDFF' : 'white'; + }); + const mode = document.querySelector('input[name="worktimeMode"]:checked')?.value || 'off'; + const showHoliday = mode === 'holiday_24h' || mode === 'worktime'; + document.getElementById('holidaySection').style.display = showHoliday ? 'block' : 'none'; + if (showHoliday && !document.getElementById('holidayYear').options.length) initHolidayYear(); +} + +function initHolidayYear() { + const sel = document.getElementById('holidayYear'); + const cur = new Date().getFullYear(); + for (let y = cur + 1; y >= cur - 2; y--) { + const opt = document.createElement('option'); + opt.value = y; opt.textContent = y + '년'; + if (y === cur) opt.selected = true; + sel.appendChild(opt); + } + loadHolidays(); +} + +async function loadHolidays() { + const year = document.getElementById('holidayYear').value; + const list = await API.get('/holidays?year=' + year); + const el = document.getElementById('holidayList'); + if (!list.length) { + el.innerHTML = '
등록된 공휴일이 없습니다.
'; + return; + } + el.innerHTML = ` + + + + + + ${list.map(h => ` + + + + + `).join('')} +
날짜공휴일명
${h.date}${h.name} + +
`; +} + +function openHolidayModal() { + document.getElementById('holidayModal').classList.remove('hidden'); + document.getElementById('hErr').style.display = 'none'; + document.getElementById('hDate').value = ''; + document.getElementById('hName').value = ''; +} +function closeHolidayModal() { document.getElementById('holidayModal').classList.add('hidden'); } + +async function saveHoliday() { + const d = document.getElementById('hDate').value; + const n = document.getElementById('hName').value.trim(); + const errEl = document.getElementById('hErr'); + if (!d || !n) { errEl.textContent = '날짜와 공휴일명을 입력하세요.'; errEl.style.display = 'block'; return; } + try { + const fd = new FormData(); fd.append('holiday_date', d); fd.append('name', n); + await API.post('/holidays', fd); + closeHolidayModal(); loadHolidays(); + } catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; } +} + +async function deleteHoliday(date) { + if (!confirm(`${date} 공휴일을 삭제하시겠습니까?`)) return; + await API.delete('/holidays/' + date); + loadHolidays(); +} + +// 고정 공휴일 (양력) 일괄 추가 +async function addFixedHolidays() { + const year = parseInt(document.getElementById('holidayYear').value); + const fixed = [ + { date: `${year}-01-01`, name: '신정' }, + { date: `${year}-03-01`, name: '삼일절' }, + { date: `${year}-05-05`, name: '어린이날' }, + { date: `${year}-06-06`, name: '현충일' }, + { date: `${year}-08-15`, name: '광복절' }, + { date: `${year}-10-03`, name: '개천절' }, + { date: `${year}-10-09`, name: '한글날' }, + { date: `${year}-12-25`, name: '성탄절' }, + ]; + const res = await API.post('/holidays/bulk', fixed); + alert(`${res.added}개 고정 공휴일이 추가되었습니다.\n설날·추석·부처님오신날·대체공휴일은 직접 추가해 주세요.`); + loadHolidays(); +} + load(); diff --git a/frontend/static/pages/login.html b/frontend/static/pages/login.html index 16582af..1febdf4 100644 --- a/frontend/static/pages/login.html +++ b/frontend/static/pages/login.html @@ -5,13 +5,36 @@ 로그인 — EV AS 관리 @@ -20,20 +43,81 @@ body{display:flex;align-items:center;justify-content:center;min-height:100vh;bac

⚡ EV AS 관리

cs.byunc.com

-
- - + +
+ +
-
- - + + +
+
+ + +
+
+ + +
+
+
-
- + + +
+
+ 📌 정비사 계정으로 가입됩니다.
+ 가입 후 관리자 승인이 완료되어야 로그인 가능합니다. +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ diff --git a/frontend/static/pages/mechanic/dashboard.html b/frontend/static/pages/mechanic/dashboard.html index 045766a..885668a 100644 --- a/frontend/static/pages/mechanic/dashboard.html +++ b/frontend/static/pages/mechanic/dashboard.html @@ -4,12 +4,57 @@ 정비사 대시보드 + + +
-
+ -
-
- - + + +
+ + +
+
+ + +
+
+
+ + +
+
+
@@ -37,23 +95,68 @@
- + +
+ + +
+
- + + + + diff --git a/frontend/static/pages/mechanic/history.html b/frontend/static/pages/mechanic/history.html new file mode 100644 index 0000000..22b5106 --- /dev/null +++ b/frontend/static/pages/mechanic/history.html @@ -0,0 +1,144 @@ + + + + +처리 이력 + + + + + + +
+ +
+

내 처리 이력

+ +
+ + +
+ +
이력을 불러오는 중...
+ +
+ +
+
+ + + + + + diff --git a/frontend/static/pages/mechanic/repair.html b/frontend/static/pages/mechanic/repair.html index 09de9e6..65d6bd5 100644 --- a/frontend/static/pages/mechanic/repair.html +++ b/frontend/static/pages/mechanic/repair.html @@ -17,7 +17,20 @@ ⚡ EV AS 관리 -
+ +
+ +
+
@@ -34,10 +47,7 @@
- - - - +
불러오는 중...
@@ -46,6 +56,13 @@
+ +
+ 📌 촬영 필수 항목
+ · 충전기 명판(제조사·모델명)충전기 식별 ID가 선명하게 보이도록 촬영해 주세요.
+ · 조치 전·후 상태를 각각 촬영하면 검증에 도움이 됩니다. +
+
@@ -64,23 +81,50 @@
-
- - -
-
🕐 조치 시작 시간: (자동 기록)
+
+ 📍 위치 정보 수집 중... +
+ + + - + + +
+
💾 저장 방식 선택
+
+ + +
+
+
+ + +
+
+
+ ✅ 조치 완료 저장은 처리 완료로 확정됩니다.
+ 💾 상태 저장은 왼쪽 상태로 임시 저장됩니다. +
+
+
+
-
+
+
+
@@ -89,16 +133,20 @@ Auth.require(['mechanic','admin']); Auth.renderNav(document.getElementById('navUser')); -const params = new URLSearchParams(location.search); -const chargerId = params.get('charger_id'); +const params = new URLSearchParams(location.search); +const repairId = params.get('repair_id'); // 편집 모드 +const chargerId = params.get('charger_id'); // 신규 모드 const initReportId = params.get('report_id'); -const startTime = new Date(); +const isEditMode = !!repairId; + +const startTime = new Date(); document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR'); const selectedReports = new Set(); if (initReportId) selectedReports.add(parseInt(initReportId)); -async function load() { +// ── 신규 모드 ── +async function loadCreate() { const charger = await API.get('/chargers/' + chargerId); document.getElementById('chargerCard').innerHTML = `
⚡ 충전기 정보
@@ -106,79 +154,219 @@ async function load() {
${charger.id}
${charger.name}
${charger.station_name}
-
${charger.cpo_name || '-'}
+
${charger.cpo_name||'-'}
`; - const reports = await API.get('/repairs/charger/' + chargerId + '/open'); - const list = document.getElementById('reportList'); - if (!reports.length) { - list.innerHTML = '
미처리 신고가 없습니다.
'; - return; - } + const list = document.getElementById('reportList'); + if (!reports.length) { list.innerHTML = '
미처리 신고가 없습니다.
'; return; } list.innerHTML = reports.map(r => ` `).join(''); } function toggleReport(id, checked, label) { - if (checked) { selectedReports.add(id); label.style.background = '#E3EDFF'; } - else { selectedReports.delete(id); label.style.background = 'white'; } + if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; } + else { selectedReports.delete(id); label.style.background='white'; } } +// ── 편집 모드 ── +async function loadEdit() { + let repair; + try { repair = await API.get('/repairs/' + repairId); } + catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; } + + // 헤더 업데이트 + document.querySelector('h2, .main h2') && (document.querySelector('.main > div > h2') || document.querySelector('h2'))?.remove?.(); + document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend', + `조치 수정 #${repair.id}`); + + // 충전기 카드 + document.getElementById('chargerCard').innerHTML = ` +
⚡ 충전기 정보
+
+
${repair.charger_id||'-'}
+
${repair.charger_name||'-'}
+
${repair.station_name||'-'}
+
`; + + // 연결된 신고 (읽기 전용) + document.getElementById('reportList').innerHTML = (repair.reports||[]).length + ? (repair.reports||[]).map(r => ` +
+ #${r.id} ${Auth.statusBadge(r.status)} +
${(r.issue_types||[]).join(', ')}
+
`).join('') + : '
연결된 신고 없음
'; + + // 승인 완료 → 잠금 + if (repair.approved_at) { + const dt = new Date(repair.approved_at).toLocaleString('ko-KR'); + document.querySelector('.card:last-child').innerHTML = ` +
+ ✅ 관리자 승인 완료 (${repair.approved_by_name||''} · ${dt})
+ 승인된 조치는 수정할 수 없습니다. +
+ ${renderRepairView(repair)}`; + return; + } + + // 폼 미리채우기 — 조치유형 동적 로드 후 체크 복원 + await loadRepairTypes(repair.repair_types || []); + document.getElementById('description').value = repair.description || ''; + const sel = document.getElementById('resultStatus'); + if (repair.result_status && sel.querySelector(`option[value="${repair.result_status}"]`)) + sel.value = repair.result_status; + + // 기존 사진 표시 + renderExistingPhotos(repair); +} + +function renderRepairView(r) { + const LABEL = {done:'✅ 완료',in_progress:'🔧 진행중',waiting:'⏳ 부품대기',revisit:'🔄 재방문'}; + const photoHtml = (type, list) => (list||[]).length + ? `
+
${(list||[]).map(p=>``).join('')}
` + : ''; + return `
+ + + + + +
조치유형${(r.repair_types||[]).join(', ')}
조치내용${r.description||'-'}
처리결과${LABEL[r.result_status]||r.result_status}
완료시각${Auth.fmtDt(r.completed_at)}
+ ${photoHtml('조치 전 사진', r.photos_before)} + ${photoHtml('조치 후 사진', r.photos_after)} +
`; +} + +function renderExistingPhotos(repair) { + const mkGrid = (list, type) => { + if (!list || !list.length) return ''; + return `
+ ${list.map(p => ` +
+ + +
`).join('')} +
`; + }; + 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')); +} + +async function deleteRepairPhoto(rId, pId) { + if (!confirm('이 사진을 삭제하시겠습니까?')) return; + try { + await API.delete(`/repairs/${rId}/photos/${pId}`); + location.reload(); + } catch(e) { alert(e.message); } +} + +// GPS 수집 +navigator.geolocation?.getCurrentPosition( + pos => { + 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 = + `📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`; + }, + () => { + document.getElementById('gpsStatus').className = 'alert alert-warn'; + document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다.'; + }, + { enableHighAccuracy: true, timeout: 10000 } +); + // 이미지 압축 + 다중 선택 프리뷰 ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore'); ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter'); -document.getElementById('submitBtn').addEventListener('click', async () => { - const rids = [...selectedReports]; - if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; } - +async function submitForm(isDone) { const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value); if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; } - const desc = document.getElementById('description').value.trim(); if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; } - document.getElementById('submitBtn').disabled = true; - document.getElementById('submitBtn').textContent = '저장 중...'; + if (!isEditMode) { + const rids = [...selectedReports]; + 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 = '저장 중...'; + + const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value; + const lat = document.getElementById('mechanicLat').value; + const lng = document.getElementById('mechanicLng').value; const fd = new FormData(); - fd.append('report_ids', JSON.stringify(rids)); fd.append('repair_types', JSON.stringify(types)); fd.append('description', desc); - fd.append('result_status', document.getElementById('resultStatus').value); + fd.append('result_status', resultStatus); + if (lat) fd.append('mechanic_lat', lat); + if (lng) fd.append('mechanic_lng', lng); Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f)); Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f)); try { - await API.post('/repairs', fd); - alert('✅ 조치 완료 저장되었습니다.'); - location.href = '/pages/mechanic/dashboard.html'; + if (isEditMode) { + await API.put('/repairs/' + repairId, fd); + alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.'); + location.href = '/pages/mechanic/history.html'; + } else { + fd.append('report_ids', JSON.stringify([...selectedReports])); + await API.post('/repairs', fd); + alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.'); + location.href = '/pages/mechanic/history.html'; + } } catch(e) { showErr(e.message); - document.getElementById('submitBtn').disabled = false; - document.getElementById('submitBtn').textContent = '조치 완료 저장'; + saveBtn.disabled = doneBtn.disabled = false; + saveBtn.textContent = '💾 상태 저장'; + doneBtn.textContent = '✅ 조치 완료 저장'; } -}); +} function showErr(msg) { const el = document.getElementById('formErr'); el.textContent = msg; el.style.display = 'block'; } -load(); +async function loadRepairTypes(preChecked = []) { + try { + const types = await API.get('/settings/repair-types'); + document.getElementById('repairTypes').innerHTML = types.map(t => ` + `).join(''); + } catch(e) { + document.getElementById('repairTypes').innerHTML = + '
조치유형을 불러오지 못했습니다.
'; + } +} + +if (isEditMode) { + loadEdit(); +} else { + loadRepairTypes(); + loadCreate(); +} diff --git a/frontend/static/pages/mechanic/scan.html b/frontend/static/pages/mechanic/scan.html index 4da8aa0..4a35596 100644 --- a/frontend/static/pages/mechanic/scan.html +++ b/frontend/static/pages/mechanic/scan.html @@ -6,24 +6,39 @@ -
-
충전기의 QR 코드를 카메라로 인식해 주세요.
-
- -
-
- -
- - + +
+ +
+
+

📷 QR 스캔

+
충전기의 QR 코드를 카메라로 인식해 주세요.
+
+ +
+
+ +
+ + +
+
diff --git a/frontend/static/pages/report.html b/frontend/static/pages/report.html index 9dffea6..b5d6b53 100644 --- a/frontend/static/pages/report.html +++ b/frontend/static/pages/report.html @@ -221,7 +221,8 @@ body { background: var(--gray1); }

🔴 문제 유형 * 1개 이상 선택

+
+

📡 신고 범위

+
+ + + +
+
+

📝 상세 설명 (선택)

@@ -294,7 +313,7 @@ body { background: var(--gray1); }