Compare commits
16 Commits
05b478372a
...
b6863cd260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6863cd260 | ||
|
|
9f0f4326fe | ||
|
|
5ebd0a6ae7 | ||
|
|
012f87d75e | ||
|
|
e52e916dc8 | ||
|
|
d75bd5f358 | ||
|
|
2908c06b93 | ||
|
|
d429ed627d | ||
|
|
124ad0d165 | ||
|
|
81c3428aa1 | ||
|
|
af7e47529c | ||
|
|
b59569ca11 | ||
|
|
585cacfa13 | ||
|
|
7ab8a5065e | ||
|
|
16f7ee651b | ||
|
|
2e8751ea6c |
@@ -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:
|
||||
@@ -68,3 +87,4 @@ def require_role(*roles):
|
||||
require_admin = require_role("admin")
|
||||
require_mechanic = require_role("mechanic", "admin")
|
||||
require_manufacturer = require_role("manufacturer", "admin")
|
||||
require_viewer = require_role("admin", "observer") # 읽기 전용 역할 포함
|
||||
|
||||
549
backend/main.py
549
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,504 @@ 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()
|
||||
|
||||
|
||||
@app.get("/api/stats/top-stations")
|
||||
def stats_top_stations(limit: int = 10):
|
||||
"""충전소별 누적 고장 신고 건수 Top N."""
|
||||
from database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(text("""
|
||||
SELECT COALESCE(c.station_name, rep.charger_id) AS station_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 COALESCE(c.station_name, rep.charger_id)
|
||||
ORDER BY total DESC
|
||||
LIMIT :lim
|
||||
"""), {"lim": limit}).fetchall()
|
||||
return [
|
||||
{
|
||||
"station_name": row[0],
|
||||
"total": int(row[1]),
|
||||
"done": int(row[2]),
|
||||
"active": int(row[3]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/stats/charger-error-codes")
|
||||
def stats_charger_error_codes(code_limit: int = 10):
|
||||
"""에러코드별 누적 건수 Top N (에러코드 없음 포함)."""
|
||||
from database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(text("""
|
||||
SELECT COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음') AS error_code,
|
||||
COUNT(*) AS cnt
|
||||
FROM reports
|
||||
GROUP BY COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음')
|
||||
ORDER BY cnt DESC
|
||||
LIMIT :limit
|
||||
"""), {"limit": code_limit}).fetchall()
|
||||
result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)]
|
||||
return {"error_codes": result}
|
||||
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,23 @@ 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())
|
||||
closure_type = Column(String(30)) # natural|remote_reset|false_alarm|other
|
||||
closure_note = Column(Text)
|
||||
closed_at = Column(TIMESTAMP)
|
||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
re_dispatch_count = Column(Integer, default=0)
|
||||
report_scope = Column(String(20), default="single") # single | station | type | multi
|
||||
scope_charger_count = Column(Integer, default=1)
|
||||
charger_ids = Column(ARRAY(Text), nullable=True) # multi 범위일 때 선택된 충전기 ID 목록
|
||||
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])
|
||||
closer = relationship("User", foreign_keys=[closed_by])
|
||||
|
||||
class ReportPhoto(Base):
|
||||
__tablename__ = "report_photos"
|
||||
@@ -74,7 +90,14 @@ 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"))
|
||||
re_dispatch_requested = Column(Boolean, default=False)
|
||||
re_dispatch_requested_at = Column(TIMESTAMP)
|
||||
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)
|
||||
@@ -101,16 +124,20 @@ class RepairCost(Base):
|
||||
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), unique=True)
|
||||
root_cause = Column(Text)
|
||||
admin_note = Column(Text)
|
||||
cost_party_type = Column(String(20))
|
||||
cost_party_manufacturer_id = Column(Integer, ForeignKey("users.id"))
|
||||
cost_party_custom = Column(String(100))
|
||||
cost_party_type = Column(String(50))
|
||||
cost_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
|
||||
cost_party_custom = Column(String(200))
|
||||
recv_party_type = Column(String(50))
|
||||
recv_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
|
||||
recv_party_custom = Column(String(200))
|
||||
cost_amount = Column(Integer, default=0)
|
||||
cost_status = Column(String(20), default="pending")
|
||||
reviewed_by = Column(Integer, ForeignKey("users.id"))
|
||||
reviewed_at = Column(TIMESTAMP)
|
||||
repair = relationship("Repair", back_populates="cost")
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||
manufacturer = relationship("User", foreign_keys=[cost_party_manufacturer_id])
|
||||
cost_manufacturer = relationship("Manufacturer", foreign_keys=[cost_party_manufacturer_id])
|
||||
recv_manufacturer = relationship("Manufacturer", foreign_keys=[recv_party_manufacturer_id])
|
||||
|
||||
class Improvement(Base):
|
||||
__tablename__ = "improvements"
|
||||
@@ -121,13 +148,13 @@ class Improvement(Base):
|
||||
priority = Column(String(10), default="normal")
|
||||
part_name = Column(String(100))
|
||||
status = Column(String(20), default="registered")
|
||||
manufacturer_id = Column(Integer, ForeignKey("users.id"))
|
||||
manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
sw_deploy_target = Column(Date)
|
||||
sw_deployed_at = Column(Date)
|
||||
manufacturer_memo = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
manufacturer = relationship("User", foreign_keys=[manufacturer_id])
|
||||
manufacturer = relationship("Manufacturer", foreign_keys=[manufacturer_id])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
report_links = relationship("ImprovementReport", back_populates="improvement", cascade="all, delete-orphan")
|
||||
attachments = relationship("ImprovementAttachment", back_populates="improvement", cascade="all, delete-orphan")
|
||||
@@ -161,8 +188,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)
|
||||
|
||||
@@ -7,7 +7,8 @@ bcrypt==4.0.1
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.9
|
||||
qrcode[pil]==7.4.2
|
||||
Pillow==10.3.0
|
||||
Pillow==12.2.0
|
||||
pillow-heif==1.3.0
|
||||
openpyxl==3.1.2
|
||||
python-dotenv==1.0.1
|
||||
pydantic[email]==2.7.1
|
||||
|
||||
@@ -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,33 @@ 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(""),
|
||||
role: str = Form("mechanic"), # mechanic | observer
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if role not in ("mechanic", "observer"):
|
||||
role = "mechanic"
|
||||
if db.query(models.User).filter_by(username=username).first():
|
||||
raise HTTPException(400, "이미 사용 중인 아이디입니다.")
|
||||
user = models.User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
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
|
||||
@@ -40,14 +40,31 @@ def list_costs(
|
||||
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
|
||||
"root_cause": cost.root_cause, "admin_note": cost.admin_note,
|
||||
"cost_party_type": cost.cost_party_type,
|
||||
"cost_party_manufacturer_id": cost.cost_party_manufacturer_id,
|
||||
"cost_party_custom": cost.cost_party_custom,
|
||||
"cost_manufacturer_name": cost.cost_manufacturer.name if cost.cost_manufacturer else None,
|
||||
"recv_party_type": cost.recv_party_type,
|
||||
"recv_party_manufacturer_id": cost.recv_party_manufacturer_id,
|
||||
"recv_party_custom": cost.recv_party_custom,
|
||||
"recv_manufacturer_name": cost.recv_manufacturer.name if cost.recv_manufacturer else None,
|
||||
"cost_amount": cost.cost_amount, "cost_status": cost.cost_status,
|
||||
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
|
||||
"reviewed_by_name": cost.reviewer.name if cost.reviewer else None,
|
||||
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
|
||||
})
|
||||
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
|
||||
@@ -67,6 +84,9 @@ def upsert_cost(
|
||||
cost_party_type: str = Form(...),
|
||||
cost_party_manufacturer_id: Optional[int] = Form(None),
|
||||
cost_party_custom: str = Form(""),
|
||||
recv_party_type: str = Form(""),
|
||||
recv_party_manufacturer_id: Optional[int] = Form(None),
|
||||
recv_party_custom: str = Form(""),
|
||||
cost_amount: int = Form(0),
|
||||
cost_status: str = Form("pending"),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -81,6 +101,9 @@ def upsert_cost(
|
||||
cost.cost_party_type = cost_party_type
|
||||
cost.cost_party_manufacturer_id = cost_party_manufacturer_id or None
|
||||
cost.cost_party_custom = cost_party_custom or None
|
||||
cost.recv_party_type = recv_party_type or None
|
||||
cost.recv_party_manufacturer_id = recv_party_manufacturer_id or None
|
||||
cost.recv_party_custom = recv_party_custom or None
|
||||
cost.cost_amount = cost_amount; cost.cost_status = cost_status
|
||||
cost.reviewed_by = current_user.id; cost.reviewed_at = datetime.now()
|
||||
else:
|
||||
@@ -89,6 +112,9 @@ def upsert_cost(
|
||||
cost_party_type=cost_party_type,
|
||||
cost_party_manufacturer_id=cost_party_manufacturer_id or None,
|
||||
cost_party_custom=cost_party_custom or None,
|
||||
recv_party_type=recv_party_type or None,
|
||||
recv_party_manufacturer_id=recv_party_manufacturer_id or None,
|
||||
recv_party_custom=recv_party_custom or None,
|
||||
cost_amount=cost_amount, cost_status=cost_status,
|
||||
reviewed_by=current_user.id, reviewed_at=datetime.now()
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from io import BytesIO
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote
|
||||
from typing import Optional
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from database import get_db
|
||||
@@ -15,17 +16,27 @@ router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
|
||||
NAVY = "0B1E3D"
|
||||
LIGHT = "D6EAF8"
|
||||
GREEN = "1B5E20"
|
||||
ORANGE = "E65100"
|
||||
PURPLE = "4A148C"
|
||||
TEAL = "004D40"
|
||||
|
||||
def style_header(ws, headers, row=1):
|
||||
|
||||
def _hdr_cell(ws, row, col, value, color=NAVY):
|
||||
bd = Side(style="thin", color="AAAAAA")
|
||||
c = ws.cell(row=row, column=col, value=value)
|
||||
c.font = Font(bold=True, color="FFFFFF", size=10)
|
||||
c.fill = PatternFill("solid", fgColor=color)
|
||||
c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
c.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
||||
|
||||
|
||||
def style_header(ws, headers, row=1, color=NAVY):
|
||||
for col, h in enumerate(headers, 1):
|
||||
cell = ws.cell(row=row, column=col, value=h)
|
||||
cell.font = Font(bold=True, color="FFFFFF", size=11)
|
||||
cell.fill = PatternFill("solid", fgColor=NAVY)
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
||||
_hdr_cell(ws, row, col, h, color)
|
||||
ws.row_dimensions[row].height = 20
|
||||
|
||||
|
||||
def style_row(ws, row_num, num_cols, even=True):
|
||||
bd = Side(style="thin", color="DDDDDD")
|
||||
for col in range(1, num_cols + 1):
|
||||
@@ -35,40 +46,352 @@ def style_row(ws, row_num, num_cols, even=True):
|
||||
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
|
||||
|
||||
def fmt_dt(dt):
|
||||
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
|
||||
|
||||
|
||||
def fmt_d(d):
|
||||
return str(d) if d else ""
|
||||
|
||||
|
||||
def elapsed(start, end):
|
||||
if not start or not end: return ""
|
||||
diff = end - start
|
||||
total = int(diff.total_seconds())
|
||||
if total < 0: return ""
|
||||
h, m = divmod(total // 60, 60)
|
||||
return f"{h}시간 {m}분"
|
||||
|
||||
|
||||
def set_col_widths(ws, widths):
|
||||
for i, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
||||
|
||||
|
||||
def make_response(wb: openpyxl.Workbook, korean_name: str) -> StreamingResponse:
|
||||
"""엑셀 파일을 StreamingResponse로 반환 — 한글 파일명 URL 인코딩 처리"""
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
date_str = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
filename = f"{korean_name}_{date_str}.xlsx"
|
||||
encoded = quote(filename, safe="") # 한글 URL 인코딩
|
||||
cd_header = f"attachment; filename*=UTF-8''{encoded}"
|
||||
encoded = quote(filename, safe="")
|
||||
cd = f"attachment; filename*=UTF-8''{encoded}"
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": cd_header},
|
||||
headers={"Content-Disposition": cd},
|
||||
)
|
||||
|
||||
|
||||
def _parse_dates(date_from, date_to):
|
||||
try:
|
||||
dt_from = datetime.strptime(date_from, "%Y-%m-%d") if date_from else None
|
||||
dt_to = (datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)) if date_to else None
|
||||
except ValueError:
|
||||
raise HTTPException(400, "날짜 형식 오류 (YYYY-MM-DD)")
|
||||
return dt_from, dt_to
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 1. AS 신고 목록
|
||||
# 시트 빌더 — 공용
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CLOSURE_LABEL = {
|
||||
"natural": "증상자연소거",
|
||||
"remote_reset": "원격리셋후증상소거",
|
||||
"false_alarm": "인지오류",
|
||||
"other": "기타",
|
||||
}
|
||||
SOURCE_LABEL = {
|
||||
"qr": "QR스캔",
|
||||
"admin": "관리자접수",
|
||||
"dashboard": "대시보드접수",
|
||||
}
|
||||
COST_STATUS_LABEL = {
|
||||
"pending": "미처리", "billed": "청구완료", "waived": "면제", "settled": "정산완료",
|
||||
}
|
||||
PARTY_LABEL = {
|
||||
"cpo": "CPO(운영사)", "manufacturer": "업체", "self": "자체부담",
|
||||
"user": "사용자과실", "other": "기타",
|
||||
}
|
||||
IMP_STATUS_LABEL = {
|
||||
"registered": "등록", "reviewing": "검토중", "developing": "개발중",
|
||||
"deployed": "배포완료", "done": "완료",
|
||||
}
|
||||
RESULT_LABEL = {
|
||||
"done": "완료", "in_progress": "진행중", "waiting": "부품대기", "revisit": "재방문",
|
||||
}
|
||||
|
||||
|
||||
def _ws_reports(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("AS신고이력")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"신고번호", "충전기ID", "충전기종류", "충전기명", "충전소명", "CPO",
|
||||
"문제유형", "에러코드", "상세설명",
|
||||
"신고자연락처", "문제발생시각", "신고일시", "신고출처", "신고자",
|
||||
"처리상태", "담당정비사", "소속",
|
||||
"조치유형", "조치내용", "조치시작", "조치완료", "작업소요시간",
|
||||
"재조치횟수", "문제원인", "비고",
|
||||
"출장비부담", "출장비금액(원)", "출장비상태",
|
||||
"상황종료사유", "상황종료일시",
|
||||
]
|
||||
style_header(ws, headers, color=NAVY)
|
||||
set_col_widths(ws, [10,14,14,14,18,12,22,12,26,14,16,16,10,14,12,12,12,16,26,16,16,12,8,24,24,14,12,12,18,16])
|
||||
|
||||
q = db.query(models.Report)
|
||||
if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Report.reported_at < dt_to)
|
||||
rows = q.order_by(desc(models.Report.reported_at)).all()
|
||||
|
||||
for rn, r in enumerate(rows, 2):
|
||||
c = r.charger
|
||||
repair = r.repair_links[0].repair if r.repair_links else None
|
||||
cost = repair.cost if repair else None
|
||||
ws.append([
|
||||
r.id,
|
||||
r.charger_id,
|
||||
c.charger_type.name if c and c.charger_type else "",
|
||||
c.name if c else "",
|
||||
c.station_name if c else "",
|
||||
c.cpo_name if c else "",
|
||||
", ".join(r.issue_types or []),
|
||||
r.error_code or "",
|
||||
r.issue_detail or "",
|
||||
r.contact or "",
|
||||
fmt_dt(r.occurred_at),
|
||||
fmt_dt(r.reported_at),
|
||||
SOURCE_LABEL.get(r.source or "qr", r.source or ""),
|
||||
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 "",
|
||||
", ".join(repair.repair_types or []) if repair else "",
|
||||
repair.description if repair else "",
|
||||
fmt_dt(repair.started_at) if repair else "",
|
||||
fmt_dt(repair.completed_at) if repair else "",
|
||||
elapsed(repair.started_at, repair.completed_at) if repair else "",
|
||||
r.re_dispatch_count or 0,
|
||||
cost.root_cause if cost else "",
|
||||
cost.admin_note if cost else "",
|
||||
PARTY_LABEL.get(cost.cost_party_type, cost.cost_party_type or "") if cost else "",
|
||||
cost.cost_amount if cost else "",
|
||||
COST_STATUS_LABEL.get(cost.cost_status, cost.cost_status or "") if cost else "",
|
||||
CLOSURE_LABEL.get(r.closure_type or "", ""),
|
||||
fmt_dt(r.closed_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_repairs(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("조치이력")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"조치번호", "연결신고번호", "충전기ID", "충전소명", "충전기종류",
|
||||
"정비사", "소속",
|
||||
"조치유형", "조치내용",
|
||||
"시작시각", "완료시각", "소요시간",
|
||||
"처리결과", "재조치요청",
|
||||
"승인완료", "승인자", "승인일시",
|
||||
]
|
||||
style_header(ws, headers, color=GREEN)
|
||||
set_col_widths(ws, [10,16,14,18,14,12,14,18,30,16,16,12,10,10,10,12,16])
|
||||
|
||||
q = db.query(models.Repair)
|
||||
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
|
||||
rows = q.order_by(desc(models.Repair.completed_at)).all()
|
||||
|
||||
for rn, rep in enumerate(rows, 2):
|
||||
rids = [rr.report_id for rr in rep.report_links]
|
||||
charger_id = station = ctype = ""
|
||||
if rids:
|
||||
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
||||
if r and r.charger:
|
||||
charger_id = r.charger_id
|
||||
station = r.charger.station_name or ""
|
||||
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
ws.append([
|
||||
rep.id,
|
||||
", ".join(str(i) for i in rids),
|
||||
charger_id, station, ctype,
|
||||
rep.mechanic.name if rep.mechanic else "",
|
||||
rep.mechanic.company if rep.mechanic else "",
|
||||
", ".join(rep.repair_types or []),
|
||||
rep.description or "",
|
||||
fmt_dt(rep.started_at),
|
||||
fmt_dt(rep.completed_at),
|
||||
elapsed(rep.started_at, rep.completed_at),
|
||||
RESULT_LABEL.get(rep.result_status or "", rep.result_status or ""),
|
||||
"예" if rep.re_dispatch_requested else "아니오",
|
||||
"예" if rep.approved_at else "아니오",
|
||||
rep.approver.name if rep.approver else "",
|
||||
fmt_dt(rep.approved_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_improvements(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("개선항목")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"번호", "제목", "분류", "우선순위", "개선내용", "관련부품",
|
||||
"담당업체", "담당자(대표)", "연락처",
|
||||
"연결AS건수", "연결AS번호",
|
||||
"진행상태", "SW배포목표일", "SW실제배포일",
|
||||
"제조사메모", "등록자", "등록일시",
|
||||
]
|
||||
style_header(ws, headers, color=PURPLE)
|
||||
set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16])
|
||||
|
||||
q = db.query(models.Improvement)
|
||||
if dt_from: q = q.filter(models.Improvement.created_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Improvement.created_at < dt_to)
|
||||
rows = q.order_by(desc(models.Improvement.created_at)).all()
|
||||
|
||||
for rn, imp in enumerate(rows, 2):
|
||||
rids = [ir.report_id for ir in imp.report_links]
|
||||
ws.append([
|
||||
imp.id, imp.title, imp.category, imp.priority,
|
||||
imp.description, imp.part_name or "",
|
||||
imp.manufacturer.name if imp.manufacturer else "",
|
||||
imp.manufacturer.representative_name if imp.manufacturer else "",
|
||||
imp.manufacturer.phone if imp.manufacturer else "",
|
||||
len(rids),
|
||||
", ".join(str(i) for i in rids),
|
||||
IMP_STATUS_LABEL.get(imp.status, imp.status),
|
||||
fmt_d(imp.sw_deploy_target),
|
||||
fmt_d(imp.sw_deployed_at),
|
||||
imp.manufacturer_memo or "",
|
||||
imp.creator.name if imp.creator else "",
|
||||
fmt_dt(imp.created_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_costs(wb, db, dt_from, dt_to):
|
||||
ws = wb.create_sheet("출장비정산")
|
||||
ws.freeze_panes = "A2"
|
||||
headers = [
|
||||
"신고번호", "충전기ID", "충전소명", "충전기종류",
|
||||
"정비사", "소속", "조치완료일",
|
||||
"문제원인", "비고",
|
||||
"부담주체유형", "부담업체명", "부담기타",
|
||||
"수급주체유형", "수급업체명", "수급기타",
|
||||
"금액(원)", "처리상태",
|
||||
"처리담당자", "처리일시",
|
||||
]
|
||||
style_header(ws, headers, color=ORANGE)
|
||||
set_col_widths(ws, [14,14,18,14,12,14,16,26,26,12,16,16,12,16,16,12,12,12,16])
|
||||
|
||||
q = db.query(models.RepairCost).join(models.Repair)
|
||||
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
|
||||
rows = q.order_by(desc(models.Repair.completed_at)).all()
|
||||
|
||||
for rn, cost in enumerate(rows, 2):
|
||||
repair = cost.repair
|
||||
rids = [rr.report_id for rr in repair.report_links]
|
||||
charger_id = station = ctype = ""
|
||||
if rids:
|
||||
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
||||
if r and r.charger:
|
||||
charger_id = r.charger_id
|
||||
station = r.charger.station_name or ""
|
||||
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
ws.append([
|
||||
", ".join(str(i) for i in rids),
|
||||
charger_id, station, ctype,
|
||||
repair.mechanic.name if repair.mechanic else "",
|
||||
repair.mechanic.company if repair.mechanic else "",
|
||||
fmt_dt(repair.completed_at),
|
||||
cost.root_cause or "",
|
||||
cost.admin_note or "",
|
||||
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""),
|
||||
cost.cost_manufacturer.name if cost.cost_manufacturer else "",
|
||||
cost.cost_party_custom or "",
|
||||
PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""),
|
||||
cost.recv_manufacturer.name if cost.recv_manufacturer else "",
|
||||
cost.recv_party_custom or "",
|
||||
cost.cost_amount or 0,
|
||||
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""),
|
||||
cost.reviewer.name if cost.reviewer else "",
|
||||
fmt_dt(cost.reviewed_at),
|
||||
])
|
||||
style_row(ws, rn, len(headers), rn % 2 == 0)
|
||||
ws.row_dimensions[rn].height = 16
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _ws_summary(wb, counts, dt_from, dt_to, date_from, date_to):
|
||||
ws = wb.create_sheet("요약", 0) # 맨 앞에 삽입
|
||||
ws.sheet_view.showGridLines = False
|
||||
|
||||
period = f"{date_from or '전체'} ~ {date_to or '전체'}"
|
||||
|
||||
def lbl(row, col, text, bold=False, size=11, color="1A2B4A"):
|
||||
c = ws.cell(row=row, column=col, value=text)
|
||||
c.font = Font(bold=bold, size=size, color=color)
|
||||
c.alignment = Alignment(vertical="center")
|
||||
|
||||
def val(row, col, text, color="1A2B4A", bold=True):
|
||||
c = ws.cell(row=row, column=col, value=text)
|
||||
c.font = Font(bold=bold, size=13, color=color)
|
||||
c.alignment = Alignment(vertical="center", horizontal="center")
|
||||
|
||||
ws.row_dimensions[1].height = 16
|
||||
ws.row_dimensions[2].height = 36
|
||||
ws.row_dimensions[3].height = 14
|
||||
|
||||
c = ws.cell(row=2, column=2, value="EV AS 관리 통합 이력")
|
||||
c.font = Font(bold=True, size=18, color=NAVY)
|
||||
c.alignment = Alignment(vertical="center")
|
||||
ws.cell(row=2, column=5, value=f"조회 기간: {period}").font = Font(size=11, color="666666")
|
||||
|
||||
sheet_names = ["AS신고이력", "조치이력", "개선항목", "출장비정산"]
|
||||
colors = [NAVY, GREEN, PURPLE, ORANGE]
|
||||
labels = ["AS 신고", "조치 이력", "개선항목", "출장비 정산"]
|
||||
|
||||
for i, (name, color, label, cnt) in enumerate(zip(sheet_names, colors, labels, counts)):
|
||||
row = 5 + i * 3
|
||||
ws.row_dimensions[row].height = 24
|
||||
ws.row_dimensions[row+1].height = 22
|
||||
cell = ws.cell(row=row, column=2, value=f"● {label}")
|
||||
cell.font = Font(bold=True, size=12, color=color)
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
ws.cell(row=row, column=3, value=f"{cnt}건").font = Font(bold=True, size=14, color=color)
|
||||
ws.cell(row=row+1, column=2,
|
||||
value=f'자세한 내용은 "{name}" 시트를 확인하세요').font = Font(size=9, color="888888")
|
||||
|
||||
ws.column_dimensions["A"].width = 4
|
||||
ws.column_dimensions["B"].width = 22
|
||||
ws.column_dimensions["C"].width = 14
|
||||
ws.column_dimensions["D"].width = 10
|
||||
ws.column_dimensions["E"].width = 30
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 1. AS 신고 목록 (개별)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/reports")
|
||||
def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
def export_reports(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "AS신고목록"
|
||||
@@ -77,20 +400,24 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
headers = [
|
||||
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
|
||||
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
|
||||
"신고자연락처","문제발생시각","신고일시","처리상태",
|
||||
"신고자연락처","문제발생시각","신고일시","신고출처","신고자","처리상태",
|
||||
"담당정비사","정비사소속","조치유형","조치내용",
|
||||
"조치시작","조치완료","작업소요시간","신고→완료소요시간",
|
||||
"문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태",
|
||||
"처리담당자","처리일시","연결개선항목번호"
|
||||
"처리담당자","처리일시","연결개선항목번호",
|
||||
"상황종료사유","상황종료메모","상황종료일시","상황종료처리자",
|
||||
"재조치횟수"
|
||||
]
|
||||
style_header(ws, headers)
|
||||
set_col_widths(ws, [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,
|
||||
18,24,16,14,10])
|
||||
|
||||
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,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
|
||||
q = db.query(models.Report)
|
||||
if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Report.reported_at < dt_to)
|
||||
reports = q.order_by(desc(models.Report.reported_at)).all()
|
||||
|
||||
reports = db.query(models.Report).order_by(desc(models.Report.reported_at)).all()
|
||||
for row_num, r in enumerate(reports, 2):
|
||||
c = r.charger
|
||||
repair = r.repair_links[0].repair if r.repair_links else None
|
||||
@@ -99,7 +426,6 @@ 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()
|
||||
]
|
||||
|
||||
row_data = [
|
||||
r.id,
|
||||
r.charger_id,
|
||||
@@ -110,16 +436,18 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
fmt_d(c.installed_at) if c else "",
|
||||
r.gps_lat or "",
|
||||
r.gps_lng or "",
|
||||
", ".join(r.issue_types) if r.issue_types else "",
|
||||
", ".join(r.issue_types or []),
|
||||
r.error_code or "",
|
||||
r.issue_detail or "",
|
||||
r.contact or "",
|
||||
fmt_dt(r.occurred_at),
|
||||
fmt_dt(r.reported_at),
|
||||
SOURCE_LABEL.get(r.source or "qr", r.source or ""),
|
||||
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 "",
|
||||
", ".join(repair.repair_types) if repair and repair.repair_types else "",
|
||||
", ".join(repair.repair_types or []) if repair else "",
|
||||
repair.description if repair else "",
|
||||
fmt_dt(repair.started_at) if repair else "",
|
||||
fmt_dt(repair.completed_at) if repair else "",
|
||||
@@ -127,12 +455,17 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None),
|
||||
cost.root_cause if cost else "",
|
||||
cost.admin_note if cost else "",
|
||||
cost.cost_party_type if cost else "",
|
||||
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or "") if cost else "",
|
||||
cost.cost_amount if cost else "",
|
||||
cost.cost_status if cost else "",
|
||||
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or "") if cost else "",
|
||||
cost.reviewer.name if cost and cost.reviewer else "",
|
||||
fmt_dt(cost.reviewed_at) if cost else "",
|
||||
", ".join(str(i) for i in imp_ids) if imp_ids else "",
|
||||
CLOSURE_LABEL.get(r.closure_type or "", ""),
|
||||
r.closure_note or "",
|
||||
fmt_dt(r.closed_at),
|
||||
r.closer.name if r.closer else "",
|
||||
r.re_dispatch_count or 0,
|
||||
]
|
||||
for col, val in enumerate(row_data, 1):
|
||||
ws.cell(row=row_num, column=col, value=val)
|
||||
@@ -143,10 +476,16 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 2. 출장비 목록
|
||||
# 2. 출장비 목록 (개별)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/costs")
|
||||
def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
def export_costs(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "출장비목록"
|
||||
@@ -155,39 +494,45 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
headers = [
|
||||
"신고번호","충전기ID","충전기종류","충전소명","조치완료일",
|
||||
"정비사","소속","문제원인","비고",
|
||||
"출장비부담주체","제조사명","금액(원)","처리상태",
|
||||
"처리담당자","처리일시"
|
||||
"부담주체유형","부담업체명","부담기타",
|
||||
"수급주체유형","수급업체명","수급기타",
|
||||
"금액(원)","처리상태","처리담당자","처리일시"
|
||||
]
|
||||
style_header(ws, headers)
|
||||
for i, w in enumerate([10,14,14,18,16,12,14,24,24,16,16,12,12,12,16], 1):
|
||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
||||
set_col_widths(ws, [14,14,14,18,16,12,14,26,26,12,16,16,12,16,16,12,12,12,16])
|
||||
|
||||
costs = db.query(models.RepairCost).join(models.Repair).order_by(
|
||||
desc(models.RepairCost.reviewed_at)).all()
|
||||
q = db.query(models.RepairCost).join(models.Repair)
|
||||
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
|
||||
costs = q.order_by(desc(models.Repair.completed_at)).all()
|
||||
|
||||
for row_num, cost in enumerate(costs, 2):
|
||||
repair = cost.repair
|
||||
rids = [rr.report_id for rr in repair.report_links]
|
||||
charger_id = station_name = charger_type = ""
|
||||
charger_id = station = ctype = ""
|
||||
if rids:
|
||||
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
||||
if r and r.charger:
|
||||
charger_id = r.charger_id
|
||||
station_name = r.charger.station_name
|
||||
charger_type = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
station = r.charger.station_name or ""
|
||||
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||
|
||||
row_data = [
|
||||
", ".join(str(i) for i in rids),
|
||||
charger_id, charger_type, station_name,
|
||||
charger_id, ctype, station,
|
||||
fmt_dt(repair.completed_at),
|
||||
repair.mechanic.name if repair.mechanic else "",
|
||||
repair.mechanic.company if repair.mechanic else "",
|
||||
cost.root_cause or "",
|
||||
cost.admin_note or "",
|
||||
cost.cost_party_type or "",
|
||||
cost.manufacturer.company if cost.manufacturer else (cost.cost_party_custom or ""),
|
||||
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""),
|
||||
cost.cost_manufacturer.name if cost.cost_manufacturer else "",
|
||||
cost.cost_party_custom or "",
|
||||
PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""),
|
||||
cost.recv_manufacturer.name if cost.recv_manufacturer else "",
|
||||
cost.recv_party_custom or "",
|
||||
cost.cost_amount or 0,
|
||||
cost.cost_status or "",
|
||||
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""),
|
||||
cost.reviewer.name if cost.reviewer else "",
|
||||
fmt_dt(cost.reviewed_at),
|
||||
]
|
||||
@@ -200,10 +545,16 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 3. 개선항목 목록
|
||||
# 3. 개선항목 목록 (개별)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/improvements")
|
||||
def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
def export_improvements(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "개선항목목록"
|
||||
@@ -211,26 +562,29 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
|
||||
|
||||
headers = [
|
||||
"번호","제목","분류","우선순위","개선내용","관련부품",
|
||||
"담당제조사","담당자","연락처","연결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):
|
||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
||||
set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16])
|
||||
|
||||
q = db.query(models.Improvement)
|
||||
if dt_from: q = q.filter(models.Improvement.created_at >= dt_from)
|
||||
if dt_to: q = q.filter(models.Improvement.created_at < dt_to)
|
||||
imps = q.order_by(desc(models.Improvement.created_at)).all()
|
||||
|
||||
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]
|
||||
row_data = [
|
||||
imp.id, imp.title, imp.category, imp.priority,
|
||||
imp.description, imp.part_name or "",
|
||||
imp.manufacturer.company if imp.manufacturer else "",
|
||||
imp.manufacturer.name if imp.manufacturer else "",
|
||||
imp.manufacturer.representative_name if imp.manufacturer else "",
|
||||
imp.manufacturer.phone if imp.manufacturer else "",
|
||||
len(rids),
|
||||
", ".join(str(i) for i in rids),
|
||||
imp.status,
|
||||
IMP_STATUS_LABEL.get(imp.status, imp.status),
|
||||
fmt_d(imp.sw_deploy_target),
|
||||
fmt_d(imp.sw_deployed_at),
|
||||
imp.manufacturer_memo or "",
|
||||
@@ -243,3 +597,28 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
|
||||
ws.row_dimensions[row_num].height = 16
|
||||
|
||||
return make_response(wb, "개선항목목록")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 4. 통합 다운로드 (멀티 시트)
|
||||
# ─────────────────────────────────────────────
|
||||
@router.get("/full")
|
||||
def export_full(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_admin)
|
||||
):
|
||||
dt_from, dt_to = _parse_dates(date_from, date_to)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active) # 기본 빈 시트 제거
|
||||
|
||||
c1 = _ws_reports(wb, db, dt_from, dt_to)
|
||||
c2 = _ws_repairs(wb, db, dt_from, dt_to)
|
||||
c3 = _ws_improvements(wb, db, dt_from, dt_to)
|
||||
c4 = _ws_costs(wb, db, dt_from, dt_to)
|
||||
_ws_summary(wb, [c1, c2, c3, c4], dt_from, dt_to, date_from, date_to)
|
||||
|
||||
period = f"{date_from or '전체'}~{date_to or '전체'}"
|
||||
return make_response(wb, f"EV_AS_통합이력_{period}")
|
||||
|
||||
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, func
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from database import get_db
|
||||
@@ -18,7 +18,7 @@ def _fmt(imp: models.Improvement):
|
||||
"part_name": imp.part_name, "status": imp.status,
|
||||
"manufacturer_id": imp.manufacturer_id,
|
||||
"manufacturer_name": imp.manufacturer.name if imp.manufacturer else None,
|
||||
"manufacturer_company": imp.manufacturer.company if imp.manufacturer else None,
|
||||
"manufacturer_company": None,
|
||||
"created_by_name": imp.creator.name if imp.creator else None,
|
||||
"sw_deploy_target": str(imp.sw_deploy_target) if imp.sw_deploy_target else None,
|
||||
"sw_deployed_at": str(imp.sw_deployed_at) if imp.sw_deployed_at else None,
|
||||
@@ -27,7 +27,7 @@ def _fmt(imp: models.Improvement):
|
||||
"report_ids": [ir.report_id for ir in imp.report_links],
|
||||
"report_count": len(imp.report_links),
|
||||
"attachments": [{"path": a.file_path, "name": a.file_name} for a in imp.attachments],
|
||||
"logs": [{"old": l.old_status, "new": l.new_status, "memo": l.memo,
|
||||
"logs": [{"old_status": l.old_status, "new_status": l.new_status, "memo": l.memo,
|
||||
"changed_at": l.changed_at.isoformat(),
|
||||
"by": l.changer.name if l.changer else None} for l in imp.logs],
|
||||
}
|
||||
@@ -52,7 +52,22 @@ def get_improvement(imp_id: int, db: Session = Depends(get_db),
|
||||
if not imp: raise HTTPException(404)
|
||||
if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id:
|
||||
raise HTTPException(403)
|
||||
return _fmt(imp)
|
||||
result = _fmt(imp)
|
||||
rids = [ir.report_id for ir in imp.report_links]
|
||||
if rids:
|
||||
seq_subq = db.query(
|
||||
models.Report.id.label("rid"),
|
||||
func.row_number().over(
|
||||
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
|
||||
).label("seq")
|
||||
).subquery()
|
||||
seqs = {row.rid: row.seq for row in
|
||||
db.query(seq_subq.c.rid, seq_subq.c.seq)
|
||||
.filter(seq_subq.c.rid.in_(rids)).all()}
|
||||
result["report_links"] = [{"id": rid, "seq": seqs.get(rid, rid)} for rid in rids]
|
||||
else:
|
||||
result["report_links"] = []
|
||||
return result
|
||||
|
||||
@router.post("")
|
||||
async def create_improvement(
|
||||
@@ -86,6 +101,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,70 @@ 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,
|
||||
})
|
||||
|
||||
# 같은 신고에 연결된 조치 목록에서 현재 조치의 순번 계산 (오래된 것=1차)
|
||||
attempt = 1
|
||||
if repair.report_links:
|
||||
first_report = repair.report_links[0].report
|
||||
if first_report and first_report.repair_links:
|
||||
all_repair_ids = sorted(rl.repair_id for rl in first_report.repair_links)
|
||||
if repair.id in all_repair_ids:
|
||||
attempt = all_repair_ids.index(repair.id) + 1
|
||||
|
||||
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,
|
||||
"re_dispatch_requested": repair.re_dispatch_requested or False,
|
||||
"re_dispatch_requested_at": repair.re_dispatch_requested_at.isoformat() if repair.re_dispatch_requested_at 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),
|
||||
"attempt": attempt,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
def pending_reports(db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)):
|
||||
@@ -21,6 +80,10 @@ def pending_reports(db: Session = Depends(get_db),
|
||||
result = []
|
||||
for r in q.all():
|
||||
c = r.charger
|
||||
# in_progress 신고만 기존 repair 편집 모드; pending은 재조치 포함 새 조치 생성
|
||||
repair_id = None
|
||||
if r.status == "in_progress" and 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 +92,24 @@ 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,
|
||||
"re_dispatch_count": r.re_dispatch_count or 0,
|
||||
"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)):
|
||||
@@ -45,74 +123,223 @@ def open_reports_for_charger(charger_id: str, db: Session = Depends(get_db),
|
||||
"issue_detail": r.issue_detail, "status": r.status,
|
||||
"reported_at": r.reported_at.isoformat(),
|
||||
"photos": [p.file_path for p in r.photos],
|
||||
"re_dispatch_count": r.re_dispatch_count or 0,
|
||||
} 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)
|
||||
|
||||
|
||||
def _parse_dt(s: Optional[str]) -> Optional[datetime]:
|
||||
if not s: return None
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@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"),
|
||||
started_at_input: Optional[str] = Form(None),
|
||||
completed_at_input: Optional[str] = Form(None),
|
||||
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)
|
||||
):
|
||||
now = datetime.now()
|
||||
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(),
|
||||
started_at=_parse_dt(started_at_input) or now,
|
||||
completed_at=_parse_dt(completed_at_input) or 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"),
|
||||
started_at_input: Optional[str] = Form(None),
|
||||
completed_at_input: Optional[str] = Form(None),
|
||||
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
|
||||
if started_at_input:
|
||||
repair.started_at = _parse_dt(started_at_input) or repair.started_at
|
||||
repair.completed_at = _parse_dt(completed_at_input) or 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.post("/{repair_id}/re-dispatch")
|
||||
def re_dispatch_repair(
|
||||
repair_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(require_admin)
|
||||
):
|
||||
"""기존 조치 기록을 유지하며 재조치 요청 — 신고를 pending으로 되돌림"""
|
||||
repair = db.query(models.Repair).filter_by(id=repair_id).first()
|
||||
if not repair: raise HTTPException(404)
|
||||
if repair.approved_at:
|
||||
raise HTTPException(400, "이미 승인된 조치는 재조치 요청할 수 없습니다.")
|
||||
repair.re_dispatch_requested = True
|
||||
repair.re_dispatch_requested_at = datetime.now()
|
||||
for link in repair.report_links:
|
||||
if link.report:
|
||||
link.report.status = "pending"
|
||||
link.report.re_dispatch_count = (link.report.re_dispatch_count or 0) + 1
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@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, func
|
||||
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,25 @@ 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,
|
||||
"closure_type": r.closure_type,
|
||||
"closure_note": r.closure_note,
|
||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||
"closed_by_name": r.closer.name if r.closer else None,
|
||||
"re_dispatch_count": r.re_dispatch_count or 0,
|
||||
"report_scope": r.report_scope or "single",
|
||||
"scope_charger_count": r.scope_charger_count or 1,
|
||||
"charger_ids": r.charger_ids or [],
|
||||
}
|
||||
|
||||
@router.post("")
|
||||
@@ -43,8 +66,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 +81,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,20 +106,138 @@ 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 | multi
|
||||
charger_ids: Optional[str] = Form(None), # JSON: ["id1","id2",...] for multi scope
|
||||
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, "충전기를 찾을 수 없습니다.")
|
||||
|
||||
selected_ids = None
|
||||
if scope == "multi" and charger_ids:
|
||||
selected_ids = json.loads(charger_ids)
|
||||
all_targets = [charger]
|
||||
report_scope = "multi"
|
||||
scope_charger_count = len(selected_ids)
|
||||
elif scope == "station":
|
||||
all_targets = db.query(models.Charger).filter_by(
|
||||
station_name=charger.station_name, is_active=True).all()
|
||||
report_scope = "station"
|
||||
scope_charger_count = len(all_targets)
|
||||
elif scope == "type" and charger.charger_type_id:
|
||||
all_targets = db.query(models.Charger).filter_by(
|
||||
charger_type_id=charger.charger_type_id, is_active=True).all()
|
||||
report_scope = "type"
|
||||
scope_charger_count = len(all_targets)
|
||||
else:
|
||||
all_targets = [charger]
|
||||
report_scope = "single"
|
||||
scope_charger_count = 1
|
||||
|
||||
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"
|
||||
|
||||
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,
|
||||
report_scope=report_scope,
|
||||
scope_charger_count=scope_charger_count,
|
||||
charger_ids=selected_ids,
|
||||
)
|
||||
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,
|
||||
station_name: 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)
|
||||
# 전체 신고 기준 순번 (삭제 gap 없이, 오래된 것=1)
|
||||
seq_subq = db.query(
|
||||
models.Report.id.label("rid"),
|
||||
func.row_number().over(
|
||||
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
|
||||
).label("seq")
|
||||
).subquery()
|
||||
|
||||
q = (db.query(models.Report, seq_subq.c.seq)
|
||||
.join(seq_subq, models.Report.id == seq_subq.c.rid)
|
||||
.order_by(desc(models.Report.reported_at)))
|
||||
if status == "pending_all":
|
||||
q = q.filter(models.Report.status.in_(["pending", "pending_approval"]))
|
||||
elif 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 station_name:
|
||||
q = (q.join(models.Charger, models.Report.charger_id == models.Charger.id, isouter=True)
|
||||
.filter(models.Charger.station_name == station_name))
|
||||
if current_user.role == "mechanic":
|
||||
q = q.filter(models.Report.status != "pending_approval")
|
||||
return [_fmt_report(r, db) for r in q.all()]
|
||||
|
||||
result = []
|
||||
for r, seq in q.all():
|
||||
fmt = _fmt_report(r, db)
|
||||
fmt["seq"] = seq
|
||||
result.append(fmt)
|
||||
return result
|
||||
|
||||
@router.get("/{report_id}")
|
||||
def get_report(report_id: int, db: Session = Depends(get_db),
|
||||
@@ -93,11 +245,20 @@ def get_report(report_id: int, db: Session = Depends(get_db),
|
||||
r = db.query(models.Report).filter_by(id=report_id).first()
|
||||
if not r: raise HTTPException(404)
|
||||
result = _fmt_report(r, db)
|
||||
# 수리 정보 포함
|
||||
if r.repair_links:
|
||||
repair = r.repair_links[0].repair
|
||||
result["re_dispatch_count"] = r.re_dispatch_count or 0
|
||||
# 전체 기준 순번 계산
|
||||
seq_subq = db.query(
|
||||
models.Report.id.label("rid"),
|
||||
func.row_number().over(
|
||||
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
|
||||
).label("seq")
|
||||
).subquery()
|
||||
row = db.query(seq_subq.c.seq).filter(seq_subq.c.rid == report_id).scalar()
|
||||
result["seq"] = row
|
||||
# 수리 정보 포함 — repair_links를 id 내림차순(최신 우선)으로 정렬
|
||||
def _fmt_one_repair(repair, include_cost=True):
|
||||
cost = repair.cost
|
||||
result["repair"] = {
|
||||
return {
|
||||
"id": repair.id,
|
||||
"mechanic_name": repair.mechanic.name if repair.mechanic else None,
|
||||
"mechanic_company": repair.mechanic.company if repair.mechanic else None,
|
||||
@@ -106,20 +267,146 @@ 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,
|
||||
"re_dispatch_requested": repair.re_dispatch_requested or False,
|
||||
"re_dispatch_requested_at": repair.re_dispatch_requested_at.isoformat() if repair.re_dispatch_requested_at 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": {
|
||||
"root_cause": cost.root_cause,
|
||||
"admin_note": cost.admin_note,
|
||||
"cost_party_type": cost.cost_party_type,
|
||||
"cost_party_manufacturer_id": cost.cost_party_manufacturer_id,
|
||||
"cost_party_custom": cost.cost_party_custom,
|
||||
"cost_manufacturer_name": cost.cost_manufacturer.name if cost.cost_manufacturer else None,
|
||||
"recv_party_type": cost.recv_party_type,
|
||||
"recv_party_manufacturer_id": cost.recv_party_manufacturer_id,
|
||||
"recv_party_custom": cost.recv_party_custom,
|
||||
"recv_manufacturer_name": cost.recv_manufacturer.name if cost.recv_manufacturer else None,
|
||||
"cost_amount": cost.cost_amount,
|
||||
"cost_status": cost.cost_status,
|
||||
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
|
||||
} if cost else None
|
||||
"reviewed_by_name": cost.reviewer.name if cost.reviewer else None,
|
||||
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
|
||||
} if cost else None,
|
||||
"linked_improvements": _get_linked_improvements(repair, db) if include_cost else [],
|
||||
}
|
||||
|
||||
if r.repair_links:
|
||||
sorted_links = sorted(r.repair_links, key=lambda l: l.repair_id, reverse=True)
|
||||
result["repair"] = _fmt_one_repair(sorted_links[0].repair)
|
||||
result["prev_repairs"] = [
|
||||
_fmt_one_repair(link.repair, include_cost=True)
|
||||
for link in sorted_links[1:]
|
||||
if link.repair
|
||||
]
|
||||
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,
|
||||
charger_id: Optional[str] = Form(None),
|
||||
scope: Optional[str] = Form(None),
|
||||
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 charger_id is not None and charger_id.strip():
|
||||
ch = db.query(models.Charger).filter_by(id=charger_id.strip()).first()
|
||||
if not ch: raise HTTPException(400, "충전기를 찾을 수 없습니다")
|
||||
r.charger_id = charger_id.strip()
|
||||
if scope is not None and scope in ("single", "station", "type"):
|
||||
ref = db.query(models.Charger).filter_by(id=r.charger_id).first()
|
||||
if scope == "station" and ref:
|
||||
count = db.query(models.Charger).filter_by(
|
||||
station_name=ref.station_name, is_active=True).count()
|
||||
elif scope == "type" and ref and ref.charger_type_id:
|
||||
count = db.query(models.Charger).filter_by(
|
||||
charger_type_id=ref.charger_type_id, is_active=True).count()
|
||||
else:
|
||||
count = 1
|
||||
r.report_scope = scope
|
||||
r.scope_charger_count = count
|
||||
if scope != "multi":
|
||||
r.charger_ids = None
|
||||
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()
|
||||
@@ -127,6 +414,29 @@ def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(requ
|
||||
r.status = "pending"; db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
CLOSURE_TYPES = {"natural", "remote_reset", "false_alarm", "other"}
|
||||
|
||||
@router.patch("/{report_id}/close")
|
||||
def close_report(
|
||||
report_id: int,
|
||||
closure_type: str = Form(...),
|
||||
closure_note: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(require_admin)
|
||||
):
|
||||
if closure_type not in CLOSURE_TYPES:
|
||||
raise HTTPException(400, "유효하지 않은 상황종료 사유입니다.")
|
||||
r = db.query(models.Report).filter_by(id=report_id).first()
|
||||
if not r: raise HTTPException(404)
|
||||
from datetime import datetime
|
||||
r.status = "closed"
|
||||
r.closure_type = closure_type
|
||||
r.closure_note = closure_note.strip() or None
|
||||
r.closed_at = datetime.now()
|
||||
r.closed_by = current_user.id
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.patch("/{report_id}/status")
|
||||
def update_status(report_id: int, status: str = Form(...),
|
||||
db: Session = Depends(get_db), _=Depends(require_admin)):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,18 +1,47 @@
|
||||
import os, uuid, qrcode
|
||||
import os, uuid, io, qrcode
|
||||
from PIL import Image
|
||||
from fastapi import UploadFile
|
||||
|
||||
# HEIC/HEIF 지원 등록 (pillow-heif)
|
||||
try:
|
||||
from pillow_heif import register_heif_opener
|
||||
register_heif_opener()
|
||||
_HEIF_SUPPORTED = True
|
||||
except ImportError:
|
||||
_HEIF_SUPPORTED = False
|
||||
|
||||
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/uploads")
|
||||
|
||||
# 브라우저에서 표시 불가능한 포맷 → 서버에서 JPEG 변환
|
||||
_CONVERT_EXTS = {".heic", ".heif", ".avif"}
|
||||
|
||||
def save_upload(file: UploadFile, sub_dir: str = "general") -> str:
|
||||
"""파일을 저장하고 /uploads 기준 상대 경로 반환"""
|
||||
"""파일을 저장하고 /uploads 기준 상대 경로 반환.
|
||||
HEIC/HEIF 등 브라우저 비호환 포맷은 JPEG로 변환 후 저장."""
|
||||
raw = file.file.read()
|
||||
ext = os.path.splitext(file.filename or "file")[1].lower() or ".jpg"
|
||||
folder = os.path.join(UPLOAD_DIR, sub_dir)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
if ext in _CONVERT_EXTS:
|
||||
# HEIC/HEIF → JPEG 변환
|
||||
try:
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
if img.mode in ("RGBA", "P", "LA"):
|
||||
img = img.convert("RGB")
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=88, optimize=True)
|
||||
raw = buf.getvalue()
|
||||
ext = ".jpg"
|
||||
except Exception:
|
||||
pass # 변환 실패 시 원본 그대로 저장
|
||||
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
filepath = os.path.join(folder, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(file.file.read())
|
||||
f.write(raw)
|
||||
return f"/uploads/{sub_dir}/{filename}"
|
||||
|
||||
def generate_qr(charger_id: str, domain: str) -> str:
|
||||
|
||||
@@ -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;}
|
||||
@@ -105,6 +114,7 @@ textarea{resize:vertical;min-height:80px;}
|
||||
.s-cost-billed{background:#DBEAFE;color:#1565C0;}
|
||||
.s-cost-waived{background:#F0F0F0;color:#555;}
|
||||
.s-cost-settled{background:#D1FAE5;color:#065F46;}
|
||||
.s-closed{background:#F1F5F9;color:#475569;}
|
||||
|
||||
/* ── ALERTS ── */
|
||||
.alert{padding:12px 16px;border-radius:6px;margin-bottom:14px;font-size:13px;}
|
||||
@@ -139,10 +149,44 @@ 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;}
|
||||
|
||||
/* 가로 오버플로우(표 등)가 body를 넓혀 fixed nav 버튼이 밀려나는 문제 방지 */
|
||||
html,body{overflow-x:hidden;}
|
||||
.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;}
|
||||
.nav-user{display:none;}
|
||||
}
|
||||
|
||||
/* ── 정비사 모바일 탭 바 ── */
|
||||
.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;position:fixed;left:0;width:100%;box-sizing:border-box;}
|
||||
.mech-tab-bar~.layout{margin-top:54px;}
|
||||
.mech-tab-bar~.layout>.sidebar{top:108px;}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ const API = (() => {
|
||||
}
|
||||
}
|
||||
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
|
||||
if (res.status === 401) { Auth.logout(); return; }
|
||||
if (res.status === 401) {
|
||||
if (location.pathname !== '/pages/login.html') {
|
||||
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
|
||||
}
|
||||
Auth.logout(); return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
|
||||
throw new Error(err.detail || '오류');
|
||||
@@ -36,7 +41,12 @@ const API = (() => {
|
||||
|
||||
const res = await fetch(BASE + path, { method: 'GET', headers });
|
||||
|
||||
if (res.status === 401) { Auth.logout(); return; }
|
||||
if (res.status === 401) {
|
||||
if (location.pathname !== '/pages/login.html') {
|
||||
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
|
||||
}
|
||||
Auth.logout(); return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '다운로드 오류' }));
|
||||
throw new Error(err.detail || '다운로드 오류');
|
||||
@@ -66,7 +76,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 !== undefined ? body : null),
|
||||
download,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -21,10 +21,16 @@ const Auth = (() => {
|
||||
}
|
||||
|
||||
function require(allowedRoles) {
|
||||
if (!token()) { logout(); return false; }
|
||||
if (!token()) {
|
||||
// 로그인 후 원래 페이지로 돌아올 수 있도록 현재 URL 저장
|
||||
if (location.pathname !== '/pages/login.html') {
|
||||
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
|
||||
}
|
||||
logout(); return false;
|
||||
}
|
||||
if (allowedRoles && !allowedRoles.includes(role())) {
|
||||
alert('접근 권한이 없습니다.');
|
||||
history.back();
|
||||
alert('접근 권한이 없습니다. (현재 역할: ' + (role() || '없음') + ')');
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -33,18 +39,61 @@ 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);
|
||||
});
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
if (sidebar && !sidebar.querySelector('.sidebar-user-footer')) {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'sidebar-user-footer';
|
||||
footer.innerHTML = `
|
||||
<div style="border-top:1px solid rgba(255,255,255,.12);margin:12px 0 4px;"></div>
|
||||
<div style="padding:6px 20px;font-size:11px;color:rgba(255,255,255,.4);">${name()} <span style="color:var(--accent)">[${role()}]</span></div>
|
||||
<a onclick="Auth.logout()" style="cursor:pointer;color:rgba(255,255,255,.6);">🚪 로그아웃</a>`;
|
||||
sidebar.appendChild(footer);
|
||||
}
|
||||
}, 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);
|
||||
if (overlay) overlay.classList.toggle('show', opening);
|
||||
}
|
||||
|
||||
function closeMobileNav() {
|
||||
var sb = document.querySelector('.sidebar');
|
||||
if (sb) sb.classList.remove('mobile-open');
|
||||
var ov = document.getElementById('mobileNavOverlay');
|
||||
if (ov) ov.classList.remove('show');
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
pending_approval: '승인대기', pending: '접수', in_progress: '처리중',
|
||||
done: '완료', waiting: '부품대기', revisit: '재방문',
|
||||
done: '완료', waiting: '부품대기', revisit: '재방문', closed: '상황종료',
|
||||
registered: '등록', reviewing: '검토중', developing: '개발중',
|
||||
deployed: '배포완료',
|
||||
deployed: '배포완료', observer: '옵저버',
|
||||
};
|
||||
return `<span class="badge s-${status}">${map[status] || status}</span>`;
|
||||
}
|
||||
@@ -59,5 +108,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 };
|
||||
})();
|
||||
|
||||
@@ -6,16 +6,21 @@
|
||||
const ImageCompressor = (() => {
|
||||
|
||||
// 서버에서 가져온 설정 캐시
|
||||
let _cfg = null;
|
||||
var _cfg = null;
|
||||
|
||||
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */
|
||||
var DEFAULT_CFG = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
|
||||
|
||||
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출, 5초 타임아웃) */
|
||||
async function loadConfig() {
|
||||
if (_cfg) return _cfg;
|
||||
try {
|
||||
const res = await fetch('/api/settings/public');
|
||||
var controller = new AbortController();
|
||||
var tid = setTimeout(function() { controller.abort(); }, 5000);
|
||||
var res = await fetch('/api/settings/public', { signal: controller.signal });
|
||||
clearTimeout(tid);
|
||||
_cfg = await res.json();
|
||||
} catch {
|
||||
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
|
||||
} catch (e) {
|
||||
_cfg = DEFAULT_CFG;
|
||||
}
|
||||
return _cfg;
|
||||
}
|
||||
@@ -27,18 +32,30 @@ const ImageCompressor = (() => {
|
||||
* @returns {Promise<File>}
|
||||
*/
|
||||
function compressOne(file, cfg) {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(function(resolve) {
|
||||
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
|
||||
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
|
||||
return resolve(file);
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxPx = cfg.image_max_px;
|
||||
let { width, height } = img;
|
||||
// createObjectURL 사용: readAsDataURL 대비 메모리 1/4 이하
|
||||
// (base64 변환 없이 브라우저가 파일을 직접 디코딩)
|
||||
var objUrl = URL.createObjectURL(file);
|
||||
var img = new Image();
|
||||
|
||||
img.onerror = function() {
|
||||
URL.revokeObjectURL(objUrl);
|
||||
resolve(file); // 디코딩 실패(HEIF 등) → 원본 사용
|
||||
};
|
||||
|
||||
img.onload = function() {
|
||||
URL.revokeObjectURL(objUrl); // 디코딩 완료 즉시 해제
|
||||
|
||||
if (!img.naturalWidth || !img.naturalHeight) { resolve(file); return; }
|
||||
|
||||
var maxPx = cfg.image_max_px;
|
||||
var width = img.naturalWidth;
|
||||
var height = img.naturalHeight;
|
||||
|
||||
// 긴 변이 maxPx 초과하면 비율 유지하며 축소
|
||||
if (width > maxPx || height > maxPx) {
|
||||
@@ -51,38 +68,65 @@ const ImageCompressor = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve(file); return; }
|
||||
img.onerror = null; // 이후 src 변경에 의한 onerror 재실행 방지
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const compressed = new File(
|
||||
function(blob) {
|
||||
canvas.width = 0; canvas.height = 0; // canvas 메모리 해제
|
||||
|
||||
if (!blob || blob.size < 500) { resolve(file); return; }
|
||||
|
||||
// blob이 실제로 렌더 가능한 이미지인지 검증
|
||||
// (OOM으로 drawImage가 빈 캔버스를 만들었을 경우를 잡기 위해)
|
||||
var blobUrl = URL.createObjectURL(blob);
|
||||
var check = new Image();
|
||||
check.onerror = function() {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
resolve(file); // 유효하지 않은 JPEG → 원본 사용
|
||||
};
|
||||
check.onload = function() {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
if (!check.naturalWidth || !check.naturalHeight) {
|
||||
resolve(file); // 빈 이미지 → 원본 사용
|
||||
return;
|
||||
}
|
||||
resolve(new File(
|
||||
[blob],
|
||||
file.name.replace(/\.[^.]+$/, '') + '.jpg',
|
||||
{ type: 'image/jpeg', lastModified: Date.now() }
|
||||
);
|
||||
resolve(compressed);
|
||||
));
|
||||
};
|
||||
check.src = blobUrl;
|
||||
},
|
||||
'image/jpeg',
|
||||
cfg.image_quality / 100 // 0~1 범위
|
||||
);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
img.src = objUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FileList / File[] 전체를 압축
|
||||
* FileList / File[] 전체를 순차 압축 (병렬 처리 시 모바일 메모리 부족 방지)
|
||||
* @param {FileList|File[]} files
|
||||
* @returns {Promise<File[]>}
|
||||
*/
|
||||
async function compressAll(files) {
|
||||
const cfg = await loadConfig();
|
||||
return Promise.all(Array.from(files).map(f => compressOne(f, cfg)));
|
||||
var cfg = await loadConfig();
|
||||
var arr = Array.from(files);
|
||||
var result = [];
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
result.push(await compressOne(arr[i], cfg));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,52 +136,59 @@ const ImageCompressor = (() => {
|
||||
* @param {string} infoId - 용량 정보 표시 span id (선택)
|
||||
*/
|
||||
function setupPreview(inputId, previewId, infoId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
const info = infoId ? document.getElementById(infoId) : null;
|
||||
var input = document.getElementById(inputId);
|
||||
var preview = document.getElementById(previewId);
|
||||
var info = infoId ? document.getElementById(infoId) : null;
|
||||
|
||||
if (!input || !preview) return;
|
||||
|
||||
input.addEventListener('change', async function () {
|
||||
input.addEventListener('change', async function() {
|
||||
preview.innerHTML = '';
|
||||
if (info) info.textContent = '압축 중...';
|
||||
|
||||
const cfg = await loadConfig();
|
||||
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0);
|
||||
var cfg = await loadConfig();
|
||||
var origBytes = Array.from(this.files).reduce(function(s, f) { return s + f.size; }, 0);
|
||||
|
||||
const compressed = await compressAll(this.files);
|
||||
const compBytes = compressed.reduce((s, f) => s + f.size, 0);
|
||||
var compressed = await compressAll(this.files);
|
||||
var compBytes = compressed.reduce(function(s, f) { return s + f.size; }, 0);
|
||||
|
||||
// DataTransfer 로 input.files 교체 (압축된 파일로)
|
||||
const dt = new DataTransfer();
|
||||
compressed.forEach(f => dt.items.add(f));
|
||||
var dt = new DataTransfer();
|
||||
compressed.forEach(function(f) { dt.items.add(f); });
|
||||
this.files = dt.files;
|
||||
|
||||
// 미리보기 렌더링
|
||||
compressed.forEach((f, i) => {
|
||||
const url = URL.createObjectURL(f);
|
||||
const wrap = document.createElement('div');
|
||||
compressed.forEach(function(f, i) {
|
||||
var url = URL.createObjectURL(f);
|
||||
var wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;display:inline-block;';
|
||||
|
||||
const img = document.createElement('img');
|
||||
var img = document.createElement('img');
|
||||
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;background:#f0f0f0;';
|
||||
img.onload = function() { URL.revokeObjectURL(url); };
|
||||
img.onerror = function() {
|
||||
// 이미지 렌더 실패 시 플레이스홀더 표시
|
||||
URL.revokeObjectURL(url);
|
||||
img.style.cssText = 'width:80px;height:80px;border-radius:6px;border:1px solid #C5CFE0;background:#f5f5f5;display:flex;align-items:center;justify-content:center;font-size:10px;color:#999;';
|
||||
img.removeAttribute('src');
|
||||
img.alt = '미리보기\n불가';
|
||||
};
|
||||
img.src = url;
|
||||
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;';
|
||||
img.onload = () => URL.revokeObjectURL(url);
|
||||
|
||||
// 삭제 버튼
|
||||
const del = document.createElement('button');
|
||||
// 삭제 버튼 — 클로저로 이 파일(f)을 직접 참조
|
||||
var del = document.createElement('button');
|
||||
del.textContent = '×';
|
||||
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;';
|
||||
del.onclick = () => {
|
||||
// 해당 파일 제거
|
||||
const cur = Array.from(input.files);
|
||||
cur.splice(i, 1);
|
||||
const dt2 = new DataTransfer();
|
||||
cur.forEach(f2 => dt2.items.add(f2));
|
||||
(function(targetFile) {
|
||||
del.onclick = function() {
|
||||
var remaining = Array.from(input.files).filter(function(f2) { return f2.name !== targetFile.name || f2.size !== targetFile.size; });
|
||||
var dt2 = new DataTransfer();
|
||||
remaining.forEach(function(f2) { dt2.items.add(f2); });
|
||||
input.files = dt2.files;
|
||||
wrap.remove();
|
||||
updateInfo(input, info);
|
||||
};
|
||||
})(f);
|
||||
|
||||
wrap.appendChild(img);
|
||||
wrap.appendChild(del);
|
||||
@@ -146,12 +197,12 @@ const ImageCompressor = (() => {
|
||||
|
||||
// 용량 정보
|
||||
if (info) {
|
||||
const pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
|
||||
var pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
|
||||
if (cfg.image_compress_enabled && pct > 0) {
|
||||
info.textContent = `${compressed.length}장 | ${fmt(origBytes)} → ${fmt(compBytes)} (${pct}% 절약) | 최대 ${cfg.image_max_px}px / 품질 ${cfg.image_quality}%`;
|
||||
info.textContent = compressed.length + '장 | ' + fmt(origBytes) + ' → ' + fmt(compBytes) + ' (' + pct + '% 절약) | 최대 ' + cfg.image_max_px + 'px / 품질 ' + cfg.image_quality + '%';
|
||||
info.style.color = '#00875A';
|
||||
} else {
|
||||
info.textContent = `${compressed.length}장 | ${fmt(compBytes)} (압축 비활성)`;
|
||||
info.textContent = compressed.length + '장 | ' + fmt(compBytes) + ' (압축 비활성)';
|
||||
info.style.color = '#8899BB';
|
||||
}
|
||||
}
|
||||
@@ -160,15 +211,36 @@ const ImageCompressor = (() => {
|
||||
|
||||
function updateInfo(input, info) {
|
||||
if (!info) return;
|
||||
const bytes = Array.from(input.files).reduce((s, f) => s + f.size, 0);
|
||||
info.textContent = `${input.files.length}장 | ${fmt(bytes)}`;
|
||||
var bytes = Array.from(input.files).reduce(function(s, f) { return s + f.size; }, 0);
|
||||
info.textContent = input.files.length + '장 | ' + fmt(bytes);
|
||||
}
|
||||
|
||||
function fmt(bytes) {
|
||||
return bytes < 1024 * 1024
|
||||
? (bytes / 1024).toFixed(0) + ' KB'
|
||||
? Math.round(bytes / 1024) + ' KB'
|
||||
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
return { compressAll, setupPreview, loadConfig };
|
||||
/**
|
||||
* 카메라 input(capture)이 찍은 사진을 갤러리 input에 병합 후 change 이벤트 발생
|
||||
* → setupPreview는 갤러리 input 하나만 바라보면 됨
|
||||
* @param {string} cameraId - capture="environment" input id
|
||||
* @param {string} galleryId - 기존 multiple input id (setupPreview 대상)
|
||||
*/
|
||||
function setupCameraAppend(cameraId, galleryId) {
|
||||
var cam = document.getElementById(cameraId);
|
||||
var main = document.getElementById(galleryId);
|
||||
if (!cam || !main) return;
|
||||
cam.addEventListener('change', function() {
|
||||
if (!this.files.length) return;
|
||||
var dt = new DataTransfer();
|
||||
Array.from(main.files).forEach(function(f) { dt.items.add(f); }); // 기존 파일 유지
|
||||
Array.from(this.files).forEach(function(f) { dt.items.add(f); }); // 새 사진 추가
|
||||
main.files = dt.files;
|
||||
main.dispatchEvent(new Event('change')); // setupPreview 재실행
|
||||
this.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
return { compressAll, setupPreview, loadConfig, setupCameraAppend };
|
||||
})();
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<!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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
@@ -11,24 +18,58 @@
|
||||
<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/export.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>
|
||||
<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;">
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;align-items:center;flex-wrap:wrap;">
|
||||
<select id="fRole" onchange="load()" style="width:auto">
|
||||
<option value="">전체</option><option value="mechanic">정비사</option>
|
||||
<option value="observer">옵저버</option>
|
||||
<option value="manufacturer">제조사</option><option value="admin">관리자</option>
|
||||
</select>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
|
||||
<input type="checkbox" id="chkInactive" onchange="load()" style="width:14px;height:14px;">
|
||||
비활성 계정 포함
|
||||
</label>
|
||||
</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>
|
||||
@@ -42,6 +83,7 @@
|
||||
<div class="form-group"><label>역할 <span class="req">*</span></label>
|
||||
<select id="eRole" onchange="toggleFields()">
|
||||
<option value="mechanic">정비사</option>
|
||||
<option value="observer">옵저버</option>
|
||||
<option value="manufacturer">제조사</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
@@ -70,16 +112,88 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사'};
|
||||
|
||||
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:'제조사',observer:'옵저버'};
|
||||
|
||||
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> <span style="font-size:11px;background:#F3F4F6;color:#374151;padding:1px 7px;border-radius:8px;font-weight:600;">${ROLE_LABEL[u.role]||u.role}</span></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 showInactive = document.getElementById('chkInactive').checked;
|
||||
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();
|
||||
const filtered = users.filter(u => !u.is_pending && (showInactive || u.is_active));
|
||||
document.getElementById('tbody').innerHTML = filtered.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('');
|
||||
${u.is_active ? `<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=''); }
|
||||
@@ -117,6 +231,11 @@ async function save() {
|
||||
closeModal(); load();
|
||||
} 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(); }
|
||||
async function delUser(id) {
|
||||
if (!confirm('계정을 삭제하시겠습니까?\n(처리 이력이 있는 계정은 비활성 처리됩니다.)')) return;
|
||||
try { await API.delete('/accounts/'+id); load(); }
|
||||
catch(e) { alert('오류: ' + e.message); }
|
||||
}
|
||||
loadPending();
|
||||
load();
|
||||
</script></body></html>
|
||||
|
||||
@@ -4,14 +4,23 @@
|
||||
<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">
|
||||
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
||||
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
@@ -20,8 +29,10 @@
|
||||
<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/export.html">📥 데이터 내보내기</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
@@ -46,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<!-- 종류 목록 -->
|
||||
<div class="card">
|
||||
<div class="card-title">등록된 충전기 종류</div>
|
||||
<div class="tbl-wrap">
|
||||
@@ -57,6 +68,7 @@
|
||||
<th>종류명</th>
|
||||
<th>설명</th>
|
||||
<th>충전기 수</th>
|
||||
<th>에러코드</th>
|
||||
<th>수정</th>
|
||||
<th>삭제</th>
|
||||
</tr>
|
||||
@@ -68,6 +80,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 +145,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 +158,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 +177,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 +285,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 +300,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,11 +4,43 @@
|
||||
<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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
@@ -17,23 +49,55 @@
|
||||
<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/export.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 +129,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');
|
||||
document.getElementById('fTypeId').innerHTML = types.map(t=>`<option value="${t.id}">${t.name}</option>`).join('');
|
||||
document.getElementById('tbody').innerHTML = chargers.map(c => `
|
||||
[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('');
|
||||
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 +223,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 +336,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,11 +4,18 @@
|
||||
<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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
@@ -17,15 +24,20 @@
|
||||
<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/export.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;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;">
|
||||
@@ -39,7 +51,7 @@
|
||||
<select id="fParty" style="width:auto">
|
||||
<option value="">전체 부담주체</option>
|
||||
<option value="cpo">CPO</option>
|
||||
<option value="manufacturer">제조사</option>
|
||||
<option value="manufacturer">업체</option>
|
||||
<option value="self">자체</option>
|
||||
<option value="user">사용자과실</option>
|
||||
<option value="other">기타</option>
|
||||
@@ -48,7 +60,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><th>처리일시</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -59,7 +74,29 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
|
||||
|
||||
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() {
|
||||
const [statsData, costs] = await Promise.all([API.get('/costs/stats'), API.get('/costs?cost_status='+document.getElementById('fStatus').value+'&cost_party_type='+document.getElementById('fParty').value)]);
|
||||
@@ -67,17 +104,24 @@ 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'&&c.cost_manufacturer_name?`<br><small>${c.cost_manufacturer_name}</small>`:''}</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${c.recv_party_type?(PARTY_LABEL[c.recv_party_type]||c.recv_party_type):'-'}${c.recv_party_type==='manufacturer'&&c.recv_manufacturer_name?`<br><small>${c.recv_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
224
frontend/static/pages/admin/export.html
Normal file
224
frontend/static/pages/admin/export.html
Normal file
@@ -0,0 +1,224 @@
|
||||
<!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>
|
||||
.export-card { background:white; border-radius:10px; padding:24px; box-shadow:0 2px 8px rgba(0,0,0,.06); margin-bottom:20px; }
|
||||
.sheet-badge { display:inline-flex; align-items:center; gap:6px; padding:5px 14px; border-radius:20px; font-size:12px; font-weight:700; margin:3px; }
|
||||
.date-row { display:flex; gap:14px; align-items:flex-end; flex-wrap:wrap; margin-top:18px; }
|
||||
.date-row .form-group { margin:0; min-width:140px; }
|
||||
.quick-btns { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
|
||||
.quick-btn { padding:4px 12px; border:1px solid var(--gray3); border-radius:6px; background:white; font-size:12px; color:var(--navy); cursor:pointer; }
|
||||
.quick-btn:hover { background:var(--gray1); border-color:var(--accent); }
|
||||
.download-btn { padding:12px 28px; font-size:15px; font-weight:700; border-radius:8px; border:none;
|
||||
background:var(--blue); color:white; cursor:pointer; display:flex; align-items:center; gap:8px;
|
||||
transition:background .15s; min-width:220px; justify-content:center; }
|
||||
.download-btn:hover { background:#1251A3; }
|
||||
.download-btn:disabled { background:var(--gray3); cursor:not-allowed; }
|
||||
.ind-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; margin-top:16px; }
|
||||
.ind-item { border:1px solid var(--gray2); border-radius:8px; padding:14px 16px; }
|
||||
.ind-item .ind-title { font-size:13px; font-weight:700; color:var(--navy); margin-bottom:8px; }
|
||||
.ind-item .ind-desc { font-size:11px; color:var(--gray4); margin-bottom:10px; line-height:1.5; }
|
||||
.status-msg { padding:10px 14px; border-radius:6px; font-size:13px; margin-top:12px; display:none; }
|
||||
@media(max-width:768px){
|
||||
.date-row { flex-direction:column; align-items:stretch; }
|
||||
.download-btn { width:100%; }
|
||||
.ind-grid { grid-template-columns:1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<div style="display:flex;align-items:center;gap:2px;">
|
||||
<button class="nav-hamburger" onclick="toggleSidebar()">☰</button>
|
||||
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
||||
</div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="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">📝 유형관리</a>
|
||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||
<a href="/pages/admin/export.html" class="active">📥 데이터 내보내기</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap;">
|
||||
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">📥 데이터 내보내기</h2>
|
||||
</div>
|
||||
|
||||
<!-- 통합 다운로드 -->
|
||||
<div class="export-card">
|
||||
<div class="card-title">📊 기간별 통합 다운로드</div>
|
||||
<p style="font-size:13px;color:var(--gray4);margin-bottom:8px;line-height:1.7;">
|
||||
설정한 기간의 모든 데이터를 <strong>하나의 엑셀 파일(4개 시트)</strong>로 다운로드합니다.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:4px;">
|
||||
<span class="sheet-badge" style="background:#0B1E3D;color:white;">① AS 신고이력</span>
|
||||
<span class="sheet-badge" style="background:#1B5E20;color:white;">② 조치이력</span>
|
||||
<span class="sheet-badge" style="background:#4A148C;color:white;">③ 개선항목</span>
|
||||
<span class="sheet-badge" style="background:#E65100;color:white;">④ 출장비정산</span>
|
||||
<span class="sheet-badge" style="background:#37474F;color:white;">+ 요약</span>
|
||||
</div>
|
||||
|
||||
<div class="date-row">
|
||||
<div class="form-group">
|
||||
<label>시작일</label>
|
||||
<input type="date" id="dateFrom">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>종료일</label>
|
||||
<input type="date" id="dateTo">
|
||||
</div>
|
||||
<button class="download-btn" id="fullBtn" onclick="downloadFull()">
|
||||
📥 통합 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="quick-btns">
|
||||
<span style="font-size:11px;color:var(--gray4);align-self:center;">빠른 선택:</span>
|
||||
<button class="quick-btn" onclick="setRange(7)">최근 7일</button>
|
||||
<button class="quick-btn" onclick="setRange(30)">최근 30일</button>
|
||||
<button class="quick-btn" onclick="setRange(90)">최근 3개월</button>
|
||||
<button class="quick-btn" onclick="setThisMonth()">이번 달</button>
|
||||
<button class="quick-btn" onclick="setLastMonth()">지난 달</button>
|
||||
<button class="quick-btn" onclick="setThisYear()">올해 전체</button>
|
||||
<button class="quick-btn" onclick="clearRange()">전체 기간</button>
|
||||
</div>
|
||||
|
||||
<div id="statusMsg" class="status-msg"></div>
|
||||
</div>
|
||||
|
||||
<!-- 개별 다운로드 -->
|
||||
<div class="export-card">
|
||||
<div class="card-title">📄 항목별 개별 다운로드</div>
|
||||
<p style="font-size:13px;color:var(--gray4);margin-bottom:4px;">위 기간 설정이 동일하게 적용됩니다.</p>
|
||||
<div class="ind-grid">
|
||||
<div class="ind-item">
|
||||
<div class="ind-title" style="color:#0B1E3D;">📋 AS 신고이력</div>
|
||||
<div class="ind-desc">신고 접수일 기준 · 충전기/신고자/상태/<br>정비사/조치내용/출장비 포함</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('reports')">📥 다운로드</button>
|
||||
</div>
|
||||
<div class="ind-item">
|
||||
<div class="ind-title" style="color:#1B5E20;">🔧 조치이력</div>
|
||||
<div class="ind-desc">조치 완료일 기준 · 정비사/조치유형/<br>소요시간/승인 여부 포함</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('repairs')">📥 다운로드</button>
|
||||
</div>
|
||||
<div class="ind-item">
|
||||
<div class="ind-title" style="color:#4A148C;">🔧 개선항목</div>
|
||||
<div class="ind-desc">등록일 기준 · 분류/우선순위/<br>담당업체/진행상태 포함</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('improvements')">📥 다운로드</button>
|
||||
</div>
|
||||
<div class="ind-item">
|
||||
<div class="ind-title" style="color:#E65100;">💰 출장비 정산</div>
|
||||
<div class="ind-desc">조치 완료일 기준 · 부담/수급 주체/<br>금액/정산상태 포함</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('costs')">📥 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('navOverlay');
|
||||
if (s) s.classList.toggle('mobile-open');
|
||||
if (o) o.classList.toggle('show');
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2,'0'); }
|
||||
function fmtDate(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
|
||||
|
||||
function setRange(days) {
|
||||
const to = new Date();
|
||||
const from = new Date(); from.setDate(from.getDate() - days + 1);
|
||||
document.getElementById('dateFrom').value = fmtDate(from);
|
||||
document.getElementById('dateTo').value = fmtDate(to);
|
||||
}
|
||||
function setThisMonth() {
|
||||
const now = new Date();
|
||||
document.getElementById('dateFrom').value = `${now.getFullYear()}-${pad(now.getMonth()+1)}-01`;
|
||||
document.getElementById('dateTo').value = fmtDate(now);
|
||||
}
|
||||
function setLastMonth() {
|
||||
const now = new Date();
|
||||
const y = now.getMonth() === 0 ? now.getFullYear()-1 : now.getFullYear();
|
||||
const m = now.getMonth() === 0 ? 12 : now.getMonth();
|
||||
const last = new Date(y, m, 0);
|
||||
document.getElementById('dateFrom').value = `${y}-${pad(m)}-01`;
|
||||
document.getElementById('dateTo').value = fmtDate(last);
|
||||
}
|
||||
function setThisYear() {
|
||||
const now = new Date();
|
||||
document.getElementById('dateFrom').value = `${now.getFullYear()}-01-01`;
|
||||
document.getElementById('dateTo').value = fmtDate(now);
|
||||
}
|
||||
function clearRange() {
|
||||
document.getElementById('dateFrom').value = '';
|
||||
document.getElementById('dateTo').value = '';
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
const from = document.getElementById('dateFrom').value;
|
||||
const to = document.getElementById('dateTo').value;
|
||||
const p = [];
|
||||
if (from) p.push('date_from=' + from);
|
||||
if (to) p.push('date_to=' + to);
|
||||
return p.length ? '?' + p.join('&') : '';
|
||||
}
|
||||
|
||||
function showStatus(msg, type='info') {
|
||||
const el = document.getElementById('statusMsg');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
el.className = 'status-msg alert alert-' + type;
|
||||
}
|
||||
function hideStatus() { document.getElementById('statusMsg').style.display = 'none'; }
|
||||
|
||||
async function downloadFull() {
|
||||
const btn = document.getElementById('fullBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 생성 중...';
|
||||
showStatus('엑셀 파일을 생성 중입니다. 데이터량에 따라 수 초가 걸릴 수 있습니다.', 'info');
|
||||
try {
|
||||
const from = document.getElementById('dateFrom').value;
|
||||
const to = document.getElementById('dateTo').value;
|
||||
const period = (from || to) ? `${from||'전체'}~${to||'전체'}` : '전체기간';
|
||||
await API.download('/export/full' + buildQuery(), `EV_AS_통합이력_${period}.xlsx`);
|
||||
showStatus('✅ 다운로드가 완료되었습니다.', 'success');
|
||||
} catch(e) {
|
||||
showStatus('❌ 오류: ' + e.message, 'danger');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '📥 통합 엑셀 다운로드';
|
||||
setTimeout(hideStatus, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadIndividual(type) {
|
||||
const names = {
|
||||
reports: 'AS신고이력',
|
||||
repairs: '조치이력',
|
||||
improvements: '개선항목',
|
||||
costs: '출장비정산',
|
||||
};
|
||||
try {
|
||||
await API.download('/export/' + type + buildQuery(), `${names[type]}.xlsx`);
|
||||
} catch(e) { alert('다운로드 오류: ' + e.message); }
|
||||
}
|
||||
|
||||
// 기본값: 이번 달
|
||||
setThisMonth();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -1,102 +1,184 @@
|
||||
<!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>
|
||||
.imp-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
|
||||
.info-dl { display:grid; grid-template-columns:80px 1fr; gap:8px 14px; font-size:13px; align-items:start; }
|
||||
.info-dl dt { color:var(--gray4); font-weight:600; padding-top:1px; }
|
||||
.info-dl dd { word-break:break-word; }
|
||||
.report-link { display:flex; align-items:center; gap:10px; padding:10px 12px;
|
||||
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
|
||||
cursor:pointer; font-size:13px; color:var(--navy); text-decoration:none; transition:background .15s; }
|
||||
.report-link:hover { background:var(--gray1); }
|
||||
.report-link-num { font-weight:700; color:var(--blue); flex-shrink:0; }
|
||||
.file-link { display:flex; align-items:center; gap:8px; padding:9px 12px;
|
||||
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
|
||||
color:var(--navy); text-decoration:none; font-size:13px; transition:background .15s; overflow:hidden; }
|
||||
.file-link:hover { background:var(--gray1); }
|
||||
.file-link span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.page-header { display:flex; align-items:center; gap:10px; margin-bottom:18px; }
|
||||
.page-header h2 { font-size:17px; font-weight:700; color:var(--navy); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.status-form { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
|
||||
@media(max-width:768px) {
|
||||
.imp-grid { grid-template-columns:1fr; }
|
||||
.status-form { grid-template-columns:1fr; }
|
||||
.info-dl { grid-template-columns:72px 1fr; gap:7px 10px; }
|
||||
}
|
||||
</style>
|
||||
</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 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>
|
||||
<nav class="nav">
|
||||
<div style="display:flex;align-items:center;gap:2px;">
|
||||
<button class="nav-hamburger" onclick="toggleSidebar()">☰</button>
|
||||
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
||||
</div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="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/export.html">📥 데이터 내보내기</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="page-header">
|
||||
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm" style="flex-shrink:0">← 목록</a>
|
||||
<h2 id="pageTitle">개선항목 상세</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'));
|
||||
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('navOverlay');
|
||||
if (s) s.classList.toggle('mobile-open');
|
||||
if (o) o.classList.toggle('show');
|
||||
}
|
||||
|
||||
const id = new URLSearchParams(location.search).get('id');
|
||||
const CAT={sw:'SW개선',hw:'HW개선',ui:'UI개선',firmware:'펌웨어',other:'기타'};
|
||||
const CAT = {hardware:'하드웨어',software:'소프트웨어',firmware:'펌웨어',installation:'설치환경',ui:'UI 개선',other:'기타'};
|
||||
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
|
||||
const STATUS_OPTIONS = ['registered','reviewing','developing','deployed','done'];
|
||||
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
|
||||
|
||||
async function load() {
|
||||
const imp = await API.get('/improvements/'+id);
|
||||
document.getElementById('pageTitle').textContent = `개선항목 #${imp.id}`;
|
||||
document.getElementById('pageTitle').textContent = `#${imp.id} ${imp.title}`;
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
|
||||
<div class="imp-grid">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-title">📋 기본 정보</div>
|
||||
<table class="no-hover" style="font-size:13px">
|
||||
<tr><td style="color:var(--gray4);width:90px">제목</td><td><strong>${imp.title}</strong></td></tr>
|
||||
<tr><td style="color:var(--gray4)">분류</td><td>${CAT[imp.category]||imp.category}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">우선순위</td><td>${imp.priority}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">관련 부품</td><td>${imp.part_name||'-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">담당 제조사</td><td><strong>${imp.manufacturer_company||'-'}</strong><br>${imp.manufacturer_name||''}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">등록자</td><td>${imp.created_by_name||'-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">실제 배포일</td><td>${imp.sw_deployed_at||'-'}</td></tr>
|
||||
<tr><td style="color:var(--gray4)">현재 상태</td><td>${Auth.statusBadge(imp.status)}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:12px">
|
||||
<dl class="info-dl">
|
||||
<dt>제목</dt> <dd><strong>${imp.title}</strong></dd>
|
||||
<dt>분류</dt> <dd>${CAT[imp.category]||imp.category}</dd>
|
||||
<dt>우선순위</dt> <dd>${PRI[imp.priority]||imp.priority}</dd>
|
||||
<dt>관련 부품</dt> <dd>${imp.part_name||'-'}</dd>
|
||||
<dt>담당 업체</dt> <dd><strong>${imp.manufacturer_name||'-'}</strong></dd>
|
||||
<dt>등록자</dt> <dd>${imp.created_by_name||'-'}</dd>
|
||||
<dt>등록일시</dt> <dd>${Auth.fmtDt(imp.created_at)}</dd>
|
||||
<dt>배포 목표일</dt><dd>${imp.sw_deploy_target||'-'}</dd>
|
||||
<dt>실제 배포일</dt><dd>${imp.sw_deployed_at||'-'}</dd>
|
||||
<dt>상태</dt> <dd>${Auth.statusBadge(imp.status)}</dd>
|
||||
</dl>
|
||||
<div style="margin-top:14px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">개선 내용</div>
|
||||
<div style="background:var(--gray1);padding:12px;border-radius:6px;font-size:13px;white-space:pre-wrap">${imp.description}</div>
|
||||
<div style="background:var(--gray1);padding:12px;border-radius:6px;font-size:13px;white-space:pre-wrap;line-height:1.7">${imp.description}</div>
|
||||
</div>
|
||||
${imp.manufacturer_memo?`<div style="margin-top:12px"><div style="font-size:12px;font-weight:700;color:var(--orange);margin-bottom:6px">제조사 메모</div><div style="background:#FFF5E6;padding:12px;border-radius:6px;font-size:13px">${imp.manufacturer_memo}</div></div>`:''}
|
||||
${imp.manufacturer_memo ? `
|
||||
<div style="margin-top:12px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--orange);margin-bottom:6px">제조사 메모</div>
|
||||
<div style="background:#FFF5E6;padding:12px;border-radius:6px;font-size:13px;line-height:1.6">${imp.manufacturer_memo}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- 연결 AS + 첨부 -->
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-title">📎 연결된 AS 신고</div>
|
||||
${imp.report_ids.length ? imp.report_ids.map(rid=>`
|
||||
<div onclick="location.href='/pages/admin/report-detail.html?id=${rid}'"
|
||||
style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;cursor:pointer;font-size:13px">
|
||||
신고 #${rid}
|
||||
</div>`).join('') : '<div class="alert alert-info">연결된 신고 없음</div>'}
|
||||
${(imp.report_links||[]).length
|
||||
? (imp.report_links||[]).map(r => `
|
||||
<a class="report-link" href="/pages/admin/report-detail.html?id=${r.id}">
|
||||
<span class="report-link-num">#${r.seq}</span>
|
||||
<span style="color:var(--gray4);font-size:12px">신고 상세 보기 →</span>
|
||||
</a>`).join('')
|
||||
: '<div class="alert alert-info" style="margin:0">연결된 신고 없음</div>'}
|
||||
</div>
|
||||
|
||||
<div class="card-title" style="margin-top:16px">📁 첨부 파일</div>
|
||||
${imp.attachments.length ? imp.attachments.map(a=>`
|
||||
<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-bottom:6px;display:block">
|
||||
📄 ${a.name||a.path.split('/').pop()}
|
||||
</a>`).join('') : '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
|
||||
<div class="card">
|
||||
<div class="card-title">📁 첨부 파일</div>
|
||||
${imp.attachments.length
|
||||
? imp.attachments.map(a => `
|
||||
<a class="file-link" href="${a.path}" target="_blank">
|
||||
<span style="flex-shrink:0">📄</span>
|
||||
<span>${a.name||a.path.split('/').pop()}</span>
|
||||
</a>`).join('')
|
||||
: '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상태 변경 -->
|
||||
<div class="card" style="margin-top:0">
|
||||
<div class="card">
|
||||
<div class="card-title">🔄 상태 변경</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>상태 변경</label>
|
||||
<div class="status-form">
|
||||
<div class="form-group" style="margin:0">
|
||||
<label>상태</label>
|
||||
<select id="newStatus">
|
||||
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SW 실제 배포일 (배포완료 시)</label>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label>SW 실제 배포일 <span style="color:var(--gray4);font-weight:400">(배포완료 시)</span></label>
|
||||
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>변경 메모</label><input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모"></div>
|
||||
<button class="btn btn-primary" onclick="changeStatus()">상태 저장</button>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label>변경 메모</label>
|
||||
<input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="changeStatus()">저장</button>
|
||||
</div>
|
||||
|
||||
<!-- 이력 로그 -->
|
||||
<div class="card" style="margin-top:0">
|
||||
<!-- 변경 이력 -->
|
||||
<div class="card">
|
||||
<div class="card-title">📜 변경 이력</div>
|
||||
${imp.logs.length ? `<div class="timeline">${imp.logs.map(l=>`
|
||||
${imp.logs.length
|
||||
? `<div class="timeline">${imp.logs.map(l=>`
|
||||
<div class="tl-item">
|
||||
<div class="tl-time">${Auth.fmtDt(l.changed_at)} — ${l.by||'시스템'}</div>
|
||||
<div class="tl-text">${l.old_status?`${STATUS_LABEL[l.old_status]||l.old_status} → `:''}${STATUS_LABEL[l.new_status]||l.new_status}${l.memo?` / ${l.memo}`:''}</div>
|
||||
</div>`).join('')}</div>` : '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
|
||||
</div>`).join('')}</div>`
|
||||
: '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function changeStatus() {
|
||||
const status = document.getElementById('newStatus').value;
|
||||
const memo = document.getElementById('changeMemo').value;
|
||||
const date = document.getElementById('deployedAt').value;
|
||||
const fd = new FormData();
|
||||
fd.append('status', status); fd.append('memo', memo);
|
||||
fd.append('status', document.getElementById('newStatus').value);
|
||||
fd.append('memo', document.getElementById('changeMemo').value);
|
||||
const date = document.getElementById('deployedAt').value;
|
||||
if (date) fd.append('sw_deployed_at', date);
|
||||
await API.patch('/improvements/'+id+'/status', fd);
|
||||
load();
|
||||
}
|
||||
|
||||
load();
|
||||
</script></body></html>
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<!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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
@@ -11,14 +18,17 @@
|
||||
<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/export.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;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 +45,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>
|
||||
@@ -51,9 +64,9 @@
|
||||
<div class="form-group">
|
||||
<label>분류 <span class="req">*</span></label>
|
||||
<select id="mCat">
|
||||
<option value="sw">SW 개선</option><option value="hw">HW 개선</option>
|
||||
<option value="ui">UI 개선</option><option value="firmware">펌웨어</option>
|
||||
<option value="other">기타</option>
|
||||
<option value="hardware">하드웨어</option><option value="software">소프트웨어</option>
|
||||
<option value="firmware">펌웨어</option><option value="installation">설치환경</option>
|
||||
<option value="ui">UI 개선</option><option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -90,41 +103,74 @@
|
||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
|
||||
|
||||
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 = {hardware:'하드웨어',software:'소프트웨어',firmware:'펌웨어',installation:'설치환경',ui:'UI',other:'기타'};
|
||||
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
|
||||
const selectedReports = new Set();
|
||||
let allReports = [];
|
||||
|
||||
async function load() {
|
||||
const st = document.getElementById('fStatus').value;
|
||||
const mfr = document.getElementById('fMfr').value;
|
||||
let impUrl = '/improvements?';
|
||||
if (st) impUrl += 'status=' + st + '&';
|
||||
if (mfr) impUrl += 'manufacturer_id=' + mfr + '&';
|
||||
const [mfrs, imps] = await Promise.all([
|
||||
API.get('/accounts?role=manufacturer'),
|
||||
API.get('/improvements?status='+document.getElementById('fStatus').value+'&manufacturer_id='+document.getElementById('fMfr').value)
|
||||
API.get('/manufacturers'),
|
||||
API.get(impUrl)
|
||||
]);
|
||||
// 제조사 필터 드롭다운
|
||||
const mfrSel = document.getElementById('fMfr');
|
||||
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); });
|
||||
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=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_name||'-'}</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('');
|
||||
}
|
||||
|
||||
async function openModal() {
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
const mfrs = await API.get('/accounts?role=manufacturer');
|
||||
document.getElementById('mMfr').innerHTML = '<option value="">제조사 선택</option>' +
|
||||
mfrs.map(m=>`<option value="${m.id}">${m.company||''} / ${m.name}</option>`).join('');
|
||||
const mfrs = await API.get('/manufacturers');
|
||||
document.getElementById('mMfr').innerHTML = '<option value="">업체 선택</option>' +
|
||||
mfrs.map(m=>`<option value="${m.id}">${m.name}</option>`).join('');
|
||||
allReports = await API.get('/reports');
|
||||
renderReportList('');
|
||||
}
|
||||
@@ -132,12 +178,12 @@ function closeModal() { document.getElementById('modal').classList.add('hidden')
|
||||
|
||||
function searchReports() { renderReportList(document.getElementById('mReportSearch').value.toLowerCase()); }
|
||||
function renderReportList(q) {
|
||||
const filtered = allReports.filter(r => !q || String(r.id).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
|
||||
const filtered = allReports.filter(r => !q || String(r.seq).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
|
||||
document.getElementById('mReportList').innerHTML = filtered.map(r => `
|
||||
<label style="display:flex;gap:8px;align-items:center;padding:5px;cursor:pointer;${selectedReports.has(r.id)?'background:#E3EDFF;border-radius:4px':''}">
|
||||
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}" style="accent-color:var(--accent);flex-shrink:0"
|
||||
onchange="${selectedReports.has(r.id)?'selectedReports.delete':'selectedReports.add'}(${r.id}); renderReportList('${q}')">
|
||||
<span><strong>#${r.id}</strong> ${r.charger_id||''} — ${(r.issue_types||[]).join(', ')}</span>
|
||||
<span><strong>#${r.seq}</strong> ${r.charger_id||''} — ${(r.issue_types||[]).join(', ')}</span>
|
||||
</label>`).join('') || '<div style="color:var(--gray4)">검색 결과 없음</div>';
|
||||
}
|
||||
|
||||
|
||||
443
frontend/static/pages/admin/issue-types.html
Normal file
443
frontend/static/pages/admin/issue-types.html
Normal file
@@ -0,0 +1,443 @@
|
||||
<!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"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="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/export.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>
|
||||
@@ -1,8 +1,9 @@
|
||||
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>QR 생성</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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
@@ -11,8 +12,10 @@
|
||||
<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/export.html">📥 데이터 내보내기</a>
|
||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,46 @@
|
||||
<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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
|
||||
@@ -17,64 +52,310 @@
|
||||
<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/export.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;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_all">접수 대기 (전체)</option>
|
||||
<option value="pending_approval">승인대기</option>
|
||||
<option value="pending">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="done">완료</option>
|
||||
<option value="waiting">부품대기</option>
|
||||
<option value="revisit">재방문</option>
|
||||
<option value="closed">상황종료</option>
|
||||
</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><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');
|
||||
let _stationNameFilter = _p.get('station_name') || '';
|
||||
|
||||
// ── 뷰 전환 ──
|
||||
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);
|
||||
if (s) url += 'status=' + s + '&';
|
||||
if (c) url += 'charger_id=' + c + '&';
|
||||
if (_stationNameFilter) url += 'station_name=' + encodeURIComponent(_stationNameFilter) + '&';
|
||||
allRows = await API.get(url);
|
||||
|
||||
// 충전소 필터 배너
|
||||
const existing = document.getElementById('stationFilterBanner');
|
||||
if (_stationNameFilter) {
|
||||
if (!existing) {
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'stationFilterBanner';
|
||||
banner.style.cssText = 'background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:8px 14px;margin-bottom:12px;font-size:13px;color:var(--navy2);display:flex;justify-content:space-between;align-items:center;';
|
||||
banner.innerHTML = `<span>🏢 충전소 필터: <strong>${_stationNameFilter}</strong></span><button onclick="_stationNameFilter='';this.closest('#stationFilterBanner').remove();load()" style="background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px;">✕ 해제</button>`;
|
||||
document.querySelector('.main').insertBefore(banner, document.querySelector('.card'));
|
||||
}
|
||||
} else if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
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">${r.seq}</span>
|
||||
</td>
|
||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||
<strong>${r.charger_id}</strong>
|
||||
${r.report_scope === 'station' ? `<div style="font-size:11px;color:#7C3AED;font-weight:600;margin-top:2px">🏢 충전소 전체 · ${r.scope_charger_count}대</div>` : r.report_scope === 'type' ? `<div style="font-size:11px;color:#0369A1;font-weight:600;margin-top:2px">🔧 동일모델 전체 · ${r.scope_charger_count}대</div>` : r.report_scope === 'multi' ? `<div style="font-size:11px;color:#B45309;font-weight:600;margin-top:2px">📋 충전기 ${r.scope_charger_count}대 선택</div>` : ''}
|
||||
</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">${r.occurred_at ? Auth.fmtDt(r.occurred_at) : '<span style="color:var(--gray4)">-</span>'}</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', closed: '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>
|
||||
|
||||
@@ -25,9 +25,10 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">AS 관리</div>
|
||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||
@@ -36,6 +37,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 +71,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 +302,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 +337,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 +383,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>
|
||||
|
||||
@@ -5,13 +5,36 @@
|
||||
<title>로그인 — EV AS 관리</title>
|
||||
<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-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;}
|
||||
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-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, #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>
|
||||
@@ -30,10 +61,79 @@ body{display:flex;align-items:center;justify-content:center;min-height:100vh;bac
|
||||
</div>
|
||||
<div id="err"></div>
|
||||
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button>
|
||||
</div>
|
||||
|
||||
<!-- 회원가입 -->
|
||||
<div class="pane" id="paneRegister">
|
||||
<div class="form-group" style="margin-bottom:12px">
|
||||
<label>계정 유형 <span style="color:var(--red)">*</span></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:4px">
|
||||
<label id="roleCardMechanic" onclick="selectRole('mechanic')" style="border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;">
|
||||
<div style="font-size:18px;margin-bottom:2px">🔧</div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--navy)">정비사</div>
|
||||
<div style="font-size:11px;color:var(--gray4)">조치 입력·처리</div>
|
||||
</label>
|
||||
<label id="roleCardObserver" onclick="selectRole('observer')" style="border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;">
|
||||
<div style="font-size:18px;margin-bottom:2px">👁</div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--navy)">옵저버</div>
|
||||
<div style="font-size:11px;color:var(--gray4)">현황 조회만 가능</div>
|
||||
</label>
|
||||
</div>
|
||||
<input type="hidden" id="regRole" value="mechanic">
|
||||
</div>
|
||||
<div id="regNotice" 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;
|
||||
@@ -47,16 +147,92 @@ async function doLogin() {
|
||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
|
||||
const data = await res.json();
|
||||
Auth.save(data.access_token, data.role, data.name, data.user_id);
|
||||
if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
|
||||
const redirect = sessionStorage.getItem('ev_redirect');
|
||||
sessionStorage.removeItem('ev_redirect');
|
||||
if (redirect && redirect !== '/pages/login.html') {
|
||||
location.href = redirect;
|
||||
} else if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
|
||||
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
|
||||
else if (data.role === 'observer') location.href = '/pages/observer/dashboard.html';
|
||||
else location.href = '/pages/manufacturer/dashboard.html';
|
||||
} catch(e) {
|
||||
document.getElementById('err').textContent = e.message;
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 계정 유형 선택 ──
|
||||
function selectRole(role) {
|
||||
document.getElementById('regRole').value = role;
|
||||
const mc = document.getElementById('roleCardMechanic');
|
||||
const oc = document.getElementById('roleCardObserver');
|
||||
if (role === 'mechanic') {
|
||||
mc.style.cssText = 'border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;';
|
||||
oc.style.cssText = 'border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;';
|
||||
document.getElementById('regNotice').innerHTML = '📌 정비사 계정으로 가입됩니다.<br>가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.';
|
||||
} else {
|
||||
oc.style.cssText = 'border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;';
|
||||
mc.style.cssText = 'border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;';
|
||||
document.getElementById('regNotice').innerHTML = '👁 현황 조회 전용 계정입니다.<br>신고 등록·조치 등 쓰기 기능은 사용할 수 없습니다.<br>가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 제조사 목록 로드 (비인증) ──
|
||||
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);
|
||||
fd.append('role', document.getElementById('regRole').value);
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<!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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS — 제조사</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/manufacturer/dashboard.html" class="active">📋 개선항목 목록</a>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!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>
|
||||
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS — 제조사</span></div><div id="navUser"></div></nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<div class="main" style="max-width:760px;margin:0 auto;">
|
||||
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
||||
<a href="/pages/manufacturer/dashboard.html" class="btn btn-outline btn-sm">← 목록</a>
|
||||
|
||||
@@ -4,32 +4,91 @@
|
||||
<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 style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리</span></div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<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" id="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
|
||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
||||
<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,33 +96,189 @@
|
||||
<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?v=20260603"></script>
|
||||
<script src="/js/auth.js?v=20260603"></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}'">
|
||||
<td>#${r.id}</td>
|
||||
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}${r.re_dispatch_count > 0 ? ' <span style="font-size:10px;background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:8px;font-weight:700;vertical-align:middle;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : ''}</td>
|
||||
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
|
||||
<td>${r.station_name||'-'}</td>
|
||||
<td>${r.charger_type||'-'}</td>
|
||||
<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 ${r.re_dispatch_count > 0 ? 'btn-accent' : '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>
|
||||
|
||||
156
frontend/static/pages/mechanic/history.html
Normal file
156
frontend/static/pages/mechanic/history.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!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">
|
||||
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리</span></div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<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" id="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?v=20260603"></script>
|
||||
<script src="/js/auth.js?v=20260603"></script>
|
||||
<script>
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
// admin 계정으로 접근 시 안내
|
||||
if (Auth.role() === 'admin') {
|
||||
document.querySelector('.main').insertAdjacentHTML('afterbegin',
|
||||
`<div class="alert" style="background:#FFF8E6;border:1px solid #FFD600;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:13px;">
|
||||
⚠️ 현재 <strong>관리자(${Auth.name()})</strong> 계정으로 접속 중입니다.
|
||||
처리이력은 해당 계정이 직접 등록한 조치만 표시됩니다.
|
||||
정비사 이력 전체는 <a href="/pages/admin/reports.html" style="color:var(--accent);font-weight:700">관리자 신고 목록</a>에서 확인하세요.
|
||||
</div>`);
|
||||
}
|
||||
|
||||
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>
|
||||
${r.attempt > 1 ? `<span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:2px 8px;border-radius:10px;font-weight:700;">🔁 ${r.attempt}차 조치</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>
|
||||
@@ -10,14 +10,36 @@
|
||||
.photo-preview{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
|
||||
.photo-preview img{width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);}
|
||||
.photo-info{font-size:11px;margin-top:4px;min-height:14px;color:var(--gray4);}
|
||||
@media(max-width:768px){
|
||||
.upload-area{padding:16px 12px;font-size:13px;}
|
||||
.photo-preview img{width:88px;height:88px;}
|
||||
.main > div{max-width:100% !important;padding:0;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<div style="display:flex;align-items:center;gap:2px;">
|
||||
<button class="nav-hamburger" onclick="toggleSidebar()">☰</button>
|
||||
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||
</div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="main" style="max-width:640px;margin:0 auto;">
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<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" id="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>
|
||||
@@ -29,15 +51,13 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">🔧 조치 내역 입력</div>
|
||||
<div class="card-title" id="repairCardTitle">🔧 조치 내역 입력</div>
|
||||
<div id="attemptBanner"></div>
|
||||
|
||||
<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,139 +66,423 @@
|
||||
<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">
|
||||
<label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
||||
<label class="upload-area" for="photosBefore">📷 촬영 또는 앨범 선택</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||
<label class="upload-area" for="photosBeforeCamera" style="margin:0">📷 카메라 촬영</label>
|
||||
<label class="upload-area" for="photosBefore" style="margin:0">🖼 갤러리 선택</label>
|
||||
</div>
|
||||
<input type="file" id="photosBeforeCamera" accept="image/*" capture="environment" style="display:none">
|
||||
<input type="file" id="photosBefore" accept="image/*" multiple style="display:none">
|
||||
<div class="photo-preview" id="previewBefore"></div>
|
||||
<div class="photo-info" id="infoBefore"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
|
||||
<label class="upload-area" for="photosAfter">📷 촬영 또는 앨범 선택</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||
<label class="upload-area" for="photosAfterCamera" style="margin:0">📷 카메라 촬영</label>
|
||||
<label class="upload-area" for="photosAfter" style="margin:0">🖼 갤러리 선택</label>
|
||||
</div>
|
||||
<input type="file" id="photosAfterCamera" accept="image/*" capture="environment" style="display:none">
|
||||
<input type="file" id="photosAfter" accept="image/*" multiple style="display:none">
|
||||
<div class="photo-preview" id="previewAfter"></div>
|
||||
<div class="photo-info" id="infoAfter"></div>
|
||||
</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 class="form-row" style="margin-bottom:14px;">
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label>🕐 조치 시작 시각 <span style="font-size:11px;color:var(--gray4);font-weight:400">(직접 수정 가능)</span></label>
|
||||
<input type="datetime-local" id="startedAt" style="width:100%">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label>🏁 조치 완료 시각 <span style="font-size:11px;color:var(--gray4);font-weight:400">(직접 수정 가능)</span></label>
|
||||
<input type="datetime-local" id="completedAt" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" style="margin-bottom:14px;">
|
||||
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록)
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/imageCompress.js"></script>
|
||||
<!-- 저장 버튼 영역 -->
|
||||
<button class="btn btn-primary btn-lg" id="doneBtn" style="width:100%;margin-top:4px;" onclick="submitForm(true)">
|
||||
✅ 조치 완료 저장
|
||||
</button>
|
||||
<input type="hidden" id="resultStatus" value="done">
|
||||
</div>
|
||||
</div><!-- max-width wrapper -->
|
||||
</div><!-- .main -->
|
||||
</div><!-- .layout -->
|
||||
|
||||
<script src="/js/api.js?v=20260603"></script>
|
||||
<script src="/js/auth.js?v=20260603"></script>
|
||||
<script src="/js/imageCompress.js?v=20260603"></script>
|
||||
<script>
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('navOverlay');
|
||||
if (s) s.classList.toggle('mobile-open');
|
||||
if (o) o.classList.toggle('show');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// datetime-local 입력값 포맷: "YYYY-MM-DDTHH:mm"
|
||||
function toLocalDtInput(date) {
|
||||
const d = new Date(date);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
const startTime = new Date();
|
||||
document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR');
|
||||
document.getElementById('startedAt').value = toLocalDtInput(startTime);
|
||||
document.getElementById('completedAt').value = toLocalDtInput(startTime);
|
||||
|
||||
// 조치 시작시각 변경 시 완료시각이 시작보다 이전이면 자동 보정
|
||||
document.getElementById('startedAt').addEventListener('change', function () {
|
||||
const started = document.getElementById('startedAt').value;
|
||||
const completed = document.getElementById('completedAt').value;
|
||||
if (started && completed && completed < started) {
|
||||
document.getElementById('completedAt').value = started;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedReports = new Set();
|
||||
if (initReportId) selectedReports.add(parseInt(initReportId));
|
||||
|
||||
async function load() {
|
||||
// ── 신규 모드 ──
|
||||
async function loadCreate() {
|
||||
try {
|
||||
const charger = await API.get('/chargers/' + chargerId);
|
||||
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>${charger.id}</strong></div>
|
||||
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${charger.name}</strong></div>
|
||||
<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>`;
|
||||
if (!charger) return; // 401 → 로그아웃 리다이렉트 진행 중
|
||||
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>' + charger.id + '</strong></div>' +
|
||||
'<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>' + charger.name + '</strong></div>' +
|
||||
'<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) {
|
||||
if (!reports || !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}"
|
||||
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>`
|
||||
: ''}
|
||||
</div>
|
||||
</label>`).join('');
|
||||
list.innerHTML = reports.map(function(r) {
|
||||
var bg = selectedReports.has(r.id) ? '#E3EDFF' : 'white';
|
||||
var checked = selectedReports.has(r.id) ? 'checked' : '';
|
||||
var photoHtml = r.photos && r.photos.length
|
||||
? '<div class="photo-preview">' + r.photos.map(function(p) { return '<img src="' + p + '">'; }).join('') + '</div>'
|
||||
: '';
|
||||
return '<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:' + bg + '">' +
|
||||
'<input type="checkbox" ' + 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) +
|
||||
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : '') + '</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>' +
|
||||
photoHtml +
|
||||
'</div>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
|
||||
// 차수 배너: 대상 신고(initReportId 또는 첫 번째)의 re_dispatch_count 기준
|
||||
var targetReport = reports.find(function(r) { return r.id === parseInt(initReportId); }) || reports[0];
|
||||
if (targetReport && targetReport.re_dispatch_count > 0) {
|
||||
var nth = targetReport.re_dispatch_count + 1;
|
||||
document.getElementById('repairCardTitle').textContent = '🔧 조치 내역 입력 (' + nth + '차 조치)';
|
||||
document.getElementById('attemptBanner').innerHTML =
|
||||
'<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">' +
|
||||
'🔁 이 건은 <strong>' + nth + '차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)' +
|
||||
'</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('chargerCard').innerHTML =
|
||||
'<div class="alert alert-danger">충전기 정보를 불러오지 못했습니다.<br><small style="opacity:.8">' + e.message + '</small></div>';
|
||||
document.getElementById('reportList').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReport(id, checked, label) {
|
||||
if (checked) { selectedReports.add(id); label.style.background = '#E3EDFF'; }
|
||||
else { selectedReports.delete(id); label.style.background = 'white'; }
|
||||
if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; }
|
||||
else { selectedReports.delete(id); label.style.background='white'; }
|
||||
}
|
||||
|
||||
// ── 편집 모드 ──
|
||||
async function loadEdit() {
|
||||
let repair;
|
||||
try { repair = await API.get('/repairs/' + repairId); }
|
||||
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
|
||||
if (!repair) return; // 401 → 로그아웃 리다이렉트 진행 중
|
||||
|
||||
// 헤더 업데이트
|
||||
var h2el = document.querySelector('.main > div > h2') || document.querySelector('h2');
|
||||
if (h2el) h2el.parentNode.removeChild(h2el);
|
||||
const attemptLabel = repair.attempt > 1 ? ` · ${repair.attempt}차 조치` : '';
|
||||
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
|
||||
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}${attemptLabel}</span>`);
|
||||
|
||||
// 차수 배너 (2차 이상일 때)
|
||||
if (repair.attempt > 1) {
|
||||
document.getElementById('repairCardTitle').textContent = `🔧 조치 내역 입력 (${repair.attempt}차 조치)`;
|
||||
document.getElementById('attemptBanner').innerHTML =
|
||||
`<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">` +
|
||||
`🔁 이 건은 <strong>${repair.attempt}차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
// 충전기 카드
|
||||
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 || '';
|
||||
if (repair.started_at) document.getElementById('startedAt').value = toLocalDtInput(repair.started_at);
|
||||
if (repair.completed_at) document.getElementById('completedAt').value = toLocalDtInput(repair.completed_at);
|
||||
|
||||
// 기존 사진 표시
|
||||
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 && repair.photos_before.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
|
||||
if (repair.photos_after && 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 수집 — 1단계: 저정밀(WiFi/셀) 즉시, 2단계: 고정밀(GPS) 백그라운드
|
||||
(function acquireGPS() {
|
||||
if (!navigator.geolocation) {
|
||||
document.getElementById('gpsStatus').className = 'alert alert-warn';
|
||||
document.getElementById('gpsStatus').textContent = '⚠️ 이 기기는 위치 정보를 지원하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
function applyPos(pos, label) {
|
||||
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 =
|
||||
`📍 위치 수집 완료${label} <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
|
||||
}
|
||||
function failGPS() {
|
||||
if (document.getElementById('mechanicLat').value) return; // 이미 성공
|
||||
document.getElementById('gpsStatus').className = 'alert alert-warn';
|
||||
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다. (저장은 가능)';
|
||||
}
|
||||
// 1단계: 캐시 허용 + 저정밀 → 5초 내 응답 (WiFi/셀 기반, 실내에서도 동작)
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
pos => {
|
||||
applyPos(pos, '');
|
||||
// 2단계: 고정밀 GPS 백그라운드로 시도해 더 정확한 좌표로 업데이트
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
pos2 => applyPos(pos2, ' (고정밀)'),
|
||||
() => {}, // 실패해도 1단계 좌표 유지
|
||||
{ enableHighAccuracy: true, timeout: 30000 }
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// 저정밀도 실패 시 고정밀 한 번 더 시도
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
pos => applyPos(pos, ''),
|
||||
failGPS,
|
||||
{ enableHighAccuracy: true, timeout: 30000 }
|
||||
);
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
|
||||
);
|
||||
})();
|
||||
|
||||
// 이미지 압축 + 다중 선택 프리뷰
|
||||
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
|
||||
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
|
||||
ImageCompressor.setupCameraAppend('photosBeforeCamera', 'photosBefore');
|
||||
ImageCompressor.setupCameraAppend('photosAfterCamera', 'photosAfter');
|
||||
|
||||
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 doneBtn = document.getElementById('doneBtn');
|
||||
doneBtn.disabled = true;
|
||||
doneBtn.textContent = '저장 중...';
|
||||
|
||||
const resultStatus = 'done';
|
||||
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);
|
||||
const startedAtVal = document.getElementById('startedAt').value;
|
||||
const completedAtVal = document.getElementById('completedAt').value;
|
||||
if (startedAtVal) fd.append('started_at_input', startedAtVal);
|
||||
if (completedAtVal) fd.append('completed_at_input', completedAtVal);
|
||||
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('✅ 조치 완료로 저장되었습니다.');
|
||||
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('✅ 조치 완료로 저장되었습니다.');
|
||||
location.href = '/pages/mechanic/history.html';
|
||||
}
|
||||
} catch(e) {
|
||||
showErr(e.message);
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
document.getElementById('submitBtn').textContent = '조치 완료 저장';
|
||||
doneBtn.disabled = false;
|
||||
doneBtn.textContent = '✅ 조치 완료 저장';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showErr(msg) {
|
||||
const el = document.getElementById('formErr');
|
||||
el.textContent = msg; el.style.display = 'block';
|
||||
}
|
||||
|
||||
load();
|
||||
const DEFAULT_REPAIR_TYPES = [
|
||||
{key:'부품교체',label:'🔩 부품 교체'},
|
||||
{key:'재시작', label:'🔄 재시작'},
|
||||
{key:'설정변경',label:'⚙️ 설정 변경'},
|
||||
{key:'청소', label:'🧹 청소'},
|
||||
{key:'배선정리',label:'🔌 배선 정리'},
|
||||
{key:'펌웨어', label:'💾 펌웨어 업데이트'},
|
||||
{key:'기타', label:'📋 기타'},
|
||||
];
|
||||
|
||||
function renderRepairTypeList(types, preChecked) {
|
||||
const el = document.getElementById('repairTypes');
|
||||
if (!el) return;
|
||||
el.innerHTML = types.map(t => `
|
||||
<label class="check-item">
|
||||
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
|
||||
${t.label}
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
async function loadRepairTypes(preChecked = []) {
|
||||
// 기본값 즉시 표시 — 네트워크 대기 없이 바로 사용 가능
|
||||
renderRepairTypeList(DEFAULT_REPAIR_TYPES, preChecked);
|
||||
// API에서 커스텀 유형 로드해 덮어쓰기 (실패해도 기본값 유지)
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const tid = setTimeout(() => controller.abort(), 8000);
|
||||
const res = await fetch('/api/settings/repair-types', { signal: controller.signal });
|
||||
clearTimeout(tid);
|
||||
if (!res.ok) return;
|
||||
const types = await res.json();
|
||||
if (Array.isArray(types) && types.length) renderRepairTypeList(types, preChecked);
|
||||
} catch(_) { /* 기본값 유지 */ }
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
loadEdit();
|
||||
} else {
|
||||
loadRepairTypes();
|
||||
loadCreate();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,15 +6,29 @@
|
||||
<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>
|
||||
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리</span></div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="scan-wrap">
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
<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" id="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>
|
||||
@@ -27,9 +41,11 @@
|
||||
</div>
|
||||
</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 src="/js/api.js?v=20260603"></script><script src="/js/auth.js?v=20260603"></script>
|
||||
<script>
|
||||
Auth.require(['mechanic','admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
126
frontend/static/pages/observer/dashboard.html
Normal file
126
frontend/static/pages/observer/dashboard.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!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>
|
||||
.ro-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:700;background:#EDE9FE;color:#5B21B6;margin-left:8px;vertical-align:middle;}
|
||||
.stat-link{text-decoration:none;display:block;}
|
||||
.stat-link:hover .stat{box-shadow:0 4px 16px rgba(0,0,0,.12);transform:translateY(-1px);transition:all .15s;}
|
||||
.filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;}
|
||||
.filter-bar select,.filter-bar input{padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;font-family:inherit;color:var(--text);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav">
|
||||
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/observer/dashboard.html" class="active">📊 현황 대시보드</a>
|
||||
<a href="/pages/observer/reports.html">📋 신고 목록</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats" id="statsRow" style="margin-bottom:24px">
|
||||
<div class="stat"><div class="stat-num" id="sTotal">-</div><div class="stat-label">전체 신고</div></div>
|
||||
<div class="stat warn"><div class="stat-num" id="sPendingApproval">-</div><div class="stat-label">승인대기</div></div>
|
||||
<div class="stat warn"><div class="stat-num" id="sPending">-</div><div class="stat-label">접수</div></div>
|
||||
<div class="stat warn"><div class="stat-num" id="sInProgress">-</div><div class="stat-label">처리중</div></div>
|
||||
<div class="stat good"><div class="stat-num" id="sDone">-</div><div class="stat-label">완료</div></div>
|
||||
<div class="stat"><div class="stat-num" id="sClosed">-</div><div class="stat-label">상황종료</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 신고 목록 -->
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-wrap:wrap;gap:8px;">
|
||||
<div class="card-title" style="margin:0">📋 신고 현황</div>
|
||||
<div class="filter-bar" style="margin:0">
|
||||
<select id="fStatus" onchange="load()">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending_approval">승인대기</option>
|
||||
<option value="pending">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="waiting">부품대기</option>
|
||||
<option value="revisit">재방문</option>
|
||||
<option value="done">완료</option>
|
||||
<option value="closed">상황종료</option>
|
||||
</select>
|
||||
<input type="text" id="fCharger" placeholder="충전기ID 검색" style="width:130px" oninput="load()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<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 id="empty" style="display:none;text-align:center;padding:40px;color:var(--gray4);font-size:13px">신고 내역이 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['observer', 'admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let _allReports = [];
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await API.get('/stats');
|
||||
document.getElementById('sTotal').textContent = s.total ?? '-';
|
||||
document.getElementById('sPendingApproval').textContent = s.pending_approval ?? 0;
|
||||
document.getElementById('sPending').textContent = s.pending ?? 0;
|
||||
document.getElementById('sInProgress').textContent = s.in_progress ?? 0;
|
||||
document.getElementById('sDone').textContent = s.done ?? 0;
|
||||
document.getElementById('sClosed').textContent = s.closed ?? 0;
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const status = document.getElementById('fStatus').value;
|
||||
const chargerId = document.getElementById('fCharger').value.trim();
|
||||
try {
|
||||
let url = '/reports';
|
||||
if (status) url += '?status=' + status;
|
||||
_allReports = await API.get(url);
|
||||
} catch(e) { _allReports = []; }
|
||||
render(chargerId);
|
||||
}
|
||||
|
||||
function render(chargerId) {
|
||||
let rows = _allReports;
|
||||
if (chargerId) rows = rows.filter(r => r.charger_id?.includes(chargerId));
|
||||
const tbody = document.getElementById('tbody');
|
||||
const empty = document.getElementById('empty');
|
||||
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr style="cursor:default">
|
||||
<td>#${r.id}</td>
|
||||
<td><strong>${r.charger_id}</strong></td>
|
||||
<td>${r.station_name || '-'}</td>
|
||||
<td>${(r.issue_types || []).join(', ') || '-'}</td>
|
||||
<td>${r.error_code || '-'}</td>
|
||||
<td>${r.occurred_at ? Auth.fmtDt(r.occurred_at) : '<span style="color:var(--gray4)">-</span>'}</td>
|
||||
<td>${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td>${r.mechanic_name || '-'}</td>
|
||||
<td>${Auth.statusBadge(r.status)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
loadStats();
|
||||
load();
|
||||
</script>
|
||||
</body></html>
|
||||
105
frontend/static/pages/observer/reports.html
Normal file
105
frontend/static/pages/observer/reports.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!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>
|
||||
.ro-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:700;background:#EDE9FE;color:#5B21B6;margin-left:8px;vertical-align:middle;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav">
|
||||
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()">☰</button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
|
||||
<div id="navUser"></div>
|
||||
</nav>
|
||||
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-section">메뉴</div>
|
||||
<a href="/pages/observer/dashboard.html">📊 현황 대시보드</a>
|
||||
<a href="/pages/observer/reports.html" class="active">📋 신고 목록</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px;">
|
||||
<div class="card-title" style="margin:0">📋 신고 목록 <span id="totalBadge" style="font-size:12px;color:var(--gray4);font-weight:400"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
|
||||
<select id="fStatus" onchange="load()" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;font-family:inherit;">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending_approval">승인대기</option>
|
||||
<option value="pending">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="waiting">부품대기</option>
|
||||
<option value="revisit">재방문</option>
|
||||
<option value="done">완료</option>
|
||||
<option value="closed">상황종료</option>
|
||||
</select>
|
||||
<input type="text" id="fCharger" placeholder="충전기ID" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;width:130px" oninput="render()">
|
||||
<input type="text" id="fStation" placeholder="충전소명" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;width:130px" oninput="render()">
|
||||
</div>
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>접수번호</th><th>충전기ID</th><th>충전소명</th><th>CPO</th>
|
||||
<th>문제유형</th><th>에러코드</th><th>발생일시</th><th>신고일시</th>
|
||||
<th>정비사</th><th>조치완료</th><th>상태</th>
|
||||
</tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
<div id="empty" style="display:none;text-align:center;padding:40px;color:var(--gray4);font-size:13px">신고 내역이 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
Auth.require(['observer', 'admin']);
|
||||
Auth.renderNav(document.getElementById('navUser'));
|
||||
|
||||
let _allReports = [];
|
||||
|
||||
async function load() {
|
||||
const status = document.getElementById('fStatus').value;
|
||||
try {
|
||||
_allReports = await API.get('/reports' + (status ? '?status=' + status : ''));
|
||||
} catch(e) { _allReports = []; }
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const cid = document.getElementById('fCharger').value.trim().toLowerCase();
|
||||
const station = document.getElementById('fStation').value.trim().toLowerCase();
|
||||
let rows = _allReports;
|
||||
if (cid) rows = rows.filter(r => r.charger_id?.toLowerCase().includes(cid));
|
||||
if (station) rows = rows.filter(r => r.station_name?.toLowerCase().includes(station));
|
||||
|
||||
document.getElementById('totalBadge').textContent = `(${rows.length}건)`;
|
||||
const tbody = document.getElementById('tbody');
|
||||
const empty = document.getElementById('empty');
|
||||
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr style="cursor:default">
|
||||
<td>#${r.id}</td>
|
||||
<td><strong>${r.charger_id}</strong></td>
|
||||
<td>${r.station_name || '-'}</td>
|
||||
<td>${r.cpo_name || '-'}</td>
|
||||
<td>${(r.issue_types || []).join(', ') || '-'}</td>
|
||||
<td>${r.error_code || '-'}</td>
|
||||
<td>${r.occurred_at ? Auth.fmtDt(r.occurred_at) : '<span style="color:var(--gray4)">-</span>'}</td>
|
||||
<td>${Auth.fmtDt(r.reported_at)}</td>
|
||||
<td>${r.mechanic_name || '-'}</td>
|
||||
<td>${r.mechanic_name && r.status === 'done' ? '완료' : '-'}</td>
|
||||
<td>${Auth.statusBadge(r.status)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -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="기타 문제 내용 입력">
|
||||
@@ -241,10 +242,11 @@ body { background: var(--gray1); }
|
||||
충전기 사진 <span style="color:var(--red)">*필수</span>
|
||||
<span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span>
|
||||
</label>
|
||||
<label class="upload-area" for="chargerPhoto">
|
||||
📷 탭하여 촬영하거나 앨범에서 선택<br>
|
||||
<span style="font-size:11px">여러 장 동시 선택 가능</span>
|
||||
</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||
<label class="upload-area" for="chargerPhotoCamera" style="margin:0">📷 카메라 촬영</label>
|
||||
<label class="upload-area" for="chargerPhoto" style="margin:0">🖼 갤러리 선택</label>
|
||||
</div>
|
||||
<input type="file" id="chargerPhotoCamera" accept="image/*" capture="environment" style="display:none">
|
||||
<input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none">
|
||||
<div class="photo-preview" id="chargerPreview"></div>
|
||||
<div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div>
|
||||
@@ -253,13 +255,35 @@ body { background: var(--gray1); }
|
||||
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
|
||||
차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span>
|
||||
</label>
|
||||
<label class="upload-area" for="carPhoto">📷 탭하여 촬영하거나 앨범에서 선택</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
|
||||
<label class="upload-area" for="carPhotoCamera" style="margin:0">📷 카메라 촬영</label>
|
||||
<label class="upload-area" for="carPhoto" style="margin:0">🖼 갤러리 선택</label>
|
||||
</div>
|
||||
<input type="file" id="carPhotoCamera" accept="image/*" capture="environment" style="display:none">
|
||||
<input type="file" id="carPhoto" accept="image/*" multiple style="display:none">
|
||||
<div class="photo-preview" id="carPreview"></div>
|
||||
<div class="photo-info" id="carInfo" style="color:var(--gray4)"></div>
|
||||
</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>
|
||||
@@ -292,9 +316,9 @@ body { background: var(--gray1); }
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/imageCompress.js"></script>
|
||||
<script src="/js/imageCompress.js?v=20260603"></script>
|
||||
<script>
|
||||
const ISSUES = [
|
||||
let ISSUES = [
|
||||
{key:'충전불가', label:'⚡ 충전 불가'},
|
||||
{key:'화면오류', label:'🖥 화면 오류'},
|
||||
{key:'케이블불량',label:'🔌 케이블 불량'},
|
||||
@@ -303,6 +327,7 @@ const ISSUES = [
|
||||
{key:'에러발생', label:'⚠️ 에러 발생'},
|
||||
{key:'기타', label:'📋 기타'},
|
||||
];
|
||||
let chargerErrors = [];
|
||||
|
||||
const STATUS_ICON = {
|
||||
pending_approval: '🕐',
|
||||
@@ -327,7 +352,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>
|
||||
@@ -421,23 +450,63 @@ function initCollapseHeight() {
|
||||
}
|
||||
|
||||
// ── GPS ──
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
pos => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function(pos) {
|
||||
document.getElementById('gpsLat').value = pos.coords.latitude;
|
||||
document.getElementById('gpsLng').value = pos.coords.longitude;
|
||||
document.getElementById('gpsStatus').textContent =
|
||||
`📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`;
|
||||
'📍 위치 수집 완료 (' + pos.coords.latitude.toFixed(5) + ', ' + pos.coords.longitude.toFixed(5) + ')';
|
||||
document.getElementById('gpsStatus').className = 'alert alert-success';
|
||||
},
|
||||
() => {
|
||||
function() {
|
||||
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
|
||||
document.getElementById('gpsStatus').className = 'alert alert-warn';
|
||||
}
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
// ── 에러코드 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__') { var ecEl = document.getElementById('errorCodeCustom'); return ecEl ? ecEl.value : ''; }
|
||||
return sel.value;
|
||||
}
|
||||
return sel.value;
|
||||
}
|
||||
|
||||
// ── 문제 유형 버튼 ──
|
||||
const grid = document.getElementById('issueGrid');
|
||||
ISSUES.forEach(issue => {
|
||||
function renderIssueButtons(issues) {
|
||||
const grid = document.getElementById('issueGrid');
|
||||
grid.innerHTML = '';
|
||||
issues.forEach(issue => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'issue-btn';
|
||||
btn.textContent = issue.label;
|
||||
@@ -445,13 +514,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 +549,15 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
document.getElementById('submitBtn').textContent = '접수 중...';
|
||||
|
||||
var scopeEl = document.querySelector('input[name="scope"]:checked');
|
||||
var scope = scopeEl ? scopeEl.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 +567,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';
|
||||
|
||||
@@ -42,6 +42,14 @@ http {
|
||||
expires 7d;
|
||||
}
|
||||
|
||||
# HTML·JS·CSS 파일 — 캐시 금지 (중간 프록시/CDN 포함)
|
||||
location ~* \.(html|js|css)$ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# 정적 파일 (SPA 라우팅)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
Reference in New Issue
Block a user