기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, UI 개선
## 처리시간 지표 - 업무시간 기준(09-18 평일) / 공휴일 제외 24h / 달력 기준 3가지 모드 선택 - 공휴일 DB 관리 (holidays 테이블, 수동 등록·삭제·일괄 추가) - 2026년 공휴일 등록 지원 - 설정 페이지에서 라디오 버튼으로 모드 선택 ## 대시보드 차트 - 월별 평균 처리시간 막대 차트 추가 - 월별 신고 접수 건수 누적 막대 차트 추가 - 월별 → 일별 드릴다운 (막대 클릭 시 해당 월의 일별 차트로 전환) - 일별 막대 클릭 시 처리 완료/신고 접수 상세 내역 모달 - 충전기별 누적 고장 건수 Top 10 수평 막대 차트 추가 ## 신고 목록 - # 컬럼을 DB PK 대신 현재 목록 순서(1, 2, 3…)로 표시 - 엑셀 export 접수번호도 순차번호로 변경 ## 모바일 네비게이션 버그 수정 - 모바일에서 가로 오버플로우 시 nav가 body 넓이로 늘어나 햄버거 버튼이 화면 밖으로 밀리는 문제 수정 - nav를 position:fixed + body padding-top:54px 로 변경 (전체 페이지 적용) - 충전기 관리·신고 목록 페이지 지도 컨테이너에 isolation:isolate 적용 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
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:
|
||||
|
||||
497
backend/main.py
497
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
68
backend/routers/holidays.py
Normal file
68
backend/routers/holidays.py
Normal file
@@ -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}
|
||||
@@ -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(""),
|
||||
|
||||
85
backend/routers/manufacturers.py
Normal file
85
backend/routers/manufacturers.py
Normal file
@@ -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}
|
||||
@@ -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),
|
||||
|
||||
@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)):
|
||||
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
|
||||
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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;}}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -33,10 +33,41 @@ const Auth = (() => {
|
||||
function renderNav(el) {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<button class="nav-hamburger" onclick="Auth.toggleMobileNav()" aria-label="메뉴">☰</button>
|
||||
<span class="nav-user">
|
||||
<span>${name()} <small style="color:var(--accent)">[${role()}]</small></span>
|
||||
<a onclick="Auth.logout()">로그아웃</a>
|
||||
</span>`;
|
||||
|
||||
// 모바일 오버레이 삽입 (중복 방지)
|
||||
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 };
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>계정 관리</title><link rel="stylesheet" href="/css/style.css"></head>
|
||||
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>계정 관리</title><link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
</style></head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
<div class="layout">
|
||||
@@ -11,15 +17,40 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html" class="active">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">계정 관리</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 비활성화 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 계정 생성</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 섹션 -->
|
||||
<div id="pendingSection" style="display:none;margin-bottom:20px;">
|
||||
<div class="card" style="border:2px solid #F59E0B;background:#FFFBEB;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
|
||||
<span style="font-size:18px">⏳</span>
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:700;color:#92400E">가입 승인 대기</div>
|
||||
<div style="font-size:12px;color:#B45309">승인 후 정비사 계정으로 이용 가능합니다.</div>
|
||||
</div>
|
||||
<span id="pendingBadge" style="margin-left:auto;background:#F59E0B;color:white;font-size:12px;font-weight:700;padding:3px 10px;border-radius:10px;"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>이름</th><th>아이디</th><th>회사명</th><th>전화번호</th><th>신청일시</th><th style="width:150px">처리</th></tr></thead>
|
||||
<tbody id="pendingTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;">
|
||||
<select id="fRole" onchange="load()" style="width:auto">
|
||||
@@ -28,7 +59,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="tbl-wrap"><table>
|
||||
<thead><tr><th>ID</th><th>아이디</th><th>역할</th><th>이름</th><th>회사/제조사</th><th>전화번호</th><th>상태</th><th>수정</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>ID</th><th>아이디</th><th>역할</th><th>이름</th><th>회사/제조사</th><th>전화번호</th><th>상태</th><th>수정</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -70,16 +104,86 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 계정 ${checked.length}개를 비활성화합니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/accounts/bulk', ids); load(); }
|
||||
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사'};
|
||||
|
||||
async function loadPending() {
|
||||
const all = await API.get('/accounts');
|
||||
const pending = all.filter(u => u.is_pending);
|
||||
const sec = document.getElementById('pendingSection');
|
||||
sec.style.display = pending.length ? 'block' : 'none';
|
||||
if (!pending.length) return;
|
||||
document.getElementById('pendingBadge').textContent = pending.length + '명 대기 중';
|
||||
document.getElementById('pendingTbody').innerHTML = pending.map(u => `
|
||||
<tr>
|
||||
<td><strong>${u.name}</strong></td>
|
||||
<td style="color:var(--gray4)">${u.username}</td>
|
||||
<td>${u.company ? `<span style="background:#EFF6FF;color:#1E40AF;font-size:11px;font-weight:600;padding:2px 8px;border-radius:8px">${u.company}</span>` : '<span style="color:var(--gray4)">-</span>'}</td>
|
||||
<td>${u.phone||'-'}</td>
|
||||
<td style="font-size:12px">${Auth.fmtDt(u.created_at)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-success btn-sm" onclick="approveUser(${u.id},'${u.name}')">✅ 승인</button>
|
||||
<button class="btn btn-sm" style="background:#fee2e2;color:#991b1b;border:none" onclick="rejectUser(${u.id},'${u.name}')">✕ 거절</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
async function approveUser(id, name) {
|
||||
if (!confirm(`"${name}" 계정을 승인하시겠습니까?\n승인 후 바로 로그인 가능합니다.`)) return;
|
||||
try {
|
||||
await API.patch('/accounts/'+id+'/approve');
|
||||
loadPending(); load();
|
||||
} catch(e) { alert('오류: '+e.message); }
|
||||
}
|
||||
|
||||
async function rejectUser(id, name) {
|
||||
if (!confirm(`"${name}" 가입 신청을 거절하고 계정을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
await API.delete('/accounts/'+id);
|
||||
loadPending(); load();
|
||||
} catch(e) { alert('오류: '+e.message); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const role = document.getElementById('fRole').value;
|
||||
const users = await API.get('/accounts'+(role?'?role='+role:''));
|
||||
document.getElementById('tbody').innerHTML = users.map(u=>`
|
||||
<tr><td>${u.id}</td><td>${u.username}</td><td>${ROLE_LABEL[u.role]||u.role}</td>
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
document.getElementById('tbody').innerHTML = users.filter(u => !u.is_pending).map(u=>`
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${u.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td>${u.id}</td><td>${u.username}</td><td>${ROLE_LABEL[u.role]||u.role}</td>
|
||||
<td>${u.name}</td><td>${u.company||'-'}</td><td>${u.phone||'-'}</td>
|
||||
<td><span class="badge ${u.is_active?'s-done':'s-waiting'}">${u.is_active?'활성':'비활성'}</span></td>
|
||||
<td><button class="btn btn-outline btn-sm" onclick="editUser(${u.id})">수정</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="delUser(${u.id})">삭제</button></td></tr>`).join('');
|
||||
<button class="btn btn-danger btn-sm" onclick="delUser(${u.id})">삭제</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
function openModal() { document.getElementById('modal').classList.remove('hidden'); document.getElementById('eId').value=''; document.getElementById('eUsername').disabled=false; document.getElementById('pwReq').style.display='inline'; }
|
||||
function closeModal() { document.getElementById('modal').classList.add('hidden'); document.getElementById('modalErr').style.display='none'; ['eUsername','ePassword','eName','eCompany','ePhone','eEmail'].forEach(id=>document.getElementById(id).value=''); }
|
||||
@@ -118,5 +222,6 @@ async function save() {
|
||||
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
|
||||
}
|
||||
async function delUser(id) { if(!confirm('비활성 처리하시겠습니까?')) return; await API.delete('/accounts/'+id); load(); }
|
||||
loadPending();
|
||||
load();
|
||||
</script></body></html>
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>충전기 종류 관리</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.err-panel { display:none; margin-top:0; }
|
||||
.err-panel.show { display:block; }
|
||||
.err-row-edit input { padding:4px 6px; font-size:12px; border:1px solid var(--gray3); border-radius:5px; }
|
||||
.err-row-edit input[type=number] { width:60px; }
|
||||
.err-row-edit input[type=text] { width:90px; }
|
||||
.err-row-edit input.wide { width:130px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
@@ -20,6 +28,7 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html" class="active">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
@@ -46,7 +55,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<!-- 종류 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-title">등록된 충전기 종류</div>
|
||||
<div class="tbl-wrap">
|
||||
@@ -57,6 +66,7 @@
|
||||
<th>종류명</th>
|
||||
<th>설명</th>
|
||||
<th>충전기 수</th>
|
||||
<th>에러코드</th>
|
||||
<th>수정</th>
|
||||
<th>삭제</th>
|
||||
</tr>
|
||||
@@ -68,6 +78,62 @@
|
||||
등록된 충전기 종류가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 에러코드 관리 패널 -->
|
||||
<div class="card err-panel" id="errPanel">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<div class="card-title" style="margin:0" id="errPanelTitle">에러코드 관리</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="closeErrPanel()">✕ 닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- 에러코드 목록 -->
|
||||
<div class="tbl-wrap" style="margin-bottom:16px">
|
||||
<table id="errTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>코드</th>
|
||||
<th>에러명</th>
|
||||
<th>진단조건</th>
|
||||
<th style="width:60px">자동복구</th>
|
||||
<th style="width:50px">순서</th>
|
||||
<th>수정</th>
|
||||
<th>삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="errEmpty" class="alert alert-info" style="display:none">등록된 에러코드가 없습니다.</div>
|
||||
|
||||
<!-- 에러코드 추가 폼 -->
|
||||
<div style="background:var(--gray1);border-radius:8px;padding:14px">
|
||||
<div style="font-size:13px;font-weight:700;color:var(--navy2);margin-bottom:10px">+ 에러코드 추가</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-bottom:8px">
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">코드 *</label>
|
||||
<input type="text" id="newCode" placeholder="12200" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">에러명 *</label>
|
||||
<input type="text" id="newName" placeholder="과전압" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">진단조건</label>
|
||||
<input type="text" id="newCond" placeholder=">275V" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--gray4)">표시순서</label>
|
||||
<input type="number" id="newOrder" value="0" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
||||
<input type="checkbox" id="newAutoRecovery" checked style="width:auto;accent-color:var(--accent)">
|
||||
<label for="newAutoRecovery" style="font-size:13px">자동복구 가능</label>
|
||||
</div>
|
||||
<div id="errFormErr" class="alert alert-danger" style="display:none;margin-bottom:8px"></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="addError()">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<td><strong>${t.name}</strong></td>
|
||||
<td>${t.description || '-'}</td>
|
||||
<td>${t.charger_count}개</td>
|
||||
<td>
|
||||
<button class="btn btn-outline btn-sm" onclick="openErrPanel(${t.id}, '${escQ(t.name)}')">
|
||||
📋 에러코드
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline btn-sm" onclick="startEdit(${t.id}, '${escQ(t.name)}', '${escQ(t.description||'')}')">
|
||||
수정
|
||||
@@ -101,10 +175,105 @@ async function load() {
|
||||
}
|
||||
|
||||
function escQ(str) {
|
||||
return str.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
return String(str).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
|
||||
}
|
||||
function escH(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/* ── 수정 모드 진입 ── */
|
||||
/* ── 에러코드 패널 ── */
|
||||
async function openErrPanel(typeId, typeName) {
|
||||
selectedTypeId = typeId;
|
||||
selectedTypeName = typeName;
|
||||
document.getElementById('errPanelTitle').textContent = `에러코드 관리 — ${typeName}`;
|
||||
document.getElementById('errPanel').classList.add('show');
|
||||
document.getElementById('errPanel').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
await loadErrors();
|
||||
}
|
||||
|
||||
function closeErrPanel() {
|
||||
document.getElementById('errPanel').classList.remove('show');
|
||||
selectedTypeId = null;
|
||||
}
|
||||
|
||||
async function loadErrors() {
|
||||
const errors = await API.get(`/chargers/types/${selectedTypeId}/errors`);
|
||||
const tbody = document.getElementById('errTbody');
|
||||
document.getElementById('errEmpty').style.display = errors.length ? 'none' : 'block';
|
||||
tbody.innerHTML = errors.map(e => renderErrorRow(e)).join('');
|
||||
}
|
||||
|
||||
function renderErrorRow(e) {
|
||||
return `
|
||||
<tr id="err-row-${e.id}">
|
||||
<td><strong>${escH(e.error_code)}</strong></td>
|
||||
<td>${escH(e.error_name)}</td>
|
||||
<td style="font-size:12px;color:var(--gray4)">${escH(e.range_condition||'')}</td>
|
||||
<td style="text-align:center">${e.auto_recovery ? '✅' : '❌'}</td>
|
||||
<td style="text-align:center">${e.display_order}</td>
|
||||
<td><button class="btn btn-outline btn-sm" onclick="startEditError(${e.id}, '${escQ(e.error_code)}', '${escQ(e.error_name)}', '${escQ(e.range_condition||'')}', ${e.auto_recovery}, ${e.display_order})">수정</button></td>
|
||||
<td><button class="btn btn-danger btn-sm" onclick="deleteError(${e.id})">삭제</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function startEditError(id, code, name, cond, autoRec, order) {
|
||||
document.getElementById(`err-row-${id}`).outerHTML = `
|
||||
<tr id="err-row-${id}" class="err-row-edit">
|
||||
<td><input type="text" id="ec-code-${id}" value="${escH(code)}" placeholder="코드"></td>
|
||||
<td><input type="text" class="wide" id="ec-name-${id}" value="${escH(name)}" placeholder="에러명"></td>
|
||||
<td><input type="text" class="wide" id="ec-cond-${id}" value="${escH(cond)}" placeholder="진단조건"></td>
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" id="ec-auto-${id}" ${autoRec?'checked':''} style="width:auto;accent-color:var(--accent)">
|
||||
</td>
|
||||
<td><input type="number" id="ec-order-${id}" value="${order}"></td>
|
||||
<td><button class="btn btn-primary btn-sm" onclick="saveEditError(${id})">저장</button></td>
|
||||
<td><button class="btn btn-outline btn-sm" onclick="loadErrors()">취소</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -4,6 +4,37 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>충전기 관리</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
|
||||
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
|
||||
.view-btn { padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white; cursor:pointer; color:var(--gray4); transition:all .15s; }
|
||||
.view-btn.active { background:var(--navy); color:white; }
|
||||
|
||||
#mapWrap {
|
||||
display:none;
|
||||
height: calc(100vh - 220px);
|
||||
min-height: 420px;
|
||||
border-radius:10px;
|
||||
overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
margin-top:12px;
|
||||
isolation: isolate;
|
||||
}
|
||||
#chargerMap { width:100%; height:100%; }
|
||||
|
||||
.ck-pin {
|
||||
width:28px; height:28px; border-radius:50% 50% 50% 0;
|
||||
transform:rotate(-45deg); border:3px solid white;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||
}
|
||||
.ck-pin.fault { background:#EF4444; }
|
||||
.ck-pin.normal { background:#22C55E; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
@@ -17,23 +48,54 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html" class="active">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">충전기 관리</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
|
||||
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 충전기 등록</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
</div>
|
||||
|
||||
<!-- 목록 뷰 -->
|
||||
<div id="listWrap" class="card">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap">
|
||||
<input type="text" id="searchInput" placeholder="충전기ID / 충전소명 / CPO 검색..." style="flex:1;min-width:180px;padding:7px 10px;border:1px solid var(--gray3);border-radius:7px;font-size:13px;outline:none">
|
||||
<select id="filterFault" onchange="renderTable()" style="width:auto">
|
||||
<option value="">전체</option>
|
||||
<option value="fault">미처리 있음</option>
|
||||
<option value="ok">정상</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 지도 뷰 -->
|
||||
<div id="mapWrap">
|
||||
<div id="chargerMap"></div>
|
||||
</div>
|
||||
<div id="mapLegend" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:16px;flex-wrap:wrap">
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>미처리 신고 있음</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#22C55E;margin-right:4px"></span>정상</span>
|
||||
<span id="noGpsNote" style="color:var(--gray4)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,17 +127,89 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
let types = [], isEdit = false;
|
||||
|
||||
let allChargers = [];
|
||||
let types = [];
|
||||
let curView = 'list';
|
||||
let chargerMap = null;
|
||||
let mapMarkers = [];
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
function setView(v) {
|
||||
curView = v;
|
||||
document.getElementById('btnList').classList.toggle('active', v === 'list');
|
||||
document.getElementById('btnMap').classList.toggle('active', v === 'map');
|
||||
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
|
||||
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
|
||||
document.getElementById('mapLegend').style.display = v === 'map' ? 'flex' : 'none';
|
||||
document.getElementById('btnDelete').style.display = v === 'map' ? 'none' :
|
||||
(document.querySelectorAll('.row-chk:checked').length > 0 ? 'inline-flex' : 'none');
|
||||
if (v === 'map') {
|
||||
initChargerMap();
|
||||
renderChargerMap();
|
||||
setTimeout(() => chargerMap && chargerMap.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 충전기 ${checked.length}대를 삭제합니다. 신고 내역이 있는 충전기는 삭제되지 않습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => c.dataset.id);
|
||||
try { await API.delete('/chargers/bulk', ids); load(); }
|
||||
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
async function load() {
|
||||
[types] = await Promise.all([API.get('/chargers/types')]);
|
||||
const chargers = await API.get('/chargers');
|
||||
[types, allChargers] = await Promise.all([
|
||||
API.get('/chargers/types'),
|
||||
API.get('/chargers'),
|
||||
]);
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
document.getElementById('fTypeId').innerHTML = types.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
|
||||
document.getElementById('tbody').innerHTML = chargers.map(c => `
|
||||
renderTable();
|
||||
if (curView === 'map') renderChargerMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderTable() {
|
||||
const q = document.getElementById('searchInput').value.trim().toLowerCase();
|
||||
const fault = document.getElementById('filterFault').value;
|
||||
const rows = allChargers.filter(c => {
|
||||
if (q && !c.id.toLowerCase().includes(q) &&
|
||||
!c.station_name.toLowerCase().includes(q) &&
|
||||
!(c.cpo_name||'').toLowerCase().includes(q) &&
|
||||
!c.name.toLowerCase().includes(q)) return false;
|
||||
if (fault === 'fault' && c.pending_reports === 0) return false;
|
||||
if (fault === 'ok' && c.pending_reports > 0) return false;
|
||||
return true;
|
||||
});
|
||||
document.getElementById('tbody').innerHTML = rows.map(c => `
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${c.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td><strong>${c.id}</strong></td>
|
||||
<td>${c.charger_type||'-'}</td>
|
||||
<td>${c.name}</td>
|
||||
@@ -87,10 +221,85 @@ async function load() {
|
||||
<td><button class="btn btn-outline btn-sm" onclick="editCharger('${c.id}')">수정</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('searchInput').addEventListener('input', renderTable);
|
||||
});
|
||||
|
||||
function openModal(id=null) { isEdit=!!id; document.getElementById('modal').classList.remove('hidden'); document.getElementById('modalTitle').textContent = id?'충전기 수정':'충전기 등록'; }
|
||||
// ── 지도 초기화 ──
|
||||
function initChargerMap() {
|
||||
if (chargerMap) return;
|
||||
chargerMap = L.map('chargerMap', { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(chargerMap);
|
||||
}
|
||||
|
||||
// ── 지도 마커 렌더 ──
|
||||
function renderChargerMap() {
|
||||
if (!chargerMap) return;
|
||||
mapMarkers.forEach(m => m.remove());
|
||||
mapMarkers = [];
|
||||
|
||||
const visible = allChargers.filter(c => c.gps_lat && c.gps_lng);
|
||||
const noGps = allChargers.length - visible.length;
|
||||
document.getElementById('noGpsNote').textContent =
|
||||
noGps ? `📍 GPS 미등록 ${noGps}대 미표시` : '';
|
||||
|
||||
if (!visible.length) {
|
||||
chargerMap.setView([36.5, 127.8], 7);
|
||||
return;
|
||||
}
|
||||
|
||||
visible.forEach(c => {
|
||||
const hasFault = c.pending_reports > 0;
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="ck-pin ${hasFault ? 'fault' : 'normal'}"></div>`,
|
||||
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
|
||||
});
|
||||
|
||||
const popup = `
|
||||
<div style="min-width:200px">
|
||||
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">⚡ ${c.id}</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:8px;line-height:1.6">
|
||||
📍 ${c.station_name}${c.location_detail ? '<br>' + c.location_detail : ''}
|
||||
${c.charger_type ? '<br>종류: ' + c.charger_type : ''}
|
||||
${c.cpo_name ? '<br>CPO: ' + c.cpo_name : ''}
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<span class="badge ${hasFault ? 's-pending' : 's-done'}" style="font-size:12px">${hasFault ? '⚠ 미처리 ' + c.pending_reports + '건' : '✅ 정상'}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
${hasFault
|
||||
? `<a href="/pages/admin/reports.html?charger_id=${c.id}" style="flex:1;text-align:center;background:#EF4444;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none">📋 신고 보기</a>`
|
||||
: ''}
|
||||
<button onclick="editCharger('${c.id}')" style="flex:1;background:#1e3a5f;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;border:none;cursor:pointer">✏ 수정</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
|
||||
.addTo(chargerMap)
|
||||
.bindPopup(popup, { maxWidth: 260 });
|
||||
mapMarkers.push(m);
|
||||
});
|
||||
|
||||
const bounds = L.latLngBounds(visible.map(c => [c.gps_lat, c.gps_lng]));
|
||||
chargerMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
if (visible.length === 1) chargerMap.setZoom(14);
|
||||
}
|
||||
|
||||
// ── 모달 ──
|
||||
function openModal(id=null) {
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
document.getElementById('modalTitle').textContent = id ? '충전기 수정' : '충전기 등록';
|
||||
}
|
||||
function closeModal() { document.getElementById('modal').classList.add('hidden'); clearForm(); }
|
||||
function clearForm() { ['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id=>document.getElementById(id).value=''); document.getElementById('modalErr').style.display='none'; }
|
||||
function clearForm() {
|
||||
['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('fId').disabled = false;
|
||||
document.getElementById('modalErr').style.display = 'none';
|
||||
}
|
||||
|
||||
async function editCharger(id) {
|
||||
const c = await API.get('/chargers/'+id);
|
||||
@@ -125,6 +334,7 @@ async function save() {
|
||||
closeModal(); load();
|
||||
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>출장비 관리</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
@@ -17,6 +23,7 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
@@ -24,8 +31,11 @@
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">출장비 관리</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/costs','출장비목록.xlsx')">📥 엑셀 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
@@ -48,7 +58,10 @@
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -59,6 +72,28 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 출장비 내역 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/costs/bulk', ids); load(); }
|
||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
|
||||
|
||||
async function load() {
|
||||
@@ -67,17 +102,23 @@ async function load() {
|
||||
<div class="stat"><div class="stat-num">${statsData.monthly_total.toLocaleString()}</div><div class="stat-label">이달 출장비 합계(원)</div></div>
|
||||
<div class="stat danger"><div class="stat-num">${statsData.pending_count}</div><div class="stat-label">미처리 건수</div></div>`;
|
||||
const tbody = document.getElementById('tbody');
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
document.getElementById('empty').style.display = costs.length ? 'none' : 'block';
|
||||
tbody.innerHTML = costs.map(c => `
|
||||
<tr onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'">
|
||||
<td>${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
|
||||
<td>${c.charger_id||'-'}</td>
|
||||
<td>${c.station_name||'-'}</td>
|
||||
<td>${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
|
||||
<td>${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td>
|
||||
<td style="font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
|
||||
<td>${Auth.costStatusBadge(c.cost_status)}</td>
|
||||
<td>${Auth.fmtDt(c.reviewed_at)}</td>
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${c.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.charger_id||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.station_name||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer;font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${Auth.costStatusBadge(c.cost_status)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${Auth.fmtDt(c.reviewed_at)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
load();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,29 @@
|
||||
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
<div class="main" style="max-width:860px;margin:0 auto;">
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
|
||||
<div class="sidebar-section">시스템</div>
|
||||
<a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
||||
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm">← 목록</a>
|
||||
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">개선항목 상세</h2>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
@@ -99,4 +115,4 @@ async function changeStatus() {
|
||||
load();
|
||||
}
|
||||
load();
|
||||
</script></body></html>
|
||||
</script></div></div></body></html>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 관리</title><link rel="stylesheet" href="/css/style.css"></head>
|
||||
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 관리</title><link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected td { background:var(--gray2) !important; }
|
||||
#btnDelete { display:none; }
|
||||
</style></head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
<div class="layout">
|
||||
@@ -11,6 +17,7 @@
|
||||
<a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
@@ -18,7 +25,8 @@
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">개선항목 관리</h2>
|
||||
<div style="display:flex;gap:8px">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/improvements','개선항목목록.xlsx')">📥 엑셀</button>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 개선항목 등록</button>
|
||||
</div>
|
||||
@@ -35,7 +43,10 @@
|
||||
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
|
||||
</div>
|
||||
<div class="tbl-wrap"><table>
|
||||
<thead><tr><th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table></div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">등록된 개선항목이 없습니다.</div>
|
||||
@@ -90,6 +101,28 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 개선항목 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/improvements/bulk', ids); load(); }
|
||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
|
||||
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
|
||||
const selectedReports = new Set();
|
||||
@@ -105,18 +138,24 @@ async function load() {
|
||||
if (mfrSel.options.length <= 1)
|
||||
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=`${m.company||''} / ${m.name}`; mfrSel.appendChild(o); });
|
||||
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
document.getElementById('empty').style.display = imps.length ? 'none' : 'block';
|
||||
document.getElementById('tbody').innerHTML = imps.map(i => `
|
||||
<tr onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'">
|
||||
<td>#${i.id}</td>
|
||||
<td style="max-width:200px"><strong>${i.title}</strong></td>
|
||||
<td>${CAT[i.category]||i.category}</td>
|
||||
<td>${PRI[i.priority]||i.priority}</td>
|
||||
<td>${i.manufacturer_company||'-'}<br><small>${i.manufacturer_name||''}</small></td>
|
||||
<td><span class="badge s-pending">${i.report_count}건</span></td>
|
||||
<td>${Auth.statusBadge(i.status)}</td>
|
||||
<td>${Auth.fmtDt(i.created_at)}</td>
|
||||
<td>${i.sw_deployed_at||'-'}</td>
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${i.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">#${i.id}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer;max-width:200px"><strong>${i.title}</strong></td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${CAT[i.category]||i.category}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${PRI[i.priority]||i.priority}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.manufacturer_company||'-'}<br><small>${i.manufacturer_name||''}</small></td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer"><span class="badge s-pending">${i.report_count}건</span></td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.statusBadge(i.status)}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.fmtDt(i.created_at)}</td>
|
||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.sw_deployed_at||'-'}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
|
||||
441
frontend/static/pages/admin/issue-types.html
Normal file
441
frontend/static/pages/admin/issue-types.html
Normal file
@@ -0,0 +1,441 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>유형관리</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--gray2); margin-bottom:20px; }
|
||||
.tab-btn {
|
||||
padding:9px 22px; font-size:14px; font-weight:600; border:none; background:none;
|
||||
cursor:pointer; color:var(--gray4); border-bottom:3px solid transparent; margin-bottom:-2px;
|
||||
transition:color .15s, border-color .15s;
|
||||
}
|
||||
.tab-btn.active { color:var(--navy); border-bottom-color:var(--accent); }
|
||||
.tab-pane { display:none; }
|
||||
.tab-pane.active { display:block; }
|
||||
|
||||
/* 유형 공통 */
|
||||
.type-list { border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||
.type-row {
|
||||
display:grid; grid-template-columns:36px 1fr 1fr 80px;
|
||||
align-items:center; gap:10px; padding:10px 14px;
|
||||
border-bottom:1px solid var(--border); background:#fff;
|
||||
}
|
||||
.type-row:last-child { border-bottom:none; }
|
||||
.type-row.header { background:var(--gray1); font-size:11px; font-weight:700; color:var(--gray4); text-transform:uppercase; letter-spacing:.5px; }
|
||||
.type-row input[type=text] { font-size:13px; padding:5px 8px; border:1px solid var(--border); border-radius:5px; width:100%; }
|
||||
.order-btns { display:flex; flex-direction:column; gap:2px; }
|
||||
.order-btn { background:none; border:1px solid var(--border); border-radius:3px; padding:0 6px; font-size:11px; cursor:pointer; color:var(--gray4); line-height:18px; }
|
||||
.order-btn:hover { background:var(--gray1); color:var(--text); }
|
||||
.del-btn { background:none; border:none; color:#e53e3e; font-size:18px; cursor:pointer; padding:0 4px; }
|
||||
.del-btn:hover { color:#c53030; }
|
||||
.add-row { display:grid; grid-template-columns:36px 1fr 1fr 80px; gap:10px; align-items:center; padding:12px 14px; background:#f9faff; border-top:2px dashed var(--blue); border-radius:0 0 8px 8px; }
|
||||
.add-row input[type=text] { font-size:13px; padding:5px 8px; border:1px solid var(--border); border-radius:5px; width:100%; }
|
||||
.hint { font-size:11px; color:var(--gray4); margin-top:4px; }
|
||||
|
||||
/* 제조사 테이블 */
|
||||
.mfr-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.mfr-table th { background:var(--gray1); color:var(--gray4); font-size:11px; font-weight:700; padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); }
|
||||
.mfr-table td { padding:9px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||
.mfr-table tr:last-child td { border-bottom:none; }
|
||||
.mfr-table tr:hover td { background:#f8faff; }
|
||||
.badge-active { background:#D1FAE5; color:#065F46; font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; }
|
||||
.badge-inactive { background:#FEE2E2; color:#991B1B; font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; }
|
||||
|
||||
/* 제조사 추가/편집 모달 */
|
||||
.mfr-modal-bg { display:none; position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:200; align-items:center; justify-content:center; }
|
||||
.mfr-modal-bg.open { display:flex; }
|
||||
.mfr-modal { background:white; border-radius:12px; width:480px; max-width:calc(100vw - 32px); padding:28px 28px 22px; box-shadow:0 8px 32px rgba(0,0,0,.2); }
|
||||
.mfr-modal h3 { font-size:16px; font-weight:700; color:var(--navy); margin-bottom:18px; }
|
||||
.mfr-field { margin-bottom:12px; }
|
||||
.mfr-field label { display:block; font-size:12px; font-weight:600; color:var(--navy2); margin-bottom:4px; }
|
||||
.mfr-field input, .mfr-field textarea {
|
||||
width:100%; padding:8px 10px; border:1px solid var(--gray3); border-radius:7px;
|
||||
font-size:13px; box-sizing:border-box; font-family:inherit; outline:none;
|
||||
}
|
||||
.mfr-field input:focus, .mfr-field textarea:focus { border-color:var(--accent); }
|
||||
.mfr-field .opt { font-size:11px; color:var(--gray4); font-weight:400; margin-left:4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
|
||||
<div class="sidebar-section">시스템</div>
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html" class="active">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">📝 유형관리</h2>
|
||||
</div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" onclick="switchTab('issue')">📋 문제유형</button>
|
||||
<button class="tab-btn" onclick="switchTab('repair')">🔧 조치유형</button>
|
||||
<button class="tab-btn" onclick="switchTab('mfr')">🏢 제조사</button>
|
||||
</div>
|
||||
|
||||
<!-- ── 문제유형 탭 ── -->
|
||||
<div class="tab-pane active" id="pane-issue">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<p style="font-size:13px;color:var(--text2);margin:0">신고 접수 시 사용자가 선택하는 문제 유형입니다. 저장키는 기존 신고 데이터와 연결되므로 수정 시 주의하세요.</p>
|
||||
<button class="btn btn-primary btn-sm" style="margin-left:16px;white-space:nowrap" onclick="saveIssue()">💾 저장</button>
|
||||
</div>
|
||||
<div class="type-list" id="issueList"></div>
|
||||
<div class="add-row">
|
||||
<span style="text-align:center;font-size:18px;color:var(--blue)">+</span>
|
||||
<div>
|
||||
<input type="text" id="iNewLabel" placeholder="표시명 예) ⚡ 충전 불가">
|
||||
<div class="hint">신고 화면에 보이는 이름 (이모지 포함 가능)</div>
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" id="iNewKey" placeholder="저장키 예) 충전불가">
|
||||
<div class="hint">공백 없는 한글/영문 권장</div>
|
||||
</div>
|
||||
<div style="text-align:center"><button class="btn btn-outline btn-sm" onclick="addIssueRow()">추가</button></div>
|
||||
</div>
|
||||
<div id="issueSaveMsg" style="display:none;margin-top:14px"></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px">
|
||||
<div class="card-title">👁 신고 화면 미리보기</div>
|
||||
<div id="issuePreview" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:420px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 조치유형 탭 ── -->
|
||||
<div class="tab-pane" id="pane-repair">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<p style="font-size:13px;color:var(--text2);margin:0">정비사가 조치 입력 시 선택하는 조치 유형입니다. 저장키는 기존 조치 기록과 연결되므로 수정 시 주의하세요.</p>
|
||||
<button class="btn btn-primary btn-sm" style="margin-left:16px;white-space:nowrap" onclick="saveRepair()">💾 저장</button>
|
||||
</div>
|
||||
<div class="type-list" id="repairList"></div>
|
||||
<div class="add-row">
|
||||
<span style="text-align:center;font-size:18px;color:var(--blue)">+</span>
|
||||
<div>
|
||||
<input type="text" id="rNewLabel" placeholder="표시명 예) 🔩 부품 교체">
|
||||
<div class="hint">조치 화면에 보이는 이름 (이모지 포함 가능)</div>
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" id="rNewKey" placeholder="저장키 예) 부품교체">
|
||||
<div class="hint">공백 없는 한글/영문 권장</div>
|
||||
</div>
|
||||
<div style="text-align:center"><button class="btn btn-outline btn-sm" onclick="addRepairRow()">추가</button></div>
|
||||
</div>
|
||||
<div id="repairSaveMsg" style="display:none;margin-top:14px"></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:18px">
|
||||
<div class="card-title">👁 조치 화면 미리보기</div>
|
||||
<div id="repairPreview" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:420px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 제조사 탭 ── -->
|
||||
<div class="tab-pane" id="pane-mfr">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<p style="font-size:13px;color:var(--text2);margin:0">
|
||||
개선항목·출장비에 연결하거나 정비사 가입 시 선택할 수 있는 회사 목록입니다.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm" style="white-space:nowrap;margin-left:16px" onclick="openMfrModal()">+ 제조사 등록</button>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="mfr-table" id="mfrTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>회사명</th><th>대표자명</th><th>사업자번호</th><th>대표전화</th><th>주소</th><th>상태</th><th style="width:90px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mfrTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="mfrEmpty" class="alert alert-info" style="display:none;margin-top:12px">등록된 제조사가 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제조사 등록/편집 모달 -->
|
||||
<div class="mfr-modal-bg" id="mfrModal">
|
||||
<div class="mfr-modal">
|
||||
<h3 id="mfrModalTitle">제조사 등록</h3>
|
||||
<input type="hidden" id="mfrEditId">
|
||||
<div class="mfr-field">
|
||||
<label>회사명 <span style="color:var(--red)">*</span></label>
|
||||
<input type="text" id="mfrName" placeholder="예) (주)한국EV">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div class="mfr-field">
|
||||
<label>대표자명 <span class="opt">(선택)</span></label>
|
||||
<input type="text" id="mfrRep" placeholder="예) 홍길동">
|
||||
</div>
|
||||
<div class="mfr-field">
|
||||
<label>사업자번호 <span class="opt">(선택)</span></label>
|
||||
<input type="text" id="mfrBiz" placeholder="예) 123-45-67890">
|
||||
</div>
|
||||
<div class="mfr-field">
|
||||
<label>대표전화 <span class="opt">(선택)</span></label>
|
||||
<input type="tel" id="mfrPhone" placeholder="예) 02-1234-5678">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mfr-field">
|
||||
<label>주소 <span class="opt">(선택)</span></label>
|
||||
<input type="text" id="mfrAddr" placeholder="예) 서울시 강남구 ...">
|
||||
</div>
|
||||
<div class="mfr-field" id="mfrActiveField" style="display:none">
|
||||
<label>상태</label>
|
||||
<select id="mfrActive" style="width:auto;padding:7px 10px;border:1px solid var(--gray3);border-radius:7px;font-size:13px">
|
||||
<option value="true">활성</option>
|
||||
<option value="false">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="mfrModalErr" class="alert alert-danger" style="display:none;margin-top:10px"></div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:18px">
|
||||
<button class="btn btn-outline" onclick="closeMfrModal()">취소</button>
|
||||
<button class="btn btn-primary" onclick="saveMfr()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let issueTypes = [];
|
||||
let repairTypes = [];
|
||||
let manufacturers = [];
|
||||
|
||||
// ── 탭 전환 ──
|
||||
const TAB_NAMES = ['issue','repair','mfr'];
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) =>
|
||||
b.classList.toggle('active', TAB_NAMES[i] === name));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('pane-' + name).classList.add('active');
|
||||
}
|
||||
|
||||
// ── 유형 공통 렌더러 ──
|
||||
function renderList(listId, types, varName, moveFn, delFn, previewFn) {
|
||||
const header = `
|
||||
<div class="type-row header">
|
||||
<span style="text-align:center">순서</span><span>표시명</span><span>저장키</span><span style="text-align:center">삭제</span>
|
||||
</div>`;
|
||||
const rows = types.map((t, i) => `
|
||||
<div class="type-row">
|
||||
<div class="order-btns">
|
||||
<button class="order-btn" onclick="${moveFn}(${i},-1)" ${i===0?'disabled':''}>▲</button>
|
||||
<button class="order-btn" onclick="${moveFn}(${i},1)" ${i===types.length-1?'disabled':''}>▼</button>
|
||||
</div>
|
||||
<input type="text" value="${esc(t.label)}" onchange="${varName}[${i}].label=this.value;${previewFn}()">
|
||||
<input type="text" value="${esc(t.key)}" onchange="${varName}[${i}].key=this.value">
|
||||
<div style="text-align:center"><button class="del-btn" onclick="${delFn}(${i})">×</button></div>
|
||||
</div>`).join('');
|
||||
document.getElementById(listId).innerHTML = header + rows;
|
||||
}
|
||||
|
||||
function renderPreview(previewId, types) {
|
||||
document.getElementById(previewId).innerHTML = types.map(t => `
|
||||
<label style="border:1.5px solid var(--border);border-radius:8px;padding:8px 12px;font-size:13px;
|
||||
display:flex;align-items:center;gap:8px;background:#fff;cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--accent)">${esc(t.label)}
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
// ── 문제유형 ──
|
||||
function renderIssue() {
|
||||
renderList('issueList', issueTypes, 'issueTypes', 'moveIssue', 'delIssue', 'renderIssuePreview');
|
||||
renderIssuePreview();
|
||||
}
|
||||
function renderIssuePreview() { renderPreview('issuePreview', issueTypes); }
|
||||
function moveIssue(idx, dir) {
|
||||
const t = idx+dir; if(t<0||t>=issueTypes.length) return;
|
||||
[issueTypes[idx],issueTypes[t]]=[issueTypes[t],issueTypes[idx]]; renderIssue();
|
||||
}
|
||||
function delIssue(idx) {
|
||||
if(!confirm(`"${issueTypes[idx].label}" 유형을 삭제하시겠습니까?\n기존 신고 기록에는 저장키가 남습니다.`)) return;
|
||||
issueTypes.splice(idx,1); renderIssue();
|
||||
}
|
||||
function addIssueRow() {
|
||||
const label=document.getElementById('iNewLabel').value.trim();
|
||||
const key=document.getElementById('iNewKey').value.trim();
|
||||
if(!label||!key){alert('표시명과 저장키를 모두 입력하세요.');return;}
|
||||
if(issueTypes.some(t=>t.key===key)){alert(`저장키 "${key}"가 이미 존재합니다.`);return;}
|
||||
issueTypes.push({key,label});
|
||||
document.getElementById('iNewLabel').value=''; document.getElementById('iNewKey').value='';
|
||||
renderIssue();
|
||||
}
|
||||
async function saveIssue() {
|
||||
if(!issueTypes.length){alert('최소 1개 이상 필요합니다.');return;}
|
||||
if(issueTypes.find(t=>!t.key.trim()||!t.label.trim())){alert('빈 항목이 있습니다.');return;}
|
||||
try{await API.put('/settings/issue-types',issueTypes);showMsg('issueSaveMsg');}
|
||||
catch(e){alert('저장 실패: '+e.message);}
|
||||
}
|
||||
|
||||
// ── 조치유형 ──
|
||||
function renderRepair() {
|
||||
renderList('repairList', repairTypes, 'repairTypes', 'moveRepair', 'delRepair', 'renderRepairPreview');
|
||||
renderRepairPreview();
|
||||
}
|
||||
function renderRepairPreview() { renderPreview('repairPreview', repairTypes); }
|
||||
function moveRepair(idx, dir) {
|
||||
const t=idx+dir; if(t<0||t>=repairTypes.length) return;
|
||||
[repairTypes[idx],repairTypes[t]]=[repairTypes[t],repairTypes[idx]]; renderRepair();
|
||||
}
|
||||
function delRepair(idx) {
|
||||
if(!confirm(`"${repairTypes[idx].label}" 유형을 삭제하시겠습니까?\n기존 조치 기록에는 저장키가 남습니다.`)) return;
|
||||
repairTypes.splice(idx,1); renderRepair();
|
||||
}
|
||||
function addRepairRow() {
|
||||
const label=document.getElementById('rNewLabel').value.trim();
|
||||
const key=document.getElementById('rNewKey').value.trim();
|
||||
if(!label||!key){alert('표시명과 저장키를 모두 입력하세요.');return;}
|
||||
if(repairTypes.some(t=>t.key===key)){alert(`저장키 "${key}"가 이미 존재합니다.`);return;}
|
||||
repairTypes.push({key,label});
|
||||
document.getElementById('rNewLabel').value=''; document.getElementById('rNewKey').value='';
|
||||
renderRepair();
|
||||
}
|
||||
async function saveRepair() {
|
||||
if(!repairTypes.length){alert('최소 1개 이상 필요합니다.');return;}
|
||||
if(repairTypes.find(t=>!t.key.trim()||!t.label.trim())){alert('빈 항목이 있습니다.');return;}
|
||||
try{await API.put('/settings/repair-types',repairTypes);showMsg('repairSaveMsg');}
|
||||
catch(e){alert('저장 실패: '+e.message);}
|
||||
}
|
||||
|
||||
// ── 제조사 ──
|
||||
function renderMfr() {
|
||||
const tbody = document.getElementById('mfrTbody');
|
||||
document.getElementById('mfrEmpty').style.display = manufacturers.length ? 'none' : 'block';
|
||||
if (!manufacturers.length) { tbody.innerHTML = ''; return; }
|
||||
tbody.innerHTML = manufacturers.map(m => `
|
||||
<tr>
|
||||
<td><strong>${esc(m.name)}</strong></td>
|
||||
<td style="color:var(--text2)">${esc(m.representative_name||'-')}</td>
|
||||
<td style="color:var(--gray4);font-size:12px">${esc(m.business_number||'-')}</td>
|
||||
<td>${esc(m.phone||'-')}</td>
|
||||
<td style="font-size:12px;color:var(--text2);max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(m.address||'-')}</td>
|
||||
<td><span class="${m.is_active?'badge-active':'badge-inactive'}">${m.is_active?'활성':'비활성'}</span></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-outline btn-sm" style="font-size:11px" onclick="editMfr(${m.id})">편집</button>
|
||||
<button class="btn btn-sm" style="font-size:11px;background:#fee2e2;color:#991b1b;border:none"
|
||||
onclick="deleteMfr(${m.id},'${esc(m.name)}')">삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function openMfrModal(id) {
|
||||
document.getElementById('mfrEditId').value = id || '';
|
||||
document.getElementById('mfrModalTitle').textContent = id ? '제조사 편집' : '제조사 등록';
|
||||
document.getElementById('mfrActiveField').style.display = id ? 'block' : 'none';
|
||||
document.getElementById('mfrModalErr').style.display = 'none';
|
||||
if (!id) {
|
||||
['mfrName','mfrRep','mfrBiz','mfrPhone','mfrAddr'].forEach(i => document.getElementById(i).value = '');
|
||||
document.getElementById('mfrActive').value = 'true';
|
||||
}
|
||||
document.getElementById('mfrModal').classList.add('open');
|
||||
}
|
||||
function closeMfrModal() { document.getElementById('mfrModal').classList.remove('open'); }
|
||||
|
||||
function editMfr(id) {
|
||||
const m = manufacturers.find(x => x.id === id);
|
||||
if (!m) return;
|
||||
openMfrModal(id);
|
||||
document.getElementById('mfrName').value = m.name || '';
|
||||
document.getElementById('mfrRep').value = m.representative_name || '';
|
||||
document.getElementById('mfrBiz').value = m.business_number || '';
|
||||
document.getElementById('mfrPhone').value = m.phone || '';
|
||||
document.getElementById('mfrAddr').value = m.address || '';
|
||||
document.getElementById('mfrActive').value = m.is_active ? 'true' : 'false';
|
||||
}
|
||||
|
||||
async function saveMfr() {
|
||||
const id = document.getElementById('mfrEditId').value;
|
||||
const name = document.getElementById('mfrName').value.trim();
|
||||
if (!name) { showMfrErr('회사명은 필수입니다.'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('representative_name', document.getElementById('mfrRep').value.trim());
|
||||
fd.append('business_number', document.getElementById('mfrBiz').value.trim());
|
||||
fd.append('phone', document.getElementById('mfrPhone').value.trim());
|
||||
fd.append('address', document.getElementById('mfrAddr').value.trim());
|
||||
if (id) fd.append('is_active', document.getElementById('mfrActive').value);
|
||||
try {
|
||||
if (id) await API.put('/manufacturers/' + id, fd);
|
||||
else await API.post('/manufacturers', fd);
|
||||
closeMfrModal();
|
||||
await loadMfr();
|
||||
} catch(e) { showMfrErr(e.message); }
|
||||
}
|
||||
|
||||
async function deleteMfr(id, name) {
|
||||
if (!confirm(`"${name}" 제조사를 삭제하시겠습니까?`)) return;
|
||||
try { await API.delete('/manufacturers/' + id); await loadMfr(); }
|
||||
catch(e) { alert('삭제 실패: ' + e.message); }
|
||||
}
|
||||
|
||||
function showMfrErr(msg) {
|
||||
const el = document.getElementById('mfrModalErr');
|
||||
el.textContent = msg; el.style.display = 'block';
|
||||
}
|
||||
|
||||
async function loadMfr() {
|
||||
manufacturers = await API.get('/manufacturers');
|
||||
renderMfr();
|
||||
}
|
||||
|
||||
// ── 공용 ──
|
||||
function showMsg(id) {
|
||||
const el = document.getElementById(id);
|
||||
el.className = 'alert alert-success';
|
||||
el.textContent = '✅ 저장되었습니다.';
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 3000);
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.getElementById('iNewLabel').addEventListener('input', function() {
|
||||
const k = document.getElementById('iNewKey');
|
||||
if (!k.value) k.value = this.value.replace(/[^가-힣a-zA-Z0-9]/g, '');
|
||||
});
|
||||
document.getElementById('rNewLabel').addEventListener('input', function() {
|
||||
const k = document.getElementById('rNewKey');
|
||||
if (!k.value) k.value = this.value.replace(/[^가-힣a-zA-Z0-9]/g, '');
|
||||
});
|
||||
|
||||
// 모달 바깥 클릭 닫기
|
||||
document.getElementById('mfrModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeMfrModal();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const [it, rt] = await Promise.all([
|
||||
API.get('/settings/issue-types'),
|
||||
API.get('/settings/repair-types'),
|
||||
loadMfr(),
|
||||
]);
|
||||
issueTypes = it;
|
||||
repairTypes = rt;
|
||||
renderIssue();
|
||||
renderRepair();
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>신고 상세</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
/* 출장비 요약 카드 */
|
||||
.cost-summary {
|
||||
@@ -104,6 +106,14 @@
|
||||
padding-top: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 신고 인라인 편집 */
|
||||
.report-view { display:block; }
|
||||
.report-edit { display:none; }
|
||||
.report-edit.active { display:block; }
|
||||
.report-view.hidden { display:none; }
|
||||
.issue-chk-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:6px; }
|
||||
.issue-chk-item { display:flex; align-items:center; gap:6px; font-size:13px; }
|
||||
.issue-chk-item input { width:15px; height:15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -111,16 +121,33 @@
|
||||
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="main" style="max-width:860px;margin:0 auto;">
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
|
||||
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
|
||||
<div class="sidebar-section">시스템</div>
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
||||
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm">← 목록</a>
|
||||
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">신고 상세</h2>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/imageCompress.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
@@ -165,11 +192,20 @@ function toggleEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
const IMP_CAT_LABEL = {
|
||||
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
|
||||
installation:'설치환경', other:'기타'
|
||||
};
|
||||
|
||||
async function load() {
|
||||
const r = await API.get('/reports/' + reportId);
|
||||
const [r, issueTypes, manufacturers, improvements] = await Promise.all([
|
||||
API.get('/reports/' + reportId),
|
||||
API.get('/settings/issue-types'),
|
||||
API.get('/accounts?role=manufacturer'),
|
||||
API.get('/improvements'),
|
||||
]);
|
||||
const repair = r.repair;
|
||||
const cost = repair?.cost;
|
||||
const manufacturers = await API.get('/accounts?role=manufacturer');
|
||||
|
||||
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`;
|
||||
|
||||
@@ -321,11 +357,17 @@ async function load() {
|
||||
}
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
|
||||
<div class="detail-grid">
|
||||
|
||||
<!-- 신고 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-title">📋 신고 정보</div>
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>📋 신고 정보</span>
|
||||
<button class="edit-toggle-btn" id="reportEditBtn" onclick="toggleReportEdit()">✏️ 내용 수정</button>
|
||||
</div>
|
||||
|
||||
<!-- 보기 모드 -->
|
||||
<div class="report-view" id="reportView">
|
||||
<table class="no-hover" style="font-size:13px;">
|
||||
<tr><td style="color:var(--gray4);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong></td></tr>
|
||||
<tr><td style="color:var(--gray4)">충전기명</td><td>${r.charger_name || '-'}</td></tr>
|
||||
@@ -340,15 +382,105 @@ async function load() {
|
||||
<tr><td style="color:var(--gray4)">발생시각</td><td>${Auth.fmtDt(r.occurred_at)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">신고일시</td><td>${Auth.fmtDt(r.reported_at)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">상태</td><td>${Auth.statusBadge(r.status)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">신고 출처</td><td>${r.source === 'dashboard'
|
||||
? `<span style="background:#F5F3FF;color:#7C3AED;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">🖥 대시보드 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
|
||||
: r.source === 'admin'
|
||||
? `<span style="background:#EFF6FF;color:#1565C0;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">⚙️ 관리자 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
|
||||
: `<span style="background:#F0FDF4;color:#166534;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">📱 QR 스캔</span>`
|
||||
}</td></tr>
|
||||
</table>
|
||||
${r.ocpp_log ? `
|
||||
<div style="margin-top:12px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그</label>
|
||||
<pre style="margin-top:6px;background:var(--gray1);border:1px solid var(--gray3);border-radius:6px;padding:10px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;">${escHtmlDetail(r.ocpp_log)}</pre>
|
||||
</div>` : ''}
|
||||
${r.status === 'pending_approval' ? `
|
||||
<button class="btn btn-success btn-sm" style="margin-top:12px"
|
||||
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- 편집 모드 -->
|
||||
<div class="report-edit" id="reportEdit">
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">문제 유형 <span class="req">*</span></label>
|
||||
<div class="issue-chk-grid">
|
||||
${issueTypes.map(i => `
|
||||
<label class="issue-chk-item">
|
||||
<input type="checkbox" class="r-issue-chk" value="${i.key}"
|
||||
${(r.issue_types||[]).includes(i.key) ? 'checked' : ''}>
|
||||
${i.label}
|
||||
</label>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">에러 코드</label>
|
||||
<input type="text" id="rEditErrorCode" value="${r.error_code||''}" placeholder="에러 코드">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">상세 설명</label>
|
||||
<textarea id="rEditDetail" rows="3" placeholder="문제 상황 설명">${r.issue_detail||''}</textarea>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:10px">
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">연락처</label>
|
||||
<input type="text" id="rEditContact" value="${r.contact||''}" placeholder="연락처">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">발생 시각</label>
|
||||
<input type="datetime-local" id="rEditOccurred"
|
||||
value="${r.occurred_at ? r.occurred_at.slice(0,16) : ''}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:14px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 상태</label>
|
||||
<select id="rEditStatus">
|
||||
<option value="pending_approval" ${r.status==='pending_approval'?'selected':''}>승인대기</option>
|
||||
<option value="pending" ${r.status==='pending' ?'selected':''}>접수</option>
|
||||
<option value="in_progress" ${r.status==='in_progress' ?'selected':''}>처리중</option>
|
||||
<option value="done" ${r.status==='done' ?'selected':''}>완료</option>
|
||||
<option value="waiting" ${r.status==='waiting' ?'selected':''}>부품대기</option>
|
||||
<option value="revisit" ${r.status==='revisit' ?'selected':''}>재방문</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:14px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그 <span style="font-weight:400;color:var(--gray4)">(선택)</span></label>
|
||||
<textarea id="rEditOcppLog" rows="5"
|
||||
style="width:100%;padding:8px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:12px;font-family:monospace;resize:vertical;box-sizing:border-box;"
|
||||
placeholder="OCPP 통신 로그 붙여넣기...">${r.ocpp_log || ''}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:5px;cursor:pointer;font-size:12px;color:var(--blue);">
|
||||
<input type="file" id="rEditOcppFile" accept=".txt,.csv,.log" style="display:none" onchange="readOcppFileEdit(this)">
|
||||
📄 파일 선택 (.txt/.csv/.log)
|
||||
<span id="rEditOcppFileName" style="color:var(--gray4);font-weight:400"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:14px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">사진 관리</label>
|
||||
${(r.photos||[]).length ? `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
${(r.photos||[]).map(p => `
|
||||
<div style="position:relative;">
|
||||
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
|
||||
<button onclick="deleteReportPhoto(${r.id},${p.id})"
|
||||
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
<label class="upload-area" for="rEditPhoto" style="padding:10px;font-size:12px;">📷 사진 추가 (선택 · 여러 장)</label>
|
||||
<input type="file" id="rEditPhoto" accept="image/*" multiple style="display:none">
|
||||
<div class="photo-preview" id="rEditPhotoPreview"></div>
|
||||
<div class="photo-info" id="rEditPhotoInfo" style="color:var(--gray4)"></div>
|
||||
</div>
|
||||
<div id="rEditErr" class="alert alert-danger" style="display:none"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-primary btn-sm" onclick="saveReport(${r.id})">💾 저장</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="toggleReportEdit()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 사진</label>
|
||||
<div class="photo-preview">
|
||||
${(r.photos || []).map(p =>
|
||||
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
|
||||
`<img src="${p.path}" onclick="window.open('${p.path}')" style="cursor:zoom-in">`
|
||||
).join('') || '<span style="font-size:12px;color:var(--gray4)">첨부 없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,7 +488,17 @@ async function load() {
|
||||
|
||||
<!-- 조치 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-title">🔧 조치 정보</div>
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
|
||||
<span>🔧 조치 정보</span>
|
||||
${repair ? `
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
${repair.approved_at
|
||||
? `<span style="font-size:12px;background:#D1FAE5;color:#065F46;padding:3px 12px;border-radius:10px;font-weight:700;">✅ 승인완료 · ${repair.approved_by_name||''}</span>`
|
||||
: `<button onclick="toggleApprovePanel()" id="approvePanelBtn" style="padding:5px 14px;border:none;border-radius:7px;background:var(--green);color:white;font-size:12px;font-weight:700;cursor:pointer;">✅ 조치 승인</button>`
|
||||
}
|
||||
<button onclick="cancelRepair(${repair.id}, ${!!repair.approved_at})" style="padding:5px 14px;border:none;border-radius:7px;background:#FEE2E2;color:#991B1B;font-size:12px;font-weight:700;cursor:pointer;">🔄 조치취소</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${repair ? `
|
||||
<table class="no-hover" style="font-size:13px;">
|
||||
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${repair.mechanic_name || '-'}</td></tr>
|
||||
@@ -381,6 +523,105 @@ async function load() {
|
||||
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
${renderLocationMap(repair)}
|
||||
|
||||
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
|
||||
repair.linked_improvements && repair.linked_improvements.length ? `
|
||||
<div style="margin-top:14px;padding:12px 14px;background:#EDE9FE;border-radius:8px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#5B21B6;margin-bottom:8px;">🔧 연결된 개선항목</div>
|
||||
${repair.linked_improvements.map(i => `
|
||||
<a href="/pages/admin/improvement-detail.html?id=${i.id}"
|
||||
style="display:flex;align-items:center;gap:8px;font-size:13px;color:#5B21B6;text-decoration:none;padding:4px 0;">
|
||||
<span style="background:#DDD6FE;border-radius:4px;padding:1px 7px;font-size:11px;">${IMP_CAT_LABEL[i.category]||i.category}</span>
|
||||
<strong>#${i.id}</strong> ${i.title}
|
||||
</a>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
${/* ── 승인 패널 (미승인 시) ── */
|
||||
!repair.approved_at ? `
|
||||
<div id="approvePanel" style="display:none;border-top:1px dashed var(--gray3);margin-top:16px;padding-top:16px;">
|
||||
<div style="font-size:14px;font-weight:700;color:var(--navy);margin-bottom:14px;">✅ 조치 승인 — 개선항목 연결</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:14px;">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
||||
<input type="radio" name="impAction" value="none" checked onchange="updateImpSection()">
|
||||
<span>개선항목 연결 안 함</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
||||
<input type="radio" name="impAction" value="link" onchange="updateImpSection()">
|
||||
<span>기존 개선항목에 연결 — <span style="color:var(--gray4);font-size:12px;">이전에 등록된 항목 선택</span></span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
|
||||
<input type="radio" name="impAction" value="create" onchange="updateImpSection()">
|
||||
<span>신규 개선항목 생성 — <span style="color:var(--gray4);font-size:12px;">이번 조치 기반으로 새 항목 등록</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 기존 항목 연결 -->
|
||||
<div id="impLinkSection" style="display:none;margin-bottom:14px;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;">개선항목 검색</div>
|
||||
<input type="text" id="impSearch" placeholder="제목으로 검색..."
|
||||
style="width:100%;padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;margin-bottom:6px;"
|
||||
oninput="filterImpOptions()">
|
||||
<select id="impSelect" size="5"
|
||||
style="width:100%;border:1px solid var(--gray3);border-radius:6px;font-size:13px;padding:4px;">
|
||||
${improvements.length
|
||||
? improvements.map(i =>
|
||||
`<option value="${i.id}">[${IMP_CAT_LABEL[i.category]||i.category}] #${i.id} ${i.title}</option>`
|
||||
).join('')
|
||||
: '<option disabled>등록된 개선항목이 없습니다</option>'}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 신규 항목 생성 -->
|
||||
<div id="impCreateSection" style="display:none;margin-bottom:14px;padding:14px;background:var(--gray1);border-radius:8px;">
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">제목 <span class="req">*</span></label>
|
||||
<input type="text" id="impTitle" placeholder="개선항목 제목">
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:10px">
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">분류 <span class="req">*</span></label>
|
||||
<select id="impCategory">
|
||||
<option value="">선택</option>
|
||||
<option value="hardware">하드웨어</option>
|
||||
<option value="software">소프트웨어</option>
|
||||
<option value="firmware">펌웨어</option>
|
||||
<option value="installation">설치환경</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">우선순위</label>
|
||||
<select id="impPriority">
|
||||
<option value="low">낮음</option>
|
||||
<option value="normal" selected>보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">내용 <span class="req">*</span></label>
|
||||
<textarea id="impDesc" rows="3" placeholder="개선이 필요한 내용을 구체적으로 기술해 주세요."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">담당 제조사</label>
|
||||
<select id="impMfr">
|
||||
<option value="">미지정 (나중에 설정)</option>
|
||||
${manufacturers.map(m =>
|
||||
`<option value="${m.id}">${m.company ? m.company+' / ' : ''}${m.name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="approveErr" class="alert alert-danger" style="display:none;margin-bottom:10px;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-success btn-sm" onclick="doApproveRepair(${repair.id})">✅ 승인 완료</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="toggleApprovePanel()">취소</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
` : '<div class="alert alert-info">아직 정비사가 조치를 입력하지 않았습니다.</div>'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,18 +629,256 @@ async function load() {
|
||||
${costHtml}
|
||||
`;
|
||||
|
||||
// 신고 편집 폼 사진 압축 설정
|
||||
if (document.getElementById('rEditPhoto')) {
|
||||
ImageCompressor.setupPreview('rEditPhoto', 'rEditPhotoPreview', 'rEditPhotoInfo');
|
||||
}
|
||||
|
||||
// 지도 초기화 (수리 정보가 있을 때만)
|
||||
if (repair) initRepairMap(repair);
|
||||
|
||||
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
|
||||
if (!editOpen) return;
|
||||
const wrap = document.getElementById('costEditWrap');
|
||||
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
|
||||
}
|
||||
|
||||
/* ── 방문 위치 지도 ── */
|
||||
function renderLocationMap(repair) {
|
||||
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
|
||||
const cLat = repair.charger_lat, cLng = repair.charger_lng;
|
||||
if (!mLat && !cLat) return '';
|
||||
|
||||
let distHtml = '';
|
||||
if (mLat && cLat) {
|
||||
const d = haversineM(mLat, mLng, cLat, cLng);
|
||||
const within = d <= 200;
|
||||
distHtml = `
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:8px;">
|
||||
<span style="padding:3px 10px;border-radius:12px;font-weight:700;
|
||||
background:${within ? '#D1FAE5' : '#FEE2E2'};color:${within ? '#065F46' : '#991B1B'}">
|
||||
${within ? '✅ 현장 방문 확인' : '⚠️ 현장 거리 초과'}
|
||||
</span>
|
||||
<span style="color:var(--gray4)">충전기와의 거리: <strong>${d < 1000 ? Math.round(d)+'m' : (d/1000).toFixed(1)+'km'}</strong></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="margin-top:16px">
|
||||
<label style="font-size:12px;font-weight:700;color:var(--navy2);display:block;margin-bottom:6px">📍 조치 위치</label>
|
||||
${distHtml}
|
||||
<div id="repairMap" style="height:220px;border-radius:8px;border:1px solid var(--gray3);overflow:hidden"></div>
|
||||
<div style="display:flex;gap:16px;font-size:11px;color:var(--gray4);margin-top:5px;">
|
||||
${mLat ? '<span>🔵 정비사 위치</span>' : ''}
|
||||
${cLat ? '<span>🔴 충전기 등록 위치</span>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function initRepairMap(repair) {
|
||||
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
|
||||
const cLat = repair.charger_lat, cLng = repair.charger_lng;
|
||||
if (!document.getElementById('repairMap')) return;
|
||||
|
||||
const center = mLat ? [mLat, mLng] : [cLat, cLng];
|
||||
const map = L.map('repairMap', { zoomControl: true, scrollWheelZoom: false })
|
||||
.setView(center, 16);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
const bounds = [];
|
||||
|
||||
if (mLat) {
|
||||
const icon = L.divIcon({
|
||||
html: '<div style="width:14px;height:14px;border-radius:50%;background:#2563EB;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
|
||||
});
|
||||
L.marker([mLat, mLng], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup('<b>정비사 위치</b><br>조치 제출 시점 기록')
|
||||
.openPopup();
|
||||
bounds.push([mLat, mLng]);
|
||||
}
|
||||
|
||||
if (cLat) {
|
||||
const icon = L.divIcon({
|
||||
html: '<div style="width:14px;height:14px;border-radius:50%;background:#DC2626;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
|
||||
});
|
||||
L.marker([cLat, cLng], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup('<b>충전기 등록 위치</b>');
|
||||
bounds.push([cLat, cLng]);
|
||||
}
|
||||
|
||||
if (mLat && cLat) {
|
||||
L.polyline([[mLat, mLng], [cLat, cLng]], {
|
||||
color: '#6366F1', weight: 2, dashArray: '5 5', opacity: 0.7
|
||||
}).addTo(map);
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
|
||||
function haversineM(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2)**2 +
|
||||
Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
}
|
||||
|
||||
function escHtmlDetail(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function readOcppFileEdit(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
document.getElementById('rEditOcppFileName').textContent = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => { document.getElementById('rEditOcppLog').value = e.target.result; };
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
}
|
||||
|
||||
async function deleteReportPhoto(reportId, photoId) {
|
||||
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await API.delete(`/reports/${reportId}/photos/${photoId}`);
|
||||
load();
|
||||
} catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
function toggleReportEdit() {
|
||||
const view = document.getElementById('reportView');
|
||||
const edit = document.getElementById('reportEdit');
|
||||
const btn = document.getElementById('reportEditBtn');
|
||||
const isEditing = edit.classList.contains('active');
|
||||
if (isEditing) {
|
||||
edit.classList.remove('active');
|
||||
view.classList.remove('hidden');
|
||||
btn.innerHTML = '✏️ 내용 수정';
|
||||
} else {
|
||||
view.classList.add('hidden');
|
||||
edit.classList.add('active');
|
||||
btn.innerHTML = '✕ 취소';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReport(reportId) {
|
||||
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
|
||||
if (!issues.length) {
|
||||
const err = document.getElementById('rEditErr');
|
||||
err.textContent = '문제 유형을 1개 이상 선택해 주세요.';
|
||||
err.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
document.getElementById('rEditErr').style.display = 'none';
|
||||
const fd = new FormData();
|
||||
fd.append('issue_types', JSON.stringify(issues));
|
||||
fd.append('issue_detail', document.getElementById('rEditDetail').value);
|
||||
fd.append('error_code', document.getElementById('rEditErrorCode').value);
|
||||
fd.append('contact', document.getElementById('rEditContact').value);
|
||||
fd.append('occurred_at', document.getElementById('rEditOccurred').value);
|
||||
fd.append('status', document.getElementById('rEditStatus').value);
|
||||
fd.append('ocpp_log', document.getElementById('rEditOcppLog').value);
|
||||
const newPhotos = document.getElementById('rEditPhoto')?.files || [];
|
||||
Array.from(newPhotos).forEach(f => fd.append('photos', f));
|
||||
try {
|
||||
await API.patch(`/reports/${reportId}`, fd);
|
||||
load();
|
||||
} catch(e) {
|
||||
const err = document.getElementById('rEditErr');
|
||||
err.textContent = e.message;
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleParty() {
|
||||
const v = document.getElementById('partyType').value;
|
||||
document.getElementById('mfrWrap').style.display = v === 'manufacturer' ? 'block' : 'none';
|
||||
document.getElementById('customWrap').style.display = v === 'other' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function toggleApprovePanel() {
|
||||
const panel = document.getElementById('approvePanel');
|
||||
const btn = document.getElementById('approvePanelBtn');
|
||||
if (!panel) return;
|
||||
const opening = panel.style.display === 'none';
|
||||
panel.style.display = opening ? 'block' : 'none';
|
||||
if (btn) btn.textContent = opening ? '✕ 취소' : '✅ 조치 승인';
|
||||
}
|
||||
|
||||
function updateImpSection() {
|
||||
const action = document.querySelector('input[name="impAction"]:checked')?.value;
|
||||
document.getElementById('impLinkSection').style.display = action === 'link' ? 'block' : 'none';
|
||||
document.getElementById('impCreateSection').style.display = action === 'create' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterImpOptions() {
|
||||
const q = document.getElementById('impSearch').value.toLowerCase();
|
||||
[...document.getElementById('impSelect').options].forEach(opt => {
|
||||
opt.hidden = q && !opt.text.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
async function doApproveRepair(repairId) {
|
||||
const action = document.querySelector('input[name="impAction"]:checked')?.value || 'none';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('improvement_action', action);
|
||||
|
||||
if (action === 'link') {
|
||||
const sel = document.getElementById('impSelect');
|
||||
const impId = sel?.value;
|
||||
if (!impId) { showApproveErr('연결할 개선항목을 선택해 주세요.'); return; }
|
||||
fd.append('improvement_id', impId);
|
||||
} else if (action === 'create') {
|
||||
const title = document.getElementById('impTitle').value.trim();
|
||||
const cat = document.getElementById('impCategory').value;
|
||||
const desc = document.getElementById('impDesc').value.trim();
|
||||
if (!title || !cat || !desc) { showApproveErr('제목, 분류, 내용을 모두 입력해 주세요.'); return; }
|
||||
fd.append('imp_title', title);
|
||||
fd.append('imp_category', cat);
|
||||
fd.append('imp_description', desc);
|
||||
fd.append('imp_priority', document.getElementById('impPriority').value);
|
||||
const mfr = document.getElementById('impMfr').value;
|
||||
if (mfr) fd.append('imp_manufacturer_id', mfr);
|
||||
}
|
||||
|
||||
if (!confirm('이 조치 내역을 승인하시겠습니까?\n승인 후에는 정비사가 수정할 수 없습니다.')) return;
|
||||
|
||||
try {
|
||||
const res = await API.post(`/repairs/${repairId}/approve`, fd);
|
||||
let msg = '✅ 조치가 승인되었습니다.';
|
||||
if (res.improvement_id) {
|
||||
msg += `\n개선항목 #${res.improvement_id}에 연결되었습니다.`;
|
||||
}
|
||||
alert(msg);
|
||||
load();
|
||||
} catch(e) { showApproveErr(e.message); }
|
||||
}
|
||||
|
||||
function showApproveErr(msg) {
|
||||
const el = document.getElementById('approveErr');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
async function cancelRepair(repairId, isApproved) {
|
||||
const msg = isApproved
|
||||
? '⚠️ 승인된 조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?'
|
||||
: '조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?';
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
await API.delete('/repairs/' + repairId);
|
||||
load();
|
||||
} catch(e) { alert('조치취소 오류: ' + e.message); }
|
||||
}
|
||||
|
||||
async function approveReport(id) {
|
||||
if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return;
|
||||
await API.patch(`/reports/${id}/approve`);
|
||||
@@ -434,5 +913,7 @@ function showCostErr(msg) {
|
||||
|
||||
load();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,40 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>신고 목록</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
|
||||
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
|
||||
tr.selected { background:var(--light-gray,#f0f4ff); }
|
||||
#btnDelete { display:none; }
|
||||
|
||||
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
|
||||
.view-btn { padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white; cursor:pointer; color:var(--gray4); transition:all .15s; }
|
||||
.view-btn.active { background:var(--navy); color:white; }
|
||||
|
||||
#mapWrap {
|
||||
display:none;
|
||||
height: calc(100vh - 230px);
|
||||
min-height: 420px;
|
||||
border-radius:10px;
|
||||
overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
isolation: isolate;
|
||||
}
|
||||
#reportMap { width:100%; height:100%; }
|
||||
|
||||
.rp-pin {
|
||||
width:28px; height:28px; border-radius:50% 50% 50% 0;
|
||||
transform:rotate(-45deg); border:3px solid white;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||
}
|
||||
.rp-pin.pending { background:#EF4444; }
|
||||
.rp-pin.in_progress { background:#F59E0B; }
|
||||
.rp-pin.waiting { background:#3B82F6; }
|
||||
.rp-pin.revisit { background:#8B5CF6; }
|
||||
.rp-pin.done { background:#9CA3AF; }
|
||||
.rp-pin.multi { background:#7C3AED; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
@@ -17,6 +51,7 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
@@ -24,10 +59,19 @@
|
||||
<div class="main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 신고 목록</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
|
||||
<button class="btn btn-success btn-sm" onclick="API.download('/export/reports','AS신고목록.xlsx')">📥 엑셀 다운로드</button>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
|
||||
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="card" style="padding:12px 16px;margin-bottom:12px">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<select id="fStatus" style="width:auto">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending_approval">승인대기</option>
|
||||
@@ -39,42 +83,256 @@
|
||||
</select>
|
||||
<input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px">
|
||||
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
|
||||
<span id="resultCount" style="font-size:13px;color:var(--gray4);margin-left:4px"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 뷰 -->
|
||||
<div id="listWrap" class="card" style="padding:0">
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>충전기ID</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>상태</th><th>정비사</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
||||
<th>#</th><th>충전기ID</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>신고자</th><th>상태</th><th>정비사</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">조회된 신고가 없습니다.</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none;margin:14px">조회된 신고가 없습니다.</div>
|
||||
</div>
|
||||
|
||||
<!-- 지도 뷰 -->
|
||||
<div id="mapWrap"><div id="reportMap"></div></div>
|
||||
<div id="mapMeta" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:14px;flex-wrap:wrap;align-items:center">
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>접수</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#F59E0B;margin-right:4px"></span>처리중</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;margin-right:4px"></span>부품대기</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8B5CF6;margin-right:4px"></span>재방문</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#9CA3AF;margin-right:4px"></span>완료</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#7C3AED;margin-right:4px"></span>복수신고</span>
|
||||
<span id="mapNoGps" style="margin-left:auto"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let allRows = [];
|
||||
let curView = 'list';
|
||||
let reportMap = null;
|
||||
let mapMarkers = [];
|
||||
|
||||
// ── URL 파라미터 초기값 ──
|
||||
const _p = new URLSearchParams(location.search);
|
||||
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
|
||||
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
function setView(v) {
|
||||
sessionStorage.setItem('reportsView', v);
|
||||
curView = v;
|
||||
document.getElementById('btnList').classList.toggle('active', v === 'list');
|
||||
document.getElementById('btnMap').classList.toggle('active', v === 'map');
|
||||
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
|
||||
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
|
||||
document.getElementById('mapMeta').style.display = v === 'map' ? 'flex' : 'none';
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(v === 'list' && document.querySelectorAll('.row-chk:checked').length > 0) ? 'inline-flex' : 'none';
|
||||
if (v === 'map') {
|
||||
initReportMap();
|
||||
renderReportMap();
|
||||
setTimeout(() => reportMap && reportMap.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 체크박스 ──
|
||||
function updateDeleteBtn() {
|
||||
const checked = document.querySelectorAll('.row-chk:checked');
|
||||
document.getElementById('selCount').textContent = checked.length;
|
||||
document.getElementById('btnDelete').style.display =
|
||||
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
|
||||
}
|
||||
function toggleAll(chkAll) {
|
||||
document.querySelectorAll('.row-chk').forEach(c => {
|
||||
c.checked = chkAll.checked;
|
||||
c.closest('tr').classList.toggle('selected', chkAll.checked);
|
||||
});
|
||||
updateDeleteBtn();
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const checked = [...document.querySelectorAll('.row-chk:checked')];
|
||||
if (!checked.length) return;
|
||||
if (!confirm(`선택한 신고 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
|
||||
const ids = checked.map(c => parseInt(c.dataset.id));
|
||||
try { await API.delete('/reports/bulk', ids); await load(); }
|
||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
||||
}
|
||||
|
||||
function maskPhone(p) {
|
||||
const d = (p||'').replace(/\D/g,'');
|
||||
if (d.length >= 10) return d.slice(0,3) + '-****-' + d.slice(-4);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
async function load() {
|
||||
document.getElementById('chkAll').checked = false;
|
||||
updateDeleteBtn();
|
||||
let url = '/reports?';
|
||||
const s = document.getElementById('fStatus').value;
|
||||
const c = document.getElementById('fCharger').value.trim();
|
||||
if (s) url += 'status=' + s + '&';
|
||||
if (c) url += 'charger_id=' + c + '&';
|
||||
const rows = await API.get(url);
|
||||
allRows = await API.get(url);
|
||||
|
||||
document.getElementById('resultCount').textContent = allRows.length + '건';
|
||||
renderTable();
|
||||
if (curView === 'map') renderReportMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
document.getElementById('empty').style.display = rows.length ? 'none' : 'block';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'">
|
||||
<td>#${r.id}</td>
|
||||
<td><strong>${r.charger_id}</strong></td>
|
||||
<td>${r.station_name||'-'}</td>
|
||||
<td>${r.charger_type||'-'}</td>
|
||||
<td style="max-width:200px">${(r.issue_types||[]).join(', ')}</td>
|
||||
<td>${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td>${Auth.statusBadge(r.status)}</td>
|
||||
<td>${r.repair?.mechanic_name||'-'}</td>
|
||||
document.getElementById('empty').style.display = allRows.length ? 'none' : 'block';
|
||||
tbody.innerHTML = allRows.map((r, i) => `
|
||||
<tr>
|
||||
<td class="cb-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-chk" data-id="${r.id}"
|
||||
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
<span style="font-weight:700">${i+1}</span>
|
||||
<span style="display:block;font-size:10px;color:var(--gray4);font-weight:400">#${r.id}</span>
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"><strong>${r.charger_id}</strong></td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.station_name||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.charger_type||'-'}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer;max-width:200px">${(r.issue_types||[]).join(', ')}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
${r.source === 'dashboard'
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:#7C3AED">🖥 대시보드</div>`
|
||||
: r.source === 'admin'
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:var(--blue)">⚙️ 관리자</div>`
|
||||
: `<div style="font-size:12px;color:var(--text)">${r.contact ? maskPhone(r.contact) : '익명'}</div><div style="font-size:11px;color:#166534">📱 QR</div>`}
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.statusBadge(r.status)}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
${r.mechanic_name
|
||||
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.mechanic_name}</div>${r.mechanic_company ? `<div style="font-size:11px;color:var(--gray4)">${r.mechanic_company}</div>` : ''}`
|
||||
: '<span style="color:var(--gray4)">-</span>'}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
load();
|
||||
|
||||
// ── 지도 초기화 ──
|
||||
function initReportMap() {
|
||||
if (reportMap) return;
|
||||
reportMap = L.map('reportMap', { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(reportMap);
|
||||
}
|
||||
|
||||
// 상태별 마커 색상
|
||||
const STATUS_CLASS = {
|
||||
pending: 'pending', pending_approval: 'pending',
|
||||
in_progress: 'in_progress',
|
||||
waiting: 'waiting', revisit: 'revisit',
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
// ── 지도 마커 렌더 ──
|
||||
function renderReportMap() {
|
||||
if (!reportMap) return;
|
||||
mapMarkers.forEach(m => m.remove());
|
||||
mapMarkers = [];
|
||||
|
||||
// 충전기별 그룹핑 (charger GPS 우선, 없으면 신고 GPS)
|
||||
const grouped = {};
|
||||
allRows.forEach(r => {
|
||||
const lat = r.charger_lat || r.gps_lat;
|
||||
const lng = r.charger_lng || r.gps_lng;
|
||||
if (!lat || !lng) return;
|
||||
if (!grouped[r.charger_id]) {
|
||||
grouped[r.charger_id] = {
|
||||
charger_id: r.charger_id, charger_name: r.charger_name,
|
||||
station_name: r.station_name, location_detail: r.location_detail,
|
||||
lat, lng, reports: [],
|
||||
};
|
||||
}
|
||||
grouped[r.charger_id].reports.push(r);
|
||||
});
|
||||
|
||||
const groups = Object.values(grouped);
|
||||
const noGps = allRows.filter(r => !r.charger_lat && !r.gps_lat).length;
|
||||
document.getElementById('mapNoGps').textContent = noGps ? `📍 GPS 미등록 ${noGps}건 미표시` : '';
|
||||
|
||||
if (!groups.length) {
|
||||
reportMap.setView([36.5, 127.8], 7);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.forEach(g => {
|
||||
// 대표 상태 결정 (우선순위: pending > in_progress > waiting > revisit > done)
|
||||
const priority = ['pending','pending_approval','in_progress','waiting','revisit','done'];
|
||||
const topStatus = priority.find(s => g.reports.some(r => r.status === s)) || 'pending';
|
||||
const pinClass = g.reports.length > 1 ? 'multi' : (STATUS_CLASS[topStatus] || 'pending');
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="rp-pin ${pinClass}"></div>`,
|
||||
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
|
||||
});
|
||||
|
||||
const m = L.marker([g.lat, g.lng], { icon }).addTo(reportMap);
|
||||
|
||||
if (g.reports.length === 1) {
|
||||
const r = g.reports[0];
|
||||
m.on('click', () => { location.href = `/pages/admin/report-detail.html?id=${r.id}`; });
|
||||
} else {
|
||||
const rowsHtml = g.reports.map(r => {
|
||||
const h = (Date.now() - new Date(r.reported_at)) / 3600000;
|
||||
const age = h < 1 ? Math.round(h*60)+'분' : h < 24 ? Math.round(h)+'h' : (h/24).toFixed(1)+'일';
|
||||
return `<a href="/pages/admin/report-detail.html?id=${r.id}"
|
||||
style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:6px 8px;border-radius:6px;font-size:12px;text-decoration:none;
|
||||
color:inherit;background:#f9fafb;border:1px solid #e5e7eb;margin-bottom:5px">
|
||||
<span><strong>#${r.id}</strong> ${(r.issue_types||[]).join(', ')}</span>
|
||||
<span style="margin-left:8px;white-space:nowrap">${Auth.statusBadge(r.status)}</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
const popup = `
|
||||
<div style="min-width:230px">
|
||||
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">
|
||||
⚡ ${g.charger_id}
|
||||
<span style="font-size:12px;color:#7C3AED;font-weight:600">${g.reports.length}건</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:10px;line-height:1.5">
|
||||
📍 ${g.station_name||'-'}${g.location_detail ? '<br>'+g.location_detail : ''}
|
||||
</div>
|
||||
${rowsHtml}
|
||||
</div>`;
|
||||
m.bindPopup(popup, { maxWidth: 300 });
|
||||
}
|
||||
|
||||
mapMarkers.push(m);
|
||||
});
|
||||
|
||||
const bounds = L.latLngBounds(groups.map(g => [g.lat, g.lng]));
|
||||
reportMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
||||
if (groups.length === 1) reportMap.setZoom(14);
|
||||
}
|
||||
|
||||
load().then(() => {
|
||||
if (sessionStorage.getItem('reportsView') === 'map') setView('map');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/settings.html" class="active">⚙️ 설정</a>
|
||||
@@ -69,6 +70,103 @@
|
||||
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:4px">전체 설정 저장</button>
|
||||
</div>
|
||||
|
||||
<!-- 처리시간 지표 기준 -->
|
||||
<div class="card" style="max-width:560px;margin-top:20px">
|
||||
<div class="card-title">⏱ 처리시간 지표 기준</div>
|
||||
<div class="alert alert-info" style="margin-bottom:14px">
|
||||
대시보드의 <strong>처리시간 평균</strong> 및 <strong>대기 심각도</strong> 지표를 계산할 때<br>
|
||||
시작 시점으로 사용할 기준을 선택합니다.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-occurred">
|
||||
<input type="radio" name="timeBase" value="occurred" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
|
||||
<div>
|
||||
<div style="font-weight:700">📅 발생시각 기준 (권장)</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-top:3px">실제 고장이 발생한 시각부터 계산합니다. 발생시각이 없으면 등록시간으로 대체됩니다.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="check-item" style="display:flex;gap:12px;padding:14px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-reported">
|
||||
<input type="radio" name="timeBase" value="reported" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
|
||||
<div>
|
||||
<div style="font-weight:700">🕐 등록시간 기준</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-top:3px">신고가 시스템에 접수된 시각부터 계산합니다.</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 처리시간 집계 방식 -->
|
||||
<div class="card" style="max-width:560px;margin-top:20px">
|
||||
<div class="card-title">🏢 처리시간 집계 방식</div>
|
||||
<div class="alert alert-info" style="margin-bottom:14px">
|
||||
대기·처리시간 지표를 산출할 때 공휴일·주말을 처리하는 방식을 선택합니다.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-wt-off">
|
||||
<input type="radio" name="worktimeMode" value="off" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
|
||||
<div>
|
||||
<div style="font-weight:700">📅 달력 기준 (기본)</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-top:3px">주말·공휴일 포함 모든 경과시간을 그대로 집계합니다.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-wt-holiday24h">
|
||||
<input type="radio" name="worktimeMode" value="holiday_24h" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
|
||||
<div>
|
||||
<div style="font-weight:700">🗓 공휴일 제외 24시간</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-top:3px">공휴일만 제외하고, 주말을 포함한 나머지 날은 하루 24시간 전체를 카운트합니다.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="check-item" style="display:flex;gap:12px;padding:14px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-wt-worktime">
|
||||
<input type="radio" name="worktimeMode" value="worktime" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
|
||||
<div>
|
||||
<div style="font-weight:700">💼 업무시간 기준 (09:00–18:00)</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-top:3px">주말·공휴일 제외 후, 평일 업무시간(09:00–18:00) 내 경과시간만 집계합니다.</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 공휴일 관리 (공휴일 제외 모드일 때만 표시) -->
|
||||
<div id="holidaySection" style="display:none;margin-top:18px;border-top:1px solid var(--gray2);padding-top:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<div style="font-size:13px;font-weight:700;color:var(--navy)">
|
||||
📅 공휴일 관리
|
||||
<select id="holidayYear" onchange="loadHolidays()" style="margin-left:10px;width:auto;font-size:13px;padding:4px 8px">
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-outline" onclick="addFixedHolidays()">📋 고정 공휴일 추가</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="openHolidayModal()">+ 공휴일 추가</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--gray4);margin-bottom:10px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;padding:8px 12px">
|
||||
⚠ <strong>설날·추석·부처님오신날</strong> 등 음력 공휴일과 <strong>대체공휴일</strong>은 매년 직접 추가해야 합니다.
|
||||
</div>
|
||||
<div id="holidayList" style="max-height:300px;overflow-y:auto">
|
||||
<div style="color:var(--gray4);font-size:13px;text-align:center;padding:20px">불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공휴일 추가 모달 -->
|
||||
<div class="modal-bg hidden" id="holidayModal">
|
||||
<div class="modal" style="max-width:380px">
|
||||
<div class="modal-title">공휴일 추가</div>
|
||||
<div class="form-group">
|
||||
<label>날짜 <span class="req">*</span></label>
|
||||
<input type="date" id="hDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>공휴일명 <span class="req">*</span></label>
|
||||
<input type="text" id="hName" placeholder="예) 추석">
|
||||
</div>
|
||||
<div id="hErr" class="alert alert-danger" style="display:none"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeHolidayModal()">취소</button>
|
||||
<button class="btn btn-primary" onclick="saveHoliday()">추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이미지 압축 설정 -->
|
||||
<div class="card" style="max-width:560px;margin-top:20px">
|
||||
<div class="card-title">🖼️ 사진 업로드 압축 설정</div>
|
||||
@@ -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 = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:20px">등록된 공휴일이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="background:var(--gray2)">
|
||||
<th style="padding:7px 10px;text-align:left">날짜</th>
|
||||
<th style="padding:7px 10px;text-align:left">공휴일명</th>
|
||||
<th style="padding:7px 10px;width:50px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${list.map(h => `
|
||||
<tr style="border-bottom:1px solid var(--gray2)">
|
||||
<td style="padding:7px 10px">${h.date}</td>
|
||||
<td style="padding:7px 10px">${h.name}</td>
|
||||
<td style="padding:7px 10px;text-align:center">
|
||||
<button onclick="deleteHoliday('${h.date}')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:15px" title="삭제">✕</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -6,12 +6,35 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
body { display:flex; align-items:center; justify-content:center; min-height:100vh; background:var(--navy); }
|
||||
.login-box{background:white;border-radius:14px;padding:40px 36px;width:100%;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,.3);}
|
||||
.login-box {
|
||||
background:white; border-radius:14px; padding:40px 36px;
|
||||
width:100%; max-width:380px; box-shadow:0 8px 32px rgba(0,0,0,.3);
|
||||
}
|
||||
.login-logo { text-align:center; margin-bottom:28px; }
|
||||
.login-logo h1 { font-size:22px; font-weight:900; color:var(--navy); }
|
||||
.login-logo p { font-size:12px; color:var(--gray4); margin-top:4px; }
|
||||
.login-box .form-group { margin-bottom:14px; }
|
||||
#err{color:var(--red);font-size:13px;text-align:center;min-height:18px;margin-bottom:8px;}
|
||||
#err, #regErr { font-size:13px; text-align:center; min-height:18px; margin-bottom:8px; }
|
||||
#err { color:var(--red); }
|
||||
#regErr { color:var(--red); }
|
||||
|
||||
.tab-row {
|
||||
display:flex; gap:0; border-bottom:2px solid var(--gray2); margin-bottom:24px;
|
||||
}
|
||||
.tab-row button {
|
||||
flex:1; background:none; border:none; padding:9px 0; font-size:14px; font-weight:600;
|
||||
color:var(--gray4); border-bottom:3px solid transparent; margin-bottom:-2px; cursor:pointer;
|
||||
transition:color .15s, border-color .15s;
|
||||
}
|
||||
.tab-row button.active { color:var(--navy); border-bottom-color:var(--accent); }
|
||||
|
||||
.pane { display:none; }
|
||||
.pane.active { display:block; }
|
||||
|
||||
.reg-notice {
|
||||
background:#EFF6FF; border:1px solid #BFDBFE; border-radius:8px;
|
||||
padding:10px 14px; font-size:12px; color:#1E40AF; margin-bottom:16px; line-height:1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,6 +43,14 @@ body{display:flex;align-items:center;justify-content:center;min-height:100vh;bac
|
||||
<h1>⚡ EV AS 관리</h1>
|
||||
<p>cs.byunc.com</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-row">
|
||||
<button id="tabLogin" class="active" onclick="switchTab('login')">로그인</button>
|
||||
<button id="tabRegister" onclick="switchTab('register')">회원가입</button>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 -->
|
||||
<div class="pane active" id="paneLogin">
|
||||
<div class="form-group">
|
||||
<label>아이디</label>
|
||||
<input type="text" id="username" placeholder="아이디 입력" autofocus>
|
||||
@@ -31,9 +62,62 @@ body{display:flex;align-items:center;justify-content:center;min-height:100vh;bac
|
||||
<div id="err"></div>
|
||||
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button>
|
||||
</div>
|
||||
|
||||
<!-- 회원가입 -->
|
||||
<div class="pane" id="paneRegister">
|
||||
<div class="reg-notice">
|
||||
📌 정비사 계정으로 가입됩니다.<br>
|
||||
가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>이름 <span style="color:var(--red)">*</span></label>
|
||||
<input type="text" id="regName" placeholder="실명 입력">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>아이디 <span style="color:var(--red)">*</span></label>
|
||||
<input type="text" id="regUsername" placeholder="영문·숫자 조합">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비밀번호 <span style="color:var(--red)">*</span></label>
|
||||
<input type="password" id="regPassword" placeholder="8자 이상 권장">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비밀번호 확인 <span style="color:var(--red)">*</span></label>
|
||||
<input type="password" id="regPassword2" placeholder="비밀번호 재입력">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>전화번호 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
|
||||
<input type="tel" id="regPhone" placeholder="예) 010-1234-5678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>회사명 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
|
||||
<select id="regCompany">
|
||||
<option value="">-- 소속 제조사 없음 --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="regErr"></div>
|
||||
<button class="btn btn-primary btn-lg" id="regBtn">가입 신청</button>
|
||||
<div id="regOk" class="alert alert-success" style="display:none;margin-top:14px;text-align:center">
|
||||
✅ 가입 신청이 완료되었습니다.<br>
|
||||
<span style="font-size:12px">관리자 승인 후 로그인 가능합니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.getElementById('tabLogin').classList.toggle('active', name==='login');
|
||||
document.getElementById('tabRegister').classList.toggle('active', name==='register');
|
||||
document.getElementById('paneLogin').classList.toggle('active', name==='login');
|
||||
document.getElementById('paneRegister').classList.toggle('active', name==='register');
|
||||
document.getElementById('err').textContent = '';
|
||||
document.getElementById('regErr').textContent = '';
|
||||
}
|
||||
|
||||
// ── 로그인 ──
|
||||
async function doLogin() {
|
||||
const u = document.getElementById('username').value.trim();
|
||||
const p = document.getElementById('password').value;
|
||||
@@ -55,8 +139,62 @@ async function doLogin() {
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 제조사 목록 로드 (비인증) ──
|
||||
async function loadCompanies() {
|
||||
try {
|
||||
const list = await fetch('/api/manufacturers/public').then(r => r.json());
|
||||
const sel = document.getElementById('regCompany');
|
||||
list.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name; opt.textContent = m.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
loadCompanies();
|
||||
|
||||
// ── 회원가입 ──
|
||||
async function doRegister() {
|
||||
const name = document.getElementById('regName').value.trim();
|
||||
const uname = document.getElementById('regUsername').value.trim();
|
||||
const pw = document.getElementById('regPassword').value;
|
||||
const pw2 = document.getElementById('regPassword2').value;
|
||||
const phone = document.getElementById('regPhone').value.trim();
|
||||
const company = document.getElementById('regCompany').value;
|
||||
const errEl = document.getElementById('regErr');
|
||||
|
||||
errEl.textContent = '';
|
||||
if (!name) { errEl.textContent = '이름을 입력하세요.'; return; }
|
||||
if (!uname) { errEl.textContent = '아이디를 입력하세요.'; return; }
|
||||
if (!pw) { errEl.textContent = '비밀번호를 입력하세요.'; return; }
|
||||
if (pw !== pw2) { errEl.textContent = '비밀번호가 일치하지 않습니다.'; return; }
|
||||
|
||||
document.getElementById('regBtn').disabled = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('username', uname);
|
||||
fd.append('password', pw);
|
||||
fd.append('name', name);
|
||||
fd.append('phone', phone);
|
||||
fd.append('company', company);
|
||||
const res = await fetch('/api/auth/register', { method:'POST', body: fd });
|
||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
|
||||
document.getElementById('regOk').style.display = 'block';
|
||||
document.getElementById('regBtn').style.display = 'none';
|
||||
['regName','regUsername','regPassword','regPassword2','regPhone'].forEach(id =>
|
||||
document.getElementById(id).value = '');
|
||||
document.getElementById('regCompany').value = '';
|
||||
} catch(e) {
|
||||
errEl.textContent = e.message;
|
||||
document.getElementById('regBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('loginBtn').addEventListener('click', doLogin);
|
||||
document.getElementById('password').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
|
||||
document.getElementById('regBtn').addEventListener('click', doRegister);
|
||||
document.getElementById('regPassword2').addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,12 +4,57 @@
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>정비사 대시보드</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
|
||||
.view-btn {
|
||||
padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white;
|
||||
cursor:pointer; color:var(--gray4); transition:all .15s;
|
||||
}
|
||||
.view-btn.active { background:var(--navy); color:white; }
|
||||
|
||||
#mapWrap {
|
||||
display:none;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 420px;
|
||||
border-radius:10px;
|
||||
overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
}
|
||||
#map { width:100%; height:100%; }
|
||||
|
||||
/* 마커 커스텀 */
|
||||
.mk-pin {
|
||||
width:32px; height:32px; border-radius:50% 50% 50% 0;
|
||||
transform:rotate(-45deg); border:3px solid white;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||
}
|
||||
.mk-pin.pending { background:#EF4444; }
|
||||
.mk-pin.in_progress{ background:#F59E0B; }
|
||||
|
||||
.leaflet-popup-content { min-width:200px; font-size:13px; }
|
||||
.popup-title { font-size:14px; font-weight:700; color:var(--navy); margin-bottom:6px; }
|
||||
.popup-meta { font-size:12px; color:var(--gray4); margin-bottom:8px; line-height:1.6; }
|
||||
.popup-tags { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:10px; }
|
||||
.popup-tag { font-size:11px; padding:2px 7px; background:var(--gray1); border-radius:8px; border:1px solid var(--gray2); }
|
||||
.popup-count { font-size:12px; font-weight:700; color:#DC2626; margin-bottom:10px; }
|
||||
|
||||
.no-gps-notice {
|
||||
font-size:12px; color:var(--gray4); padding:6px 10px;
|
||||
background:var(--gray1); border-radius:6px; margin-bottom:10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html" class="active">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
@@ -18,18 +63,31 @@
|
||||
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 처리 목록</h2>
|
||||
<a href="/pages/mechanic/scan.html" class="btn btn-accent">📷 QR 스캔하여 조치 시작</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
<select id="filterStatus" style="width:auto">
|
||||
|
||||
<!-- 필터 + 뷰 토글 -->
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center;">
|
||||
<select id="filterStatus" style="width:auto" onchange="load()">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
</select>
|
||||
<button class="btn btn-outline btn-sm" onclick="load()">새로고침</button>
|
||||
<div style="margin-left:auto">
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
|
||||
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 뷰 -->
|
||||
<div id="listWrap" class="card" style="padding:0">
|
||||
<div style="padding:14px 16px 0">
|
||||
<div id="noGpsNotice" class="no-gps-notice" style="display:none"></div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
@@ -37,23 +95,68 @@
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">처리 대기 중인 AS가 없습니다.</div>
|
||||
<div id="empty" class="alert alert-info" style="display:none;margin:14px">처리 대기 중인 AS가 없습니다.</div>
|
||||
</div>
|
||||
|
||||
<!-- 지도 뷰 -->
|
||||
<div id="mapWrap">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let allRows = [];
|
||||
let mapObj = null;
|
||||
let markers = [];
|
||||
let curView = 'list';
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
function setView(v) {
|
||||
curView = v;
|
||||
document.getElementById('btnList').classList.toggle('active', v === 'list');
|
||||
document.getElementById('btnMap').classList.toggle('active', v === 'map');
|
||||
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
|
||||
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
|
||||
if (v === 'map') {
|
||||
initMap();
|
||||
renderMap();
|
||||
// 컨테이너가 보인 직후 Leaflet에 크기 재계산 알림
|
||||
setTimeout(() => mapObj && mapObj.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 데이터 로드 ──
|
||||
async function load() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const rows = await API.get('/repairs/pending' + (status ? '?status='+status : ''));
|
||||
allRows = await API.get('/repairs/pending' + (status ? '?status=' + status : ''));
|
||||
renderList();
|
||||
if (curView === 'map') renderMap();
|
||||
}
|
||||
|
||||
// ── 목록 렌더 ──
|
||||
function renderList() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
if (!rows.length) { tbody.innerHTML=''; document.getElementById('empty').style.display='block'; return; }
|
||||
document.getElementById('empty').style.display='none';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr onclick="location.href='/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}'">
|
||||
const empty = document.getElementById('empty');
|
||||
if (!allRows.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
document.getElementById('noGpsNotice').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = allRows.map(r => {
|
||||
const href = r.repair_id
|
||||
? `/pages/mechanic/repair.html?repair_id=${r.repair_id}`
|
||||
: `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`;
|
||||
return `
|
||||
<tr onclick="location.href='${href}'">
|
||||
<td>#${r.id}</td>
|
||||
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
|
||||
<td>${r.station_name||'-'}</td>
|
||||
@@ -61,9 +164,120 @@ async function load() {
|
||||
<td>${(r.issue_types||[]).join(', ')}</td>
|
||||
<td>${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td>${Auth.statusBadge(r.status)}</td>
|
||||
<td><a class="btn btn-primary btn-sm" href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}" onclick="event.stopPropagation()">조치</a></td>
|
||||
</tr>`).join('');
|
||||
<td><a class="btn btn-primary btn-sm" href="${href}" onclick="event.stopPropagation()">조치</a></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const noGps = allRows.filter(r => !r.gps_lat || !r.gps_lng).length;
|
||||
const noticeEl = document.getElementById('noGpsNotice');
|
||||
if (noGps) {
|
||||
noticeEl.textContent = `📍 GPS 미등록 충전기 ${noGps}건은 지도에 표시되지 않습니다.`;
|
||||
noticeEl.style.display = 'block';
|
||||
} else {
|
||||
noticeEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 지도 초기화 ──
|
||||
function initMap() {
|
||||
if (mapObj) return;
|
||||
mapObj = L.map('map', { zoomControl: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(mapObj);
|
||||
}
|
||||
|
||||
// ── 지도 마커 렌더 ──
|
||||
function renderMap() {
|
||||
if (!mapObj) return;
|
||||
|
||||
// 기존 마커 제거
|
||||
markers.forEach(m => m.remove());
|
||||
markers = [];
|
||||
|
||||
// 충전기별로 그룹핑
|
||||
const chargerMap = {};
|
||||
allRows.forEach(r => {
|
||||
if (!r.gps_lat || !r.gps_lng) return;
|
||||
const key = r.charger_id;
|
||||
if (!chargerMap[key]) {
|
||||
chargerMap[key] = {
|
||||
charger_id: r.charger_id,
|
||||
charger_name: r.charger_name,
|
||||
station_name: r.station_name,
|
||||
location_detail:r.location_detail,
|
||||
gps_lat: r.gps_lat,
|
||||
gps_lng: r.gps_lng,
|
||||
reports: [],
|
||||
};
|
||||
}
|
||||
chargerMap[key].reports.push(r);
|
||||
});
|
||||
|
||||
const chargers = Object.values(chargerMap);
|
||||
if (!chargers.length) {
|
||||
// GPS 없는 경우 한국 중심으로
|
||||
mapObj.setView([36.5, 127.8], 7);
|
||||
return;
|
||||
}
|
||||
|
||||
chargers.forEach(c => {
|
||||
const hasInProgress = c.reports.some(r => r.status === 'in_progress');
|
||||
const statusClass = hasInProgress ? 'in_progress' : 'pending';
|
||||
const color = hasInProgress ? '#F59E0B' : '#EF4444';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="mk-pin ${statusClass}"></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 32],
|
||||
popupAnchor: [0, -34],
|
||||
});
|
||||
|
||||
// 팝업 내용
|
||||
const allIssues = [...new Set(c.reports.flatMap(r => r.issue_types || []))];
|
||||
const firstReport = c.reports[0];
|
||||
const href = firstReport.repair_id
|
||||
? `/pages/mechanic/repair.html?repair_id=${firstReport.repair_id}`
|
||||
: `/pages/mechanic/repair.html?charger_id=${c.charger_id}&report_id=${firstReport.id}`;
|
||||
|
||||
const popup = `
|
||||
<div class="popup-title">⚡ ${c.charger_id}</div>
|
||||
<div class="popup-meta">
|
||||
📍 ${c.station_name || '-'}${c.location_detail ? '<br>' + c.location_detail : ''}
|
||||
${c.charger_name ? '<br>' + c.charger_name : ''}
|
||||
</div>
|
||||
${c.reports.length > 1
|
||||
? `<div class="popup-count">📋 신고 ${c.reports.length}건</div>`
|
||||
: ''}
|
||||
<div class="popup-tags">
|
||||
${allIssues.map(t => `<span class="popup-tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<a href="${href}" class="btn btn-primary btn-sm" style="font-size:12px;text-decoration:none">🔧 조치 시작</a>
|
||||
${c.reports.length > 1
|
||||
? c.reports.map(r =>
|
||||
`<a href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}"
|
||||
style="font-size:11px;color:var(--blue);text-decoration:none;align-self:center">#${r.id}</a>`
|
||||
).join('')
|
||||
: ''}
|
||||
</div>`;
|
||||
|
||||
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
|
||||
.addTo(mapObj)
|
||||
.bindPopup(popup, { maxWidth: 280 });
|
||||
markers.push(m);
|
||||
});
|
||||
|
||||
// 모든 마커가 보이도록 뷰 조정
|
||||
const bounds = L.latLngBounds(chargers.map(c => [c.gps_lat, c.gps_lng]));
|
||||
mapObj.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
|
||||
// 마커 1개면 줌 고정
|
||||
if (chargers.length === 1) mapObj.setZoom(15);
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
144
frontend/static/pages/mechanic/history.html
Normal file
144
frontend/static/pages/mechanic/history.html
Normal file
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>처리 이력</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.history-card {
|
||||
border: 1px solid var(--gray2);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.history-card:hover { box-shadow: 0 3px 12px rgba(0,0,0,.1); }
|
||||
.history-card.approved { border-left: 4px solid var(--green); }
|
||||
.history-card.pending { border-left: 4px solid var(--orange); }
|
||||
.hc-top { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; margin-bottom:8px; }
|
||||
.hc-title { font-size:14px; font-weight:700; color:var(--navy); }
|
||||
.hc-meta { font-size:12px; color:var(--gray4); margin-top:3px; }
|
||||
.hc-tags { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; }
|
||||
.hc-tag { font-size:11px; padding:2px 8px; border-radius:10px; background:var(--gray1); color:var(--text2); border:1px solid var(--gray2); }
|
||||
.badge-approved { background:#D1FAE5; color:#065F46; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
|
||||
.badge-pending { background:#FEF3C7; color:#92400E; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html" class="active">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
||||
<a href="/pages/mechanic/history.html" class="active">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">내 처리 이력</h2>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
<select id="fStatus" style="width:auto">
|
||||
<option value="">전체</option>
|
||||
<option value="approved">승인 완료</option>
|
||||
<option value="pending">승인 대기</option>
|
||||
</select>
|
||||
<select id="fResult" style="width:auto">
|
||||
<option value="">전체 처리상태</option>
|
||||
<option value="done">완료</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="waiting">부품대기</option>
|
||||
<option value="revisit">재방문</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="alert alert-info">이력을 불러오는 중...</div>
|
||||
<div id="error" class="alert alert-danger" style="display:none"></div>
|
||||
<div id="list"></div>
|
||||
<div id="empty" class="alert alert-info" style="display:none">처리 이력이 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
const RESULT_LABEL = {
|
||||
done: '✅ 완료',
|
||||
in_progress: '🔧 진행중',
|
||||
waiting: '⏳ 부품대기',
|
||||
revisit: '🔄 재방문',
|
||||
};
|
||||
|
||||
let allRepairs = [];
|
||||
|
||||
async function load() {
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('error').style.display = 'none';
|
||||
try {
|
||||
allRepairs = await API.get('/repairs/my');
|
||||
render();
|
||||
} catch(e) {
|
||||
document.getElementById('error').textContent = '이력을 불러오지 못했습니다: ' + e.message;
|
||||
document.getElementById('error').style.display = 'block';
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const fStatus = document.getElementById('fStatus').value;
|
||||
const fResult = document.getElementById('fResult').value;
|
||||
|
||||
let list = allRepairs;
|
||||
if (fStatus === 'approved') list = list.filter(r => r.approved_at);
|
||||
if (fStatus === 'pending') list = list.filter(r => !r.approved_at);
|
||||
if (fResult) list = list.filter(r => r.result_status === fResult);
|
||||
|
||||
document.getElementById('empty').style.display = list.length ? 'none' : 'block';
|
||||
document.getElementById('list').innerHTML = list.map(r => {
|
||||
const isApproved = !!r.approved_at;
|
||||
const dt = r.completed_at
|
||||
? new Date(r.completed_at).toLocaleDateString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit'})
|
||||
: '';
|
||||
return `
|
||||
<div class="history-card ${isApproved ? 'approved' : 'pending'}"
|
||||
onclick="location.href='/pages/mechanic/repair.html?repair_id=${r.id}'">
|
||||
<div class="hc-top">
|
||||
<div>
|
||||
<div class="hc-title">
|
||||
${r.station_name || '-'} · ${r.charger_id || '-'}
|
||||
</div>
|
||||
<div class="hc-meta">${r.charger_name || ''} · 신고 ${r.report_count}건 · ${dt}</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;">
|
||||
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
|
||||
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
|
||||
</span>
|
||||
<span style="font-size:11px;color:var(--gray4)">${RESULT_LABEL[r.result_status] || r.result_status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hc-tags">
|
||||
${(r.repair_types||[]).map(t => `<span class="hc-tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('fStatus').onchange = render;
|
||||
document.getElementById('fResult').onchange = render;
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,7 +17,20 @@
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="main" style="max-width:640px;margin:0 auto;">
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
||||
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="max-width:640px;margin:0 auto;">
|
||||
<div style="margin-bottom:14px;">
|
||||
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
|
||||
</div>
|
||||
@@ -34,10 +47,7 @@
|
||||
<div class="form-group">
|
||||
<label>조치 유형 <span class="req">*</span></label>
|
||||
<div class="check-group" id="repairTypes">
|
||||
<label class="check-item"><input type="checkbox" value="부품교체"> 부품 교체</label>
|
||||
<label class="check-item"><input type="checkbox" value="재시작"> 재시작</label>
|
||||
<label class="check-item"><input type="checkbox" value="설정변경"> 설정 변경</label>
|
||||
<label class="check-item"><input type="checkbox" value="기타"> 기타</label>
|
||||
<div style="color:var(--gray4);font-size:12px">불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +56,13 @@
|
||||
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 사진 안내 -->
|
||||
<div style="background:#FFF8E6;border:1px solid #FFD600;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;line-height:1.7;">
|
||||
📌 <strong>촬영 필수 항목</strong><br>
|
||||
· 충전기 <strong>명판(제조사·모델명)</strong> 및 <strong>충전기 식별 ID</strong>가 선명하게 보이도록 촬영해 주세요.<br>
|
||||
· 조치 전·후 상태를 각각 촬영하면 검증에 도움이 됩니다.
|
||||
</div>
|
||||
|
||||
<!-- 조치 전 사진 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@@ -64,23 +81,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>처리 상태 <span class="req">*</span></label>
|
||||
<select id="resultStatus">
|
||||
<option value="done">✅ 처리 완료</option>
|
||||
<option value="waiting">⏳ 부품 대기</option>
|
||||
<option value="revisit">🔄 재방문 필요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" style="margin-bottom:14px;">
|
||||
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록)
|
||||
</div>
|
||||
|
||||
<div id="gpsStatus" class="alert alert-info" style="margin-bottom:14px;">
|
||||
📍 위치 정보 수집 중...
|
||||
</div>
|
||||
<input type="hidden" id="mechanicLat">
|
||||
<input type="hidden" id="mechanicLng">
|
||||
|
||||
<div id="formErr" class="alert alert-danger" style="display:none"></div>
|
||||
<button class="btn btn-primary btn-lg" id="submitBtn">조치 완료 저장</button>
|
||||
|
||||
<!-- 저장 버튼 영역 -->
|
||||
<div style="background:var(--gray1);border:1px solid var(--gray2);border-radius:10px;padding:16px;margin-top:4px;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:10px;">💾 저장 방식 선택</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<button class="btn btn-outline btn-lg" id="saveBtn" style="flex:1;min-width:140px;" onclick="submitForm(false)">
|
||||
💾 상태 저장
|
||||
</button>
|
||||
<button class="btn btn-primary btn-lg" id="doneBtn" style="flex:1;min-width:140px;" onclick="submitForm(true)">
|
||||
✅ 조치 완료 저장
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;">
|
||||
<div style="flex:1;min-width:140px;">
|
||||
<label style="font-size:11px;color:var(--gray4)">저장 상태 선택</label>
|
||||
<select id="resultStatus" style="width:100%;margin-top:4px;font-size:13px;">
|
||||
<option value="in_progress">🔧 계속 진행 중</option>
|
||||
<option value="waiting">⏳ 부품 대기</option>
|
||||
<option value="revisit">🔄 재방문 필요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;min-width:140px;display:flex;align-items:flex-end;">
|
||||
<div style="font-size:11px;color:var(--gray4);padding-bottom:6px;line-height:1.6;">
|
||||
✅ <strong>조치 완료 저장</strong>은 처리 완료로 확정됩니다.<br>
|
||||
💾 <strong>상태 저장</strong>은 왼쪽 상태로 임시 저장됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- max-width wrapper -->
|
||||
</div><!-- .main -->
|
||||
</div><!-- .layout -->
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
@@ -90,15 +134,19 @@ Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const chargerId = params.get('charger_id');
|
||||
const repairId = params.get('repair_id'); // 편집 모드
|
||||
const chargerId = params.get('charger_id'); // 신규 모드
|
||||
const initReportId = params.get('report_id');
|
||||
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 = `
|
||||
<div class="card-title">⚡ 충전기 정보</div>
|
||||
@@ -108,26 +156,19 @@ async function load() {
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${charger.station_name}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name||'-'}</strong></div>
|
||||
</div>`;
|
||||
|
||||
const reports = await API.get('/repairs/charger/' + chargerId + '/open');
|
||||
const list = document.getElementById('reportList');
|
||||
if (!reports.length) {
|
||||
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
if (!reports.length) { list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>'; return; }
|
||||
list.innerHTML = reports.map(r => `
|
||||
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:${selectedReports.has(r.id)?'#E3EDFF':'white'}">
|
||||
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''}
|
||||
value="${r.id}"
|
||||
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}"
|
||||
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
|
||||
onchange="toggleReport(${r.id},this.checked,this.closest('label'))">
|
||||
<div>
|
||||
<div><strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}</div>
|
||||
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
|
||||
<div style="font-size:11px;color:var(--gray4)">${Auth.fmtDt(r.reported_at)}</div>
|
||||
${r.photos.length
|
||||
? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>`
|
||||
: ''}
|
||||
${r.photos.length ? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>` : ''}
|
||||
</div>
|
||||
</label>`).join('');
|
||||
}
|
||||
@@ -137,48 +178,195 @@ function toggleReport(id, checked, label) {
|
||||
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',
|
||||
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
|
||||
|
||||
// 충전기 카드
|
||||
document.getElementById('chargerCard').innerHTML = `
|
||||
<div class="card-title">⚡ 충전기 정보</div>
|
||||
<div class="form-row">
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${repair.charger_id||'-'}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${repair.charger_name||'-'}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${repair.station_name||'-'}</strong></div>
|
||||
</div>`;
|
||||
|
||||
// 연결된 신고 (읽기 전용)
|
||||
document.getElementById('reportList').innerHTML = (repair.reports||[]).length
|
||||
? (repair.reports||[]).map(r => `
|
||||
<div style="padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;background:#F8FAFF;">
|
||||
<strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}
|
||||
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
|
||||
</div>`).join('')
|
||||
: '<div class="alert alert-info">연결된 신고 없음</div>';
|
||||
|
||||
// 승인 완료 → 잠금
|
||||
if (repair.approved_at) {
|
||||
const dt = new Date(repair.approved_at).toLocaleString('ko-KR');
|
||||
document.querySelector('.card:last-child').innerHTML = `
|
||||
<div class="alert alert-success" style="margin-bottom:0">
|
||||
✅ <strong>관리자 승인 완료</strong> (${repair.approved_by_name||''} · ${dt})<br>
|
||||
<span style="font-size:12px;">승인된 조치는 수정할 수 없습니다.</span>
|
||||
</div>
|
||||
${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
|
||||
? `<div style="margin-top:8px"><label style="font-size:11px;font-weight:700;color:var(--navy2)">${type}</label>
|
||||
<div class="photo-preview">${(list||[]).map(p=>`<img src="${p.path||p}" onclick="window.open('${p.path||p}')" style="cursor:zoom-in">`).join('')}</div></div>`
|
||||
: '';
|
||||
return `<div style="padding:14px 0">
|
||||
<table style="font-size:13px;width:100%">
|
||||
<tr><td style="color:var(--gray4);width:90px">조치유형</td><td>${(r.repair_types||[]).join(', ')}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">조치내용</td><td>${r.description||'-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">처리결과</td><td>${LABEL[r.result_status]||r.result_status}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(r.completed_at)}</td></tr>
|
||||
</table>
|
||||
${photoHtml('조치 전 사진', r.photos_before)}
|
||||
${photoHtml('조치 후 사진', r.photos_after)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderExistingPhotos(repair) {
|
||||
const mkGrid = (list, type) => {
|
||||
if (!list || !list.length) return '';
|
||||
return `<div style="display:flex;flex-wrap:wrap;gap:7px;margin-bottom:8px;">
|
||||
${list.map(p => `
|
||||
<div style="position:relative;">
|
||||
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
|
||||
<button onclick="deleteRepairPhoto(${repair.id},${p.id},'${type}')"
|
||||
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
};
|
||||
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 =
|
||||
`📍 위치 수집 완료 <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
|
||||
},
|
||||
() => {
|
||||
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 {
|
||||
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('✅ 조치 완료 저장되었습니다.');
|
||||
location.href = '/pages/mechanic/dashboard.html';
|
||||
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 => `
|
||||
<label class="check-item">
|
||||
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
|
||||
${t.label}
|
||||
</label>`).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('repairTypes').innerHTML =
|
||||
'<div class="alert alert-danger" style="margin:0">조치유형을 불러오지 못했습니다.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
loadEdit();
|
||||
} else {
|
||||
loadRepairTypes();
|
||||
loadCreate();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,15 +6,28 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
#reader{width:100%;max-width:400px;margin:0 auto;border-radius:10px;overflow:hidden;}
|
||||
.scan-wrap{max-width:480px;margin:0 auto;padding:20px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<span class="nav-brand">⚡ QR 스캔</span>
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="scan-wrap">
|
||||
<div class="mech-tab-bar">
|
||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||
<a href="/pages/mechanic/scan.html" class="active">📷<span>QR 스캔</span></a>
|
||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>
|
||||
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="max-width:480px;margin:0 auto;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:16px">📷 QR 스캔</h2>
|
||||
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
|
||||
<div id="reader"></div>
|
||||
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
|
||||
@@ -28,6 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://unpkg.com/html5-qrcode/minified/html5-qrcode.min.js"></script>
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -221,7 +221,8 @@ body { background: var(--gray1); }
|
||||
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
|
||||
<div class="issue-grid" id="issueGrid"></div>
|
||||
<div id="errorCodeWrap" style="margin-top:10px;display:none;">
|
||||
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">
|
||||
<!-- populated as dropdown or text input depending on chargerErrors -->
|
||||
<div id="errorCodeInner"></div>
|
||||
</div>
|
||||
<div id="etcWrap" style="margin-top:10px;display:none;">
|
||||
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
|
||||
@@ -260,6 +261,24 @@ body { background: var(--gray1); }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>📡 신고 범위</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
|
||||
<input type="radio" name="scope" value="single" checked style="width:auto;accent-color:var(--accent)">
|
||||
<div><strong>이 충전기만</strong><div style="font-size:11px;color:var(--gray4)">현재 스캔한 충전기에만 신고</div></div>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
|
||||
<input type="radio" name="scope" value="station" style="width:auto;accent-color:var(--accent)">
|
||||
<div><strong>충전소 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전소의 모든 충전기에 신고</div></div>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
|
||||
<input type="radio" name="scope" value="type" style="width:auto;accent-color:var(--accent)">
|
||||
<div><strong>동일 모델 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전기 모델 전체에 신고</div></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>📝 상세 설명 (선택)</h3>
|
||||
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
|
||||
@@ -294,7 +313,7 @@ body { background: var(--gray1); }
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/imageCompress.js"></script>
|
||||
<script>
|
||||
const ISSUES = [
|
||||
let ISSUES = [
|
||||
{key:'충전불가', label:'⚡ 충전 불가'},
|
||||
{key:'화면오류', label:'🖥 화면 오류'},
|
||||
{key:'케이블불량',label:'🔌 케이블 불량'},
|
||||
@@ -303,6 +322,7 @@ const ISSUES = [
|
||||
{key:'에러발생', label:'⚠️ 에러 발생'},
|
||||
{key:'기타', label:'📋 기타'},
|
||||
];
|
||||
let chargerErrors = [];
|
||||
|
||||
const STATUS_ICON = {
|
||||
pending_approval: '🕐',
|
||||
@@ -327,7 +347,11 @@ let isStatusOpen = true;
|
||||
// ── 충전기 정보 로드 ──
|
||||
async function loadCharger() {
|
||||
try {
|
||||
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
|
||||
const [c, errs] = await Promise.all([
|
||||
fetch('/api/chargers/' + chargerId).then(r => r.json()),
|
||||
fetch('/api/chargers/' + chargerId + '/errors').then(r => r.json()).catch(() => []),
|
||||
]);
|
||||
chargerErrors = errs;
|
||||
document.getElementById('chargerInfo').innerHTML = `
|
||||
<h2>⚡ ${c.name}</h2>
|
||||
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
|
||||
@@ -435,9 +459,47 @@ navigator.geolocation?.getCurrentPosition(
|
||||
}
|
||||
);
|
||||
|
||||
// ── 에러코드 UI 갱신 ──
|
||||
function updateErrorCodeUI() {
|
||||
const wrap = document.getElementById('errorCodeWrap');
|
||||
const inner = document.getElementById('errorCodeInner');
|
||||
if (!selected.has('에러발생')) { wrap.style.display = 'none'; return; }
|
||||
wrap.style.display = 'block';
|
||||
if (chargerErrors.length > 0) {
|
||||
inner.innerHTML = `
|
||||
<select id="errorCode" style="width:100%">
|
||||
<option value="">-- 에러코드 선택 --</option>
|
||||
${chargerErrors.map(e =>
|
||||
`<option value="${e.error_code}">${e.error_code} — ${e.error_name}${e.range_condition ? ' ('+e.range_condition+')' : ''}</option>`
|
||||
).join('')}
|
||||
<option value="__other__">기타 (직접 입력)</option>
|
||||
</select>
|
||||
<input type="text" id="errorCodeCustom" placeholder="에러코드 직접 입력" style="margin-top:6px;display:none">`;
|
||||
document.getElementById('errorCode').onchange = function() {
|
||||
document.getElementById('errorCodeCustom').style.display =
|
||||
this.value === '__other__' ? 'block' : 'none';
|
||||
};
|
||||
} else {
|
||||
inner.innerHTML = `<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 에러코드 값 가져오기 ──
|
||||
function getErrorCodeValue() {
|
||||
const sel = document.getElementById('errorCode');
|
||||
if (!sel) return '';
|
||||
if (sel.tagName === 'SELECT') {
|
||||
if (sel.value === '__other__') return document.getElementById('errorCodeCustom')?.value || '';
|
||||
return sel.value;
|
||||
}
|
||||
return sel.value;
|
||||
}
|
||||
|
||||
// ── 문제 유형 버튼 ──
|
||||
function renderIssueButtons(issues) {
|
||||
const grid = document.getElementById('issueGrid');
|
||||
ISSUES.forEach(issue => {
|
||||
grid.innerHTML = '';
|
||||
issues.forEach(issue => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'issue-btn';
|
||||
btn.textContent = issue.label;
|
||||
@@ -445,13 +507,19 @@ ISSUES.forEach(issue => {
|
||||
btn.onclick = () => {
|
||||
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
|
||||
else { selected.add(issue.key); btn.classList.add('sel'); }
|
||||
document.getElementById('errorCodeWrap').style.display =
|
||||
selected.has('에러발생') ? 'block' : 'none';
|
||||
updateErrorCodeUI();
|
||||
document.getElementById('etcWrap').style.display =
|
||||
selected.has('기타') ? 'block' : 'none';
|
||||
};
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/api/settings/issue-types')
|
||||
.then(r => r.json())
|
||||
.then(data => { if (Array.isArray(data) && data.length) { ISSUES = data; } })
|
||||
.catch(() => {})
|
||||
.finally(() => renderIssueButtons(ISSUES));
|
||||
|
||||
// ── 이미지 압축 + 다중 선택 ──
|
||||
ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2');
|
||||
@@ -474,11 +542,14 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
document.getElementById('submitBtn').textContent = '접수 중...';
|
||||
|
||||
const scope = document.querySelector('input[name="scope"]:checked')?.value || 'single';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('charger_id', chargerId);
|
||||
fd.append('scope', scope);
|
||||
fd.append('issue_types', JSON.stringify(issues));
|
||||
fd.append('issue_detail', document.getElementById('detail').value);
|
||||
fd.append('error_code', document.getElementById('errorCode').value);
|
||||
fd.append('error_code', getErrorCodeValue());
|
||||
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
|
||||
fd.append('contact', contact);
|
||||
fd.append('consent', consent);
|
||||
@@ -488,12 +559,13 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
|
||||
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/reports', { method: 'POST', body: fd });
|
||||
const res = await fetch('/api/reports/batch', { method: 'POST', body: fd });
|
||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
|
||||
const data = await res.json();
|
||||
document.getElementById('mainForm').style.display = 'none';
|
||||
document.getElementById('resultBox').style.display = 'block';
|
||||
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`;
|
||||
const label = data.count > 1 ? `접수번호: #${data.primary_id} 외 ${data.count-1}건` : `접수번호: #${data.primary_id}`;
|
||||
document.getElementById('resultMsg').textContent = label;
|
||||
// 현황 새로고침
|
||||
document.getElementById('statusSection').style.display = 'none';
|
||||
document.getElementById('noReportNotice').style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user