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

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

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

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

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

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

View File

@@ -13,7 +13,8 @@ SECRET_KEY = os.getenv("SECRET_KEY", "changeme")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def hash_password(password: str) -> str:
"""비밀번호 bcrypt 해시 생성"""
@@ -58,6 +59,24 @@ def get_current_user(
raise credentials_exception
return user
def get_optional_user(
token: Optional[str] = Depends(oauth2_scheme_optional),
db: Session = Depends(get_db)
) -> Optional[models.User]:
if not token:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if not user_id:
return None
return db.query(models.User).filter(
models.User.id == int(user_id),
models.User.is_active == True
).first()
except Exception:
return None
def require_role(*roles):
def checker(current_user: models.User = Depends(get_current_user)):
if current_user.role not in roles:

View File

@@ -1,9 +1,13 @@
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from datetime import datetime, timedelta
from collections import defaultdict
import calendar as cal_module
import os
from routers import auth_router, chargers, reports, repairs, costs, improvements, accounts, settings, export
from routers import auth_router, chargers, reports, repairs, costs, improvements, accounts, settings, export, manufacturers
from routers import holidays
app = FastAPI(title="EV 충전기 AS 관리 시스템", version="1.0.0")
@@ -25,6 +29,49 @@ app.include_router(improvements.router)
app.include_router(accounts.router)
app.include_router(settings.router)
app.include_router(export.router)
app.include_router(manufacturers.router)
app.include_router(holidays.router)
def _calc_business_hours(start: datetime, end: datetime, holiday_dates: set,
work_start: int = 9, work_end: int = 18) -> float:
"""업무시간(평일 09:00-18:00, 공휴일 제외) 기준 경과 시간 계산."""
if not start or not end or end <= start:
return 0.0
s = start.replace(tzinfo=None)
e = end.replace(tzinfo=None)
total = 0.0
cur_date = s.date()
end_date = e.date()
while cur_date <= end_date:
if cur_date.weekday() < 5 and cur_date not in holiday_dates:
day_ws = datetime(cur_date.year, cur_date.month, cur_date.day, work_start)
day_we = datetime(cur_date.year, cur_date.month, cur_date.day, work_end)
seg_start = max(s, day_ws)
seg_end = min(e, day_we)
if seg_end > seg_start:
total += (seg_end - seg_start).total_seconds() / 3600
cur_date += timedelta(days=1)
return round(total, 1)
def _calc_holiday_excluded_hours(start: datetime, end: datetime, holiday_dates: set) -> float:
"""공휴일을 제외하고 나머지 날(주말 포함)은 24시간 전체 카운트."""
if not start or not end or end <= start:
return 0.0
s = start.replace(tzinfo=None)
e = end.replace(tzinfo=None)
total = 0.0
cur_date = s.date()
end_date = e.date()
while cur_date <= end_date:
if cur_date not in holiday_dates:
day_s = datetime(cur_date.year, cur_date.month, cur_date.day)
day_e = day_s + timedelta(days=1)
seg_start = max(s, day_s)
seg_end = min(e, day_e)
if seg_end > seg_start:
total += (seg_end - seg_start).total_seconds() / 3600
cur_date += timedelta(days=1)
return round(total, 1)
@app.get("/api/health")
def health():
@@ -33,8 +80,8 @@ def health():
@app.get("/api/stats")
def stats(db=None):
from database import SessionLocal
from sqlalchemy import func
from models import Report, Repair, RepairCost, Improvement
from sqlalchemy import func, text
from models import Report, Repair, RepairCost, Improvement, SystemSetting, Holiday
db = SessionLocal()
try:
total = db.query(Report).count()
@@ -44,10 +91,452 @@ def stats(db=None):
cost_pend = db.query(RepairCost).filter(RepairCost.cost_status == "pending").count()
imp_open = db.query(Improvement).filter(
Improvement.status.in_(["registered","reviewing","developing"])).count()
# ── 설정 읽기 ──
def _setting(key, default):
r = db.query(SystemSetting).filter_by(key=key).first()
return r.value if r else default
_base = _setting("time_metric_base", "occurred")
# 구버전 "true"/"false" → 신버전 mode 문자열로 정규화
_raw_mode = _setting("time_metric_worktime", "off")
if _raw_mode == "true": _raw_mode = "worktime"
elif _raw_mode == "false": _raw_mode = "off"
_mode = _raw_mode # "off" | "holiday_24h" | "worktime"
if _base == "reported":
t_join = "rep.reported_at"
t_plain = "reported_at"
else:
t_join = "COALESCE(rep.occurred_at, rep.reported_at)"
t_plain = "COALESCE(occurred_at, reported_at)"
if _mode in ("worktime", "holiday_24h"):
# ── Python 기반 계산 (공휴일 테이블 활용) ──
holiday_dates = {r.holiday_date for r in db.query(Holiday).all()}
calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours
def _avg_py(interval_days):
rows = db.execute(text(f"""
SELECT {t_join} AS start_t, r.completed_at
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
WHERE r.completed_at IS NOT NULL
AND r.completed_at >= NOW() - INTERVAL '{interval_days} days'
""")).fetchall()
h_list = [calc_fn(row[0], row[1], holiday_dates)
for row in rows if row[0] and row[1]]
return round(sum(h_list) / len(h_list), 1) if h_list else None
avg_30d = _avg_py(30)
avg_7d = _avg_py(7)
pending_rows = db.execute(text(f"""
SELECT {t_plain} AS start_t
FROM reports
WHERE status IN ('pending','pending_approval','in_progress','waiting','revisit')
AND {t_plain} IS NOT NULL
""")).fetchall()
now = datetime.now()
h_pending = [calc_fn(row[0], now, holiday_dates) for row in pending_rows if row[0]]
over_24h = sum(1 for h in h_pending if h > 24)
over_72h = sum(1 for h in h_pending if h > 72)
longest_h = max(h_pending, default=0.0)
else:
# ── 단순 경과시간 기준 계산 (SQL) ──
avg_30d = db.execute(text(f"""
SELECT ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1)
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
WHERE r.completed_at IS NOT NULL
AND r.completed_at >= NOW() - INTERVAL '30 days'
""")).scalar()
avg_7d = db.execute(text(f"""
SELECT ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1)
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
WHERE r.completed_at IS NOT NULL
AND r.completed_at >= NOW() - INTERVAL '7 days'
""")).scalar()
avg_30d = float(avg_30d) if avg_30d else None
avg_7d = float(avg_7d) if avg_7d else None
row = db.execute(text(f"""
SELECT
COUNT(*) FILTER (WHERE EXTRACT(EPOCH FROM (NOW()-{t_plain}))/3600 > 24) AS over_24h,
COUNT(*) FILTER (WHERE EXTRACT(EPOCH FROM (NOW()-{t_plain}))/3600 > 72) AS over_72h,
COALESCE(MAX(ROUND(EXTRACT(EPOCH FROM (NOW()-{t_plain}))/3600, 1)), 0) AS longest_h
FROM reports
WHERE status IN ('pending','pending_approval','in_progress','waiting','revisit')
""")).fetchone()
over_24h = int(row.over_24h)
over_72h = int(row.over_72h)
longest_h = float(row.longest_h)
return {
"total": total, "pending": pending,
"in_progress": in_prog, "done": done,
"cost_pending": cost_pend, "improvement_open": imp_open,
"time_metric_base": _base,
"time_metric_worktime": _mode,
"avg_resolution_hours_30d": avg_30d,
"avg_resolution_hours_7d": avg_7d,
"pending_over_24h": over_24h,
"pending_over_72h": over_72h,
"longest_pending_hours": longest_h,
}
finally:
db.close()
def _month_range(n: int = 13):
"""최근 n개월 목록 생성 (YYYY-MM 형식)."""
now = datetime.now()
result = []
for i in range(n - 1, -1, -1):
m = now.month - i
y = now.year
while m <= 0:
m += 12
y -= 1
result.append(f"{y:04d}-{m:02d}")
return result
@app.get("/api/stats/monthly")
def stats_monthly(months: int = 13):
from database import SessionLocal
from sqlalchemy import text
from models import SystemSetting, Holiday
db = SessionLocal()
try:
def _setting(key, default):
r = db.query(SystemSetting).filter_by(key=key).first()
return r.value if r else default
_base = _setting("time_metric_base", "occurred")
_raw = _setting("time_metric_worktime", "off")
if _raw == "true": _raw = "worktime"
elif _raw == "false": _raw = "off"
_mode = _raw
if _base == "reported":
t_join = "rep.reported_at"
else:
t_join = "COALESCE(rep.occurred_at, rep.reported_at)"
all_months = _month_range(months)
if _mode in ("worktime", "holiday_24h"):
holiday_dates = {r.holiday_date for r in db.query(Holiday).all()}
calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours
rows = db.execute(text(f"""
SELECT TO_CHAR(r.completed_at, 'YYYY-MM') AS month,
{t_join} AS start_t,
r.completed_at
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
WHERE r.completed_at IS NOT NULL
AND r.completed_at >= NOW() - INTERVAL '{months} months'
""")).fetchall()
by_month = defaultdict(list)
for row in rows:
if row[1] and row[2]:
by_month[row[0]].append(calc_fn(row[1], row[2], holiday_dates))
data = {}
for m, vals in by_month.items():
data[m] = {"avg": round(sum(vals) / len(vals), 1), "count": len(vals)}
else:
rows = db.execute(text(f"""
SELECT TO_CHAR(r.completed_at, 'YYYY-MM') AS month,
ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1) AS avg_h,
COUNT(*) AS cnt
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
WHERE r.completed_at IS NOT NULL
AND r.completed_at >= NOW() - INTERVAL '{months} months'
GROUP BY month
ORDER BY month
""")).fetchall()
data = {row[0]: {"avg": float(row[1]) if row[1] else None, "count": int(row[2])} for row in rows}
# ── 월별 신고 접수 건수 ──
rpt_rows = db.execute(text(f"""
SELECT TO_CHAR(reported_at, 'YYYY-MM') AS month,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'done') AS done_cnt
FROM reports
WHERE reported_at >= NOW() - INTERVAL '{months} months'
GROUP BY month
ORDER BY month
""")).fetchall()
rpt_map = {row[0]: {"total": int(row[1]), "done": int(row[2])} for row in rpt_rows}
result = []
for m in all_months:
d = data.get(m)
r = rpt_map.get(m)
result.append({
"month": m,
"avg_hours": d["avg"] if d else None,
"count": d["count"] if d else 0,
"report_total": r["total"] if r else 0,
"report_done": r["done"] if r else 0,
})
return {"data": result, "time_metric_worktime": _mode, "time_metric_base": _base}
finally:
db.close()
@app.get("/api/stats/daily")
def stats_daily(month: str):
"""month: YYYY-MM. Returns day-by-day processing time and report counts."""
from database import SessionLocal
from sqlalchemy import text
from models import SystemSetting, Holiday
try:
year, mon = int(month[:4]), int(month[5:7])
except (ValueError, IndexError):
raise HTTPException(400, "month must be YYYY-MM format")
db = SessionLocal()
try:
def _setting(key, default):
r = db.query(SystemSetting).filter_by(key=key).first()
return r.value if r else default
_base = _setting("time_metric_base", "occurred")
_raw = _setting("time_metric_worktime", "off")
if _raw == "true": _raw = "worktime"
elif _raw == "false": _raw = "off"
_mode = _raw
if _base == "reported":
t_join = "rep.reported_at"
t_plain = "reported_at"
else:
t_join = "COALESCE(rep.occurred_at, rep.reported_at)"
t_plain = "COALESCE(occurred_at, reported_at)"
_, days_in_month = cal_module.monthrange(year, mon)
all_days = [f"{year:04d}-{mon:02d}-{d:02d}" for d in range(1, days_in_month + 1)]
if _mode in ("worktime", "holiday_24h"):
holiday_dates = {r.holiday_date for r in db.query(Holiday).all()}
calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours
rows = db.execute(text(f"""
SELECT TO_CHAR(r.completed_at, 'YYYY-MM-DD') AS day,
{t_join} AS start_t,
r.completed_at
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
WHERE r.completed_at IS NOT NULL
AND TO_CHAR(r.completed_at, 'YYYY-MM') = :month
"""), {"month": month}).fetchall()
by_day = defaultdict(list)
for row in rows:
if row[1] and row[2]:
by_day[row[0]].append(calc_fn(row[1], row[2], holiday_dates))
data = {}
for d, vals in by_day.items():
data[d] = {"avg": round(sum(vals) / len(vals), 1), "count": len(vals)}
else:
rows = db.execute(text(f"""
SELECT TO_CHAR(r.completed_at, 'YYYY-MM-DD') AS day,
ROUND(AVG(EXTRACT(EPOCH FROM (r.completed_at - {t_join}))/3600)::numeric, 1) AS avg_h,
COUNT(*) AS cnt
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
WHERE r.completed_at IS NOT NULL
AND TO_CHAR(r.completed_at, 'YYYY-MM') = :month
GROUP BY day
ORDER BY day
"""), {"month": month}).fetchall()
data = {row[0]: {"avg": float(row[1]) if row[1] else None, "count": int(row[2])} for row in rows}
rpt_rows = db.execute(text(f"""
SELECT TO_CHAR(reported_at, 'YYYY-MM-DD') AS day,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'done') AS done_cnt
FROM reports
WHERE TO_CHAR(reported_at, 'YYYY-MM') = :month
GROUP BY day
ORDER BY day
"""), {"month": month}).fetchall()
rpt_map = {row[0]: {"total": int(row[1]), "done": int(row[2])} for row in rpt_rows}
result = []
for day in all_days:
d = data.get(day)
r = rpt_map.get(day)
result.append({
"day": day,
"avg_hours": d["avg"] if d else None,
"count": d["count"] if d else 0,
"report_total": r["total"] if r else 0,
"report_done": r["done"] if r else 0,
})
return {"data": result, "time_metric_worktime": _mode, "time_metric_base": _base, "month": month}
finally:
db.close()
@app.get("/api/stats/daily/detail")
def stats_daily_detail(day: str):
"""day: YYYY-MM-DD. Returns per-repair and per-report detail for that day."""
import json as _json
from database import SessionLocal
from sqlalchemy import text
from models import SystemSetting, Holiday
try:
datetime.strptime(day, "%Y-%m-%d")
except ValueError:
raise HTTPException(400, "day must be YYYY-MM-DD format")
db = SessionLocal()
try:
def _setting(key, default):
r = db.query(SystemSetting).filter_by(key=key).first()
return r.value if r else default
_base = _setting("time_metric_base", "occurred")
_raw = _setting("time_metric_worktime", "off")
if _raw == "true": _raw = "worktime"
elif _raw == "false": _raw = "off"
_mode = _raw
if _base == "reported":
t_join = "rep.reported_at"
else:
t_join = "COALESCE(rep.occurred_at, rep.reported_at)"
if _mode in ("worktime", "holiday_24h"):
holiday_dates = {r.holiday_date for r in db.query(Holiday).all()}
calc_fn = _calc_business_hours if _mode == "worktime" else _calc_holiday_excluded_hours
# ── 처리 완료 내역 ──
repair_rows = db.execute(text(f"""
SELECT r.id AS repair_id,
rep.id AS report_id,
rep.charger_id,
c.station_name,
rep.issue_types,
{t_join} AS start_t,
r.completed_at,
u.name AS mechanic_name
FROM repairs r
JOIN repair_reports rr ON rr.repair_id = r.id
JOIN reports rep ON rep.id = rr.report_id
LEFT JOIN users u ON u.id = r.mechanic_id
LEFT JOIN chargers c ON c.id = rep.charger_id
WHERE r.completed_at IS NOT NULL
AND TO_CHAR(r.completed_at, 'YYYY-MM-DD') = :day
ORDER BY r.completed_at
"""), {"day": day}).fetchall()
repairs = []
for row in repair_rows:
start_t, completed = row[5], row[6]
if _mode in ("worktime", "holiday_24h") and start_t and completed:
h = calc_fn(start_t, completed, holiday_dates)
elif start_t and completed:
h = round((completed - start_t).total_seconds() / 3600, 1)
else:
h = None
try:
issues = _json.loads(row[4]) if row[4] else []
except Exception:
issues = []
repairs.append({
"repair_id": row[0],
"report_id": row[1],
"charger_id": row[2] or "",
"station_name": row[3] or "",
"issue_types": issues,
"start_t": start_t.isoformat() if start_t else None,
"completed_at": completed.isoformat() if completed else None,
"processing_hours": h,
"mechanic_name": row[7] or "",
})
# ── 신고 접수 내역 ──
rpt_rows = db.execute(text("""
SELECT rep.id, rep.charger_id, c.station_name,
rep.issue_types, rep.status, rep.reported_at
FROM reports rep
LEFT JOIN chargers c ON c.id = rep.charger_id
WHERE TO_CHAR(rep.reported_at, 'YYYY-MM-DD') = :day
ORDER BY rep.reported_at
"""), {"day": day}).fetchall()
reports = []
for row in rpt_rows:
try:
issues = _json.loads(row[3]) if row[3] else []
except Exception:
issues = []
reports.append({
"id": row[0],
"charger_id": row[1] or "",
"station_name": row[2] or "",
"issue_types": issues,
"status": row[4],
"reported_at": row[5].isoformat() if row[5] else None,
})
return {"day": day, "repairs": repairs, "reports": reports}
finally:
db.close()
@app.get("/api/stats/top-chargers")
def stats_top_chargers(limit: int = 10):
"""충전기별 누적 고장 신고 건수 Top N."""
from database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
try:
rows = db.execute(text("""
SELECT rep.charger_id,
COALESCE(c.station_name, rep.charger_id) AS station_name,
COALESCE(c.name, '') AS charger_name,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE rep.status = 'done') AS done_cnt,
COUNT(*) FILTER (WHERE rep.status != 'done') AS active_cnt
FROM reports rep
LEFT JOIN chargers c ON c.id = rep.charger_id
GROUP BY rep.charger_id, c.station_name, c.name
ORDER BY total DESC
LIMIT :lim
"""), {"lim": limit}).fetchall()
return [
{
"charger_id": row[0],
"station_name": row[1],
"charger_name": row[2],
"total": int(row[3]),
"done": int(row[4]),
"active": int(row[5]),
}
for row in rows
]
finally:
db.close()

View File

@@ -9,6 +9,8 @@ class ChargerType(Base):
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
chargers = relationship("Charger", back_populates="charger_type")
errors = relationship("ChargerTypeError", back_populates="charger_type",
cascade="all, delete-orphan", order_by="ChargerTypeError.display_order")
class User(Base):
__tablename__ = "users"
@@ -21,6 +23,7 @@ class User(Base):
phone = Column(String(20))
email = Column(String(100))
is_active = Column(Boolean, default=True)
is_pending = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, server_default=func.now())
class Charger(Base):
@@ -52,10 +55,14 @@ class Report(Base):
gps_lat = Column(Float)
gps_lng = Column(Float)
status = Column(String(30), default="pending")
ocpp_log = Column(Text)
source = Column(String(20), default="qr") # qr | admin
reported_by = Column(Integer, ForeignKey("users.id"), nullable=True)
reported_at = Column(TIMESTAMP, server_default=func.now())
charger = relationship("Charger", back_populates="reports")
photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan")
repair_links = relationship("RepairReport", back_populates="report")
reporter = relationship("User", foreign_keys=[reported_by])
class ReportPhoto(Base):
__tablename__ = "report_photos"
@@ -74,7 +81,12 @@ class Repair(Base):
started_at = Column(TIMESTAMP, nullable=False)
completed_at = Column(TIMESTAMP)
result_status = Column(String(20), default="done")
mechanic_lat = Column(Float)
mechanic_lng = Column(Float)
approved_at = Column(TIMESTAMP)
approved_by = Column(Integer, ForeignKey("users.id"))
mechanic = relationship("User", foreign_keys=[mechanic_id])
approver = relationship("User", foreign_keys=[approved_by])
report_links = relationship("RepairReport", back_populates="repair", cascade="all, delete-orphan")
photos = relationship("RepairPhoto", back_populates="repair", cascade="all, delete-orphan")
cost = relationship("RepairCost", back_populates="repair", uselist=False)
@@ -161,8 +173,36 @@ class ImprovementLog(Base):
improvement = relationship("Improvement", back_populates="logs")
changer = relationship("User")
class ChargerTypeError(Base):
__tablename__ = "charger_type_errors"
id = Column(Integer, primary_key=True)
charger_type_id = Column(Integer, ForeignKey("charger_types.id", ondelete="CASCADE"), nullable=False)
error_code = Column(String(20), nullable=False)
error_name = Column(String(100), nullable=False)
range_condition = Column(String(200))
description = Column(Text)
auto_recovery = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
charger_type = relationship("ChargerType", back_populates="errors")
class Manufacturer(Base):
__tablename__ = "manufacturers"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
representative_name = Column(String(100))
business_number = Column(String(50))
phone = Column(String(30))
address = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
class SystemSetting(Base):
__tablename__ = "system_settings"
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=False)
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class Holiday(Base):
__tablename__ = "holidays"
holiday_date = Column(Date, primary_key=True)
name = Column(String(100), nullable=False)

View File

@@ -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)):

View File

@@ -1,20 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
import models
from auth import verify_password, create_access_token, get_current_user
from auth import verify_password, create_access_token, get_current_user, hash_password
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(models.User).filter(
models.User.username == form.username,
models.User.is_active == True
models.User.username == form.username
).first()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다.")
if getattr(user, 'is_pending', False):
raise HTTPException(status_code=403, detail="가입 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다.")
if not user.is_active:
raise HTTPException(status_code=403, detail="비활성화된 계정입니다. 관리자에게 문의하세요.")
token = create_access_token({"sub": str(user.id)})
return {
"access_token": token,
@@ -24,6 +28,30 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
"user_id": user.id
}
@router.post("/register")
def register(
username: str = Form(...),
password: str = Form(...),
name: str = Form(...),
phone: str = Form(""),
company: str = Form(""),
db: Session = Depends(get_db)
):
if db.query(models.User).filter_by(username=username).first():
raise HTTPException(400, "이미 사용 중인 아이디입니다.")
user = models.User(
username=username,
password_hash=hash_password(password),
role="mechanic",
name=name,
phone=phone or None,
company=company or None,
is_active=False,
is_pending=True,
)
db.add(user); db.commit()
return {"ok": True}
@router.get("/me")
def me(current_user: models.User = Depends(get_current_user)):
return {

View File

@@ -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()

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi import APIRouter, Depends, HTTPException, Form, Body
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import Optional
from sqlalchemy import desc, text
from typing import List, Optional
from datetime import datetime
from database import get_db
import models
@@ -48,6 +48,18 @@ def list_costs(
})
return result
@router.delete("/bulk")
def bulk_delete_costs(
ids: List[int] = Body(...),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
if not ids:
raise HTTPException(400, "삭제할 항목을 선택하세요.")
result = db.execute(text("DELETE FROM repair_costs WHERE id = ANY(:ids)"), {"ids": ids})
db.commit()
return {"deleted": result.rowcount}
@router.get("/stats")
def cost_stats(db: Session = Depends(get_db), _=Depends(require_admin)):
from sqlalchemy import func, extract

View File

@@ -77,7 +77,7 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
headers = [
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
"신고자연락처","문제발생시각","신고일시","처리상태",
"신고자연락처","문제발생시각","신고일시","신고출처","신고자","처리상태",
"담당정비사","정비사소속","조치유형","조치내용",
"조치시작","조치완료","작업소요시간","신고→완료소요시간",
"문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태",
@@ -85,7 +85,7 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
]
style_header(ws, headers)
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,12,
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18]
for i, w in enumerate(col_widths, 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
@@ -99,9 +99,10 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
ir.improvement_id
for ir in db.query(models.ImprovementReport).filter_by(report_id=r.id).all()
]
seq_no = row_num - 1 # 순차번호 (1부터 시작)
row_data = [
r.id,
seq_no,
r.charger_id,
c.charger_type.name if c and c.charger_type else "",
c.name if c else "",
@@ -116,6 +117,8 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
r.contact or "",
fmt_dt(r.occurred_at),
fmt_dt(r.reported_at),
{"qr": "QR스캔", "admin": "관리자접수", "dashboard": "대시보드접수"}.get(r.source or "qr", r.source or "qr"),
r.reporter.name if r.reporter else "",
r.status,
repair.mechanic.name if repair and repair.mechanic else "",
repair.mechanic.company if repair and repair.mechanic else "",
@@ -211,17 +214,30 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
headers = [
"번호","제목","분류","우선순위","개선내용","관련부품",
"담당제조사","담당자","연락처","연결AS건수","연결AS번호",
"담당제조사","담당자","연락처","연결AS건수","연결AS번호","연결AS신고자",
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
"등록관리자","등록일시"
]
style_header(ws, headers)
for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,12,14,14,24,12,16], 1):
for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,24,12,14,14,24,12,16], 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
imps = db.query(models.Improvement).order_by(desc(models.Improvement.created_at)).all()
for row_num, imp in enumerate(imps, 2):
rids = [ir.report_id for ir in imp.report_links]
reporters = []
for ir in imp.report_links:
r = ir.report
if not r:
continue
if r.source == "admin" and r.reporter:
reporters.append(f"#{r.id} {r.reporter.name}(관리자)")
elif r.contact:
reporters.append(f"#{r.id} {r.contact}(QR)")
else:
reporters.append(f"#{r.id} 익명(QR)")
row_data = [
imp.id, imp.title, imp.category, imp.priority,
imp.description, imp.part_name or "",
@@ -230,6 +246,7 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
imp.manufacturer.phone if imp.manufacturer else "",
len(rids),
", ".join(str(i) for i in rids),
"\n".join(reporters),
imp.status,
fmt_d(imp.sw_deploy_target),
fmt_d(imp.sw_deployed_at),

View 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}

View File

@@ -1,7 +1,7 @@
import json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
from sqlalchemy.orm import Session
from sqlalchemy import desc
from sqlalchemy import desc, text
from typing import List, Optional
from datetime import datetime
from database import get_db
@@ -86,6 +86,18 @@ async def create_improvement(
db.commit()
return {"id": imp.id}
@router.delete("/bulk")
def bulk_delete_improvements(
ids: List[int] = Body(...),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
if not ids:
raise HTTPException(400, "삭제할 항목을 선택하세요.")
result = db.execute(text("DELETE FROM improvements WHERE id = ANY(:ids)"), {"ids": ids})
db.commit()
return {"deleted": result.rowcount}
@router.patch("/{imp_id}/status")
def update_status(
imp_id: int, status: str = Form(...), memo: str = Form(""),

View 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}

View File

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

View File

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

View File

@@ -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)

View File

@@ -17,12 +17,21 @@
body{font-family:'Noto Sans KR',sans-serif;background:var(--gray1);color:var(--text);font-size:14px;min-height:100vh;}
/* ── NAV ── */
.nav{background:var(--navy);color:white;padding:0 24px;height:54px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.3);}
.nav{background:var(--navy);color:white;padding:0 24px;height:54px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:400;box-shadow:0 2px 8px rgba(0,0,0,.3);}
.nav-brand{font-size:16px;font-weight:700;color:var(--accent);}
.nav-user{font-size:13px;color:rgba(255,255,255,.7);display:flex;align-items:center;gap:12px;}
.nav-user a{color:rgba(255,255,255,.7);text-decoration:none;cursor:pointer;}
.nav-user a:hover{color:white;}
/* ── 햄버거 버튼 (데스크톱 숨김) ── */
.nav-hamburger{display:none;background:none;border:none;color:white;font-size:22px;
cursor:pointer;padding:4px 10px;margin-right:2px;border-radius:6px;line-height:1;}
.nav-hamburger:hover{background:rgba(255,255,255,.12);}
/* ── 모바일 오버레이 ── */
.mobile-nav-overlay{display:none;position:fixed;inset:54px 0 0 0;
background:rgba(0,0,0,.45);z-index:299;}
/* ── SIDEBAR (admin/mechanic) ── */
.layout{display:flex;min-height:calc(100vh - 54px);}
.sidebar{background:var(--navy2);width:200px;flex-shrink:0;padding:16px 0;}
@@ -139,10 +148,38 @@ textarea{resize:vertical;min-height:80px;}
.spinner{display:inline-block;width:18px;height:18px;border:2px solid var(--gray3);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
/* ── 2컬럼 상세 그리드 ── */
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;}
/* ── RESPONSIVE ── */
@media(max-width:768px){
.form-row,.form-row-3{grid-template-columns:1fr;}
.sidebar{display:none;}
.main{padding:16px;}
.stats{grid-template-columns:repeat(2,1fr);}
.detail-grid{grid-template-columns:1fr;}
/* nav: sticky → fixed (가로 오버플로우 시 body 넓이에 끌려 햄버거 버튼이 밀려나는 문제 방지) */
.nav{position:fixed;left:0;width:100%;box-sizing:border-box;}
body{padding-top:54px;}
/* 사이드바 → 슬라이드 드로어 */
.sidebar{
position:fixed;top:54px;left:0;bottom:0;
width:220px;z-index:300;overflow-y:auto;
transform:translateX(-100%);
transition:transform .25s ease;
box-shadow:none;
}
.sidebar.mobile-open{
transform:translateX(0);
box-shadow:4px 0 28px rgba(0,0,0,.45);
}
.mobile-nav-overlay.show{display:block;}
.nav-hamburger{display:inline-flex;align-items:center;}
}
/* ── 정비사 모바일 탭 바 ── */
.mech-tab-bar{display:none;background:var(--navy2);position:sticky;top:54px;z-index:200;border-bottom:1px solid rgba(255,255,255,.15);}
.mech-tab-bar a{flex:1;display:flex;flex-direction:column;align-items:center;padding:8px 4px 7px;color:rgba(255,255,255,.6);text-decoration:none;font-size:11px;border-bottom:3px solid transparent;transition:all .15s;gap:1px;line-height:1.4;}
.mech-tab-bar a:hover,.mech-tab-bar a.active{color:white;border-bottom-color:var(--accent);background:rgba(255,255,255,.06);}
@media(max-width:768px){.mech-tab-bar{display:flex;}}

View File

@@ -66,7 +66,7 @@ const API = (() => {
post: (path, body) => req('POST', path, body, body instanceof FormData),
put: (path, body) => req('PUT', path, body, body instanceof FormData),
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
delete: (path) => req('DELETE', path),
delete: (path, body) => req('DELETE', path, body ?? null),
download,
};
})();

View File

@@ -33,10 +33,41 @@ const Auth = (() => {
function renderNav(el) {
if (!el) return;
el.innerHTML = `
<button class="nav-hamburger" onclick="Auth.toggleMobileNav()" aria-label="메뉴">☰</button>
<span class="nav-user">
<span>${name()} <small style="color:var(--accent)">[${role()}]</small></span>
<a onclick="Auth.logout()">로그아웃</a>
</span>`;
// 모바일 오버레이 삽입 (중복 방지)
if (!document.getElementById('mobileNavOverlay')) {
const ov = document.createElement('div');
ov.id = 'mobileNavOverlay';
ov.className = 'mobile-nav-overlay';
ov.addEventListener('click', closeMobileNav);
document.body.appendChild(ov);
}
// 사이드바 링크 클릭 시 드로어 닫기
setTimeout(() => {
document.querySelectorAll('.sidebar a').forEach(a => {
a.addEventListener('click', closeMobileNav);
});
}, 0);
}
function toggleMobileNav() {
const sidebar = document.querySelector('.sidebar');
const overlay = document.getElementById('mobileNavOverlay');
if (!sidebar) return;
const opening = !sidebar.classList.contains('mobile-open');
sidebar.classList.toggle('mobile-open', opening);
overlay?.classList.toggle('show', opening);
}
function closeMobileNav() {
document.querySelector('.sidebar')?.classList.remove('mobile-open');
document.getElementById('mobileNavOverlay')?.classList.remove('show');
}
function statusBadge(status) {
@@ -59,5 +90,6 @@ const Auth = (() => {
return new Date(dt).toLocaleString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
}
return { save, token, role, name, uid, logout, require, renderNav, statusBadge, costStatusBadge, fmtDt };
return { save, token, role, name, uid, logout, require, renderNav,
toggleMobileNav, closeMobileNav, statusBadge, costStatusBadge, fmtDt };
})();

View File

@@ -1,4 +1,10 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>계정 관리</title><link rel="stylesheet" href="/css/style.css"></head>
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>계정 관리</title><link rel="stylesheet" href="/css/style.css">
<style>
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
tr.selected td { background:var(--gray2) !important; }
#btnDelete { display:none; }
</style></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
@@ -11,15 +17,40 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html" class="active">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">계정 관리</h2>
<button class="btn btn-primary" onclick="openModal()">+ 계정 생성</button>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 비활성화 (<span id="selCount">0</span>개)</button>
<button class="btn btn-primary" onclick="openModal()">+ 계정 생성</button>
</div>
</div>
<!-- 승인 대기 섹션 -->
<div id="pendingSection" style="display:none;margin-bottom:20px;">
<div class="card" style="border:2px solid #F59E0B;background:#FFFBEB;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
<span style="font-size:18px"></span>
<div>
<div style="font-size:15px;font-weight:700;color:#92400E">가입 승인 대기</div>
<div style="font-size:12px;color:#B45309">승인 후 정비사 계정으로 이용 가능합니다.</div>
</div>
<span id="pendingBadge" style="margin-left:auto;background:#F59E0B;color:white;font-size:12px;font-weight:700;padding:3px 10px;border-radius:10px;"></span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>이름</th><th>아이디</th><th>회사명</th><th>전화번호</th><th>신청일시</th><th style="width:150px">처리</th></tr></thead>
<tbody id="pendingTbody"></tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;">
<select id="fRole" onchange="load()" style="width:auto">
@@ -28,7 +59,10 @@
</select>
</div>
<div class="tbl-wrap"><table>
<thead><tr><th>ID</th><th>아이디</th><th>역할</th><th>이름</th><th>회사/제조사</th><th>전화번호</th><th>상태</th><th>수정</th></tr></thead>
<thead><tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>ID</th><th>아이디</th><th>역할</th><th>이름</th><th>회사/제조사</th><th>전화번호</th><th>상태</th><th>수정</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table></div>
</div>
@@ -70,16 +104,86 @@
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function updateDeleteBtn() {
const checked = document.querySelectorAll('.row-chk:checked');
document.getElementById('selCount').textContent = checked.length;
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
}
function toggleAll(chkAll) {
document.querySelectorAll('.row-chk').forEach(c => {
c.checked = chkAll.checked;
c.closest('tr').classList.toggle('selected', chkAll.checked);
});
updateDeleteBtn();
}
async function bulkDelete() {
const checked = [...document.querySelectorAll('.row-chk:checked')];
if (!checked.length) return;
if (!confirm(`선택한 계정 ${checked.length}개를 비활성화합니다. 계속하시겠습니까?`)) return;
const ids = checked.map(c => parseInt(c.dataset.id));
try { await API.delete('/accounts/bulk', ids); load(); }
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
}
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사'};
async function loadPending() {
const all = await API.get('/accounts');
const pending = all.filter(u => u.is_pending);
const sec = document.getElementById('pendingSection');
sec.style.display = pending.length ? 'block' : 'none';
if (!pending.length) return;
document.getElementById('pendingBadge').textContent = pending.length + '명 대기 중';
document.getElementById('pendingTbody').innerHTML = pending.map(u => `
<tr>
<td><strong>${u.name}</strong></td>
<td style="color:var(--gray4)">${u.username}</td>
<td>${u.company ? `<span style="background:#EFF6FF;color:#1E40AF;font-size:11px;font-weight:600;padding:2px 8px;border-radius:8px">${u.company}</span>` : '<span style="color:var(--gray4)">-</span>'}</td>
<td>${u.phone||'-'}</td>
<td style="font-size:12px">${Auth.fmtDt(u.created_at)}</td>
<td>
<div style="display:flex;gap:6px">
<button class="btn btn-success btn-sm" onclick="approveUser(${u.id},'${u.name}')">✅ 승인</button>
<button class="btn btn-sm" style="background:#fee2e2;color:#991b1b;border:none" onclick="rejectUser(${u.id},'${u.name}')">✕ 거절</button>
</div>
</td>
</tr>`).join('');
}
async function approveUser(id, name) {
if (!confirm(`"${name}" 계정을 승인하시겠습니까?\n승인 후 바로 로그인 가능합니다.`)) return;
try {
await API.patch('/accounts/'+id+'/approve');
loadPending(); load();
} catch(e) { alert('오류: '+e.message); }
}
async function rejectUser(id, name) {
if (!confirm(`"${name}" 가입 신청을 거절하고 계정을 삭제하시겠습니까?`)) return;
try {
await API.delete('/accounts/'+id);
loadPending(); load();
} catch(e) { alert('오류: '+e.message); }
}
async function load() {
const role = document.getElementById('fRole').value;
const users = await API.get('/accounts'+(role?'?role='+role:''));
document.getElementById('tbody').innerHTML = users.map(u=>`
<tr><td>${u.id}</td><td>${u.username}</td><td>${ROLE_LABEL[u.role]||u.role}</td>
<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('');
document.getElementById('chkAll').checked = false;
updateDeleteBtn();
document.getElementById('tbody').innerHTML = users.filter(u => !u.is_pending).map(u=>`
<tr>
<td class="cb-cell" onclick="event.stopPropagation()">
<input type="checkbox" class="row-chk" data-id="${u.id}"
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
</td>
<td>${u.id}</td><td>${u.username}</td><td>${ROLE_LABEL[u.role]||u.role}</td>
<td>${u.name}</td><td>${u.company||'-'}</td><td>${u.phone||'-'}</td>
<td><span class="badge ${u.is_active?'s-done':'s-waiting'}">${u.is_active?'활성':'비활성'}</span></td>
<td><button class="btn btn-outline btn-sm" onclick="editUser(${u.id})">수정</button>
<button class="btn btn-danger btn-sm" onclick="delUser(${u.id})">삭제</button></td>
</tr>`).join('');
}
function openModal() { document.getElementById('modal').classList.remove('hidden'); document.getElementById('eId').value=''; document.getElementById('eUsername').disabled=false; document.getElementById('pwReq').style.display='inline'; }
function closeModal() { document.getElementById('modal').classList.add('hidden'); document.getElementById('modalErr').style.display='none'; ['eUsername','ePassword','eName','eCompany','ePhone','eEmail'].forEach(id=>document.getElementById(id).value=''); }
@@ -118,5 +222,6 @@ async function save() {
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
}
async function delUser(id) { if(!confirm('비활성 처리하시겠습니까?')) return; await API.delete('/accounts/'+id); load(); }
loadPending();
load();
</script></body></html>

View File

@@ -4,6 +4,14 @@
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>충전기 종류 관리</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.err-panel { display:none; margin-top:0; }
.err-panel.show { display:block; }
.err-row-edit input { padding:4px 6px; font-size:12px; border:1px solid var(--gray3); border-radius:5px; }
.err-row-edit input[type=number] { width:60px; }
.err-row-edit input[type=text] { width:90px; }
.err-row-edit input.wide { width:130px; }
</style>
</head>
<body>
<nav class="nav">
@@ -20,6 +28,7 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html" class="active">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
@@ -46,7 +55,7 @@
</div>
</div>
<!-- 목록 -->
<!-- 종류 목록 -->
<div class="card">
<div class="card-title">등록된 충전기 종류</div>
<div class="tbl-wrap">
@@ -57,6 +66,7 @@
<th>종류명</th>
<th>설명</th>
<th>충전기 수</th>
<th>에러코드</th>
<th>수정</th>
<th>삭제</th>
</tr>
@@ -68,6 +78,62 @@
등록된 충전기 종류가 없습니다.
</div>
</div>
<!-- 에러코드 관리 패널 -->
<div class="card err-panel" id="errPanel">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div class="card-title" style="margin:0" id="errPanelTitle">에러코드 관리</div>
<button class="btn btn-outline btn-sm" onclick="closeErrPanel()">✕ 닫기</button>
</div>
<!-- 에러코드 목록 -->
<div class="tbl-wrap" style="margin-bottom:16px">
<table id="errTable">
<thead>
<tr>
<th>코드</th>
<th>에러명</th>
<th>진단조건</th>
<th style="width:60px">자동복구</th>
<th style="width:50px">순서</th>
<th>수정</th>
<th>삭제</th>
</tr>
</thead>
<tbody id="errTbody"></tbody>
</table>
</div>
<div id="errEmpty" class="alert alert-info" style="display:none">등록된 에러코드가 없습니다.</div>
<!-- 에러코드 추가 폼 -->
<div style="background:var(--gray1);border-radius:8px;padding:14px">
<div style="font-size:13px;font-weight:700;color:var(--navy2);margin-bottom:10px"> 에러코드 추가</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-bottom:8px">
<div>
<label style="font-size:11px;color:var(--gray4)">코드 *</label>
<input type="text" id="newCode" placeholder="12200" style="width:100%;box-sizing:border-box">
</div>
<div>
<label style="font-size:11px;color:var(--gray4)">에러명 *</label>
<input type="text" id="newName" placeholder="과전압" style="width:100%;box-sizing:border-box">
</div>
<div>
<label style="font-size:11px;color:var(--gray4)">진단조건</label>
<input type="text" id="newCond" placeholder=">275V" style="width:100%;box-sizing:border-box">
</div>
<div>
<label style="font-size:11px;color:var(--gray4)">표시순서</label>
<input type="number" id="newOrder" value="0" style="width:100%;box-sizing:border-box">
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<input type="checkbox" id="newAutoRecovery" checked style="width:auto;accent-color:var(--accent)">
<label for="newAutoRecovery" style="font-size:13px">자동복구 가능</label>
</div>
<div id="errFormErr" class="alert alert-danger" style="display:none;margin-bottom:8px"></div>
<button class="btn btn-primary btn-sm" onclick="addError()">추가</button>
</div>
</div>
</div>
</div>
@@ -77,6 +143,9 @@
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
let selectedTypeId = null;
let selectedTypeName = '';
async function load() {
const types = await API.get('/chargers/types');
const tbody = document.getElementById('tbody');
@@ -87,6 +156,11 @@ async function load() {
<td><strong>${t.name}</strong></td>
<td>${t.description || '-'}</td>
<td>${t.charger_count}개</td>
<td>
<button class="btn btn-outline btn-sm" onclick="openErrPanel(${t.id}, '${escQ(t.name)}')">
📋 에러코드
</button>
</td>
<td>
<button class="btn btn-outline btn-sm" onclick="startEdit(${t.id}, '${escQ(t.name)}', '${escQ(t.description||'')}')">
수정
@@ -101,10 +175,105 @@ async function load() {
}
function escQ(str) {
return str.replace(/'/g, "\\'").replace(/"/g, '&quot;');
return String(str).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;');
}
function escH(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ── 수정 모드 진입 ── */
/* ── 에러코드 패널 ── */
async function openErrPanel(typeId, typeName) {
selectedTypeId = typeId;
selectedTypeName = typeName;
document.getElementById('errPanelTitle').textContent = `에러코드 관리 — ${typeName}`;
document.getElementById('errPanel').classList.add('show');
document.getElementById('errPanel').scrollIntoView({ behavior: 'smooth', block: 'start' });
await loadErrors();
}
function closeErrPanel() {
document.getElementById('errPanel').classList.remove('show');
selectedTypeId = null;
}
async function loadErrors() {
const errors = await API.get(`/chargers/types/${selectedTypeId}/errors`);
const tbody = document.getElementById('errTbody');
document.getElementById('errEmpty').style.display = errors.length ? 'none' : 'block';
tbody.innerHTML = errors.map(e => renderErrorRow(e)).join('');
}
function renderErrorRow(e) {
return `
<tr id="err-row-${e.id}">
<td><strong>${escH(e.error_code)}</strong></td>
<td>${escH(e.error_name)}</td>
<td style="font-size:12px;color:var(--gray4)">${escH(e.range_condition||'')}</td>
<td style="text-align:center">${e.auto_recovery ? '✅' : '❌'}</td>
<td style="text-align:center">${e.display_order}</td>
<td><button class="btn btn-outline btn-sm" onclick="startEditError(${e.id}, '${escQ(e.error_code)}', '${escQ(e.error_name)}', '${escQ(e.range_condition||'')}', ${e.auto_recovery}, ${e.display_order})">수정</button></td>
<td><button class="btn btn-danger btn-sm" onclick="deleteError(${e.id})">삭제</button></td>
</tr>`;
}
function startEditError(id, code, name, cond, autoRec, order) {
document.getElementById(`err-row-${id}`).outerHTML = `
<tr id="err-row-${id}" class="err-row-edit">
<td><input type="text" id="ec-code-${id}" value="${escH(code)}" placeholder="코드"></td>
<td><input type="text" class="wide" id="ec-name-${id}" value="${escH(name)}" placeholder="에러명"></td>
<td><input type="text" class="wide" id="ec-cond-${id}" value="${escH(cond)}" placeholder="진단조건"></td>
<td style="text-align:center">
<input type="checkbox" id="ec-auto-${id}" ${autoRec?'checked':''} style="width:auto;accent-color:var(--accent)">
</td>
<td><input type="number" id="ec-order-${id}" value="${order}"></td>
<td><button class="btn btn-primary btn-sm" onclick="saveEditError(${id})">저장</button></td>
<td><button class="btn btn-outline btn-sm" onclick="loadErrors()">취소</button></td>
</tr>`;
}
async function saveEditError(id) {
const fd = new FormData();
fd.append('error_code', document.getElementById(`ec-code-${id}`).value.trim());
fd.append('error_name', document.getElementById(`ec-name-${id}`).value.trim());
fd.append('range_condition',document.getElementById(`ec-cond-${id}`).value.trim());
fd.append('auto_recovery', document.getElementById(`ec-auto-${id}`).checked);
fd.append('display_order', document.getElementById(`ec-order-${id}`).value);
if (!fd.get('error_code') || !fd.get('error_name')) { alert('코드와 에러명은 필수입니다.'); return; }
await API.put(`/chargers/types/${selectedTypeId}/errors/${id}`, fd);
await loadErrors();
}
async function deleteError(id) {
if (!confirm('에러코드를 삭제하시겠습니까?')) return;
await API.delete(`/chargers/types/${selectedTypeId}/errors/${id}`);
await loadErrors();
}
async function addError() {
const code = document.getElementById('newCode').value.trim();
const name = document.getElementById('newName').value.trim();
const cond = document.getElementById('newCond').value.trim();
const order = document.getElementById('newOrder').value;
const auto = document.getElementById('newAutoRecovery').checked;
const errEl = document.getElementById('errFormErr');
errEl.style.display = 'none';
if (!code || !name) { errEl.textContent = '코드와 에러명은 필수입니다.'; errEl.style.display = 'block'; return; }
const fd = new FormData();
fd.append('error_code', code); fd.append('error_name', name);
fd.append('range_condition', cond); fd.append('auto_recovery', auto);
fd.append('display_order', order);
try {
await API.post(`/chargers/types/${selectedTypeId}/errors`, fd);
document.getElementById('newCode').value = '';
document.getElementById('newName').value = '';
document.getElementById('newCond').value = '';
document.getElementById('newOrder').value = '0';
document.getElementById('newAutoRecovery').checked = true;
await loadErrors();
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
}
/* ── 종류 수정 모드 진입 ── */
function startEdit(id, name, desc) {
document.getElementById('editId').value = id;
document.getElementById('typeName').value = name;
@@ -114,12 +283,10 @@ function startEdit(id, name, desc) {
document.getElementById('submitBtn').className = 'btn btn-accent';
document.getElementById('cancelBtn').style.display = 'inline-flex';
document.getElementById('formErr').style.display = 'none';
// 폼으로 스크롤
document.getElementById('typeName').focus();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/* ── 수정 취소 ── */
function cancelEdit() {
document.getElementById('editId').value = '';
document.getElementById('typeName').value = '';
@@ -131,47 +298,26 @@ function cancelEdit() {
document.getElementById('formErr').style.display = 'none';
}
/* ── 추가 / 수정 공통 제출 ── */
async function submitForm() {
const id = document.getElementById('editId').value;
const name = document.getElementById('typeName').value.trim();
const desc = document.getElementById('typeDesc').value.trim();
const errEl = document.getElementById('formErr');
errEl.style.display = 'none';
if (!name) {
errEl.textContent = '종류명을 입력하세요.';
errEl.style.display = 'block';
return;
}
if (!name) { errEl.textContent = '종류명을 입력하세요.'; errEl.style.display = 'block'; return; }
const fd = new FormData();
fd.append('name', name);
fd.append('description', desc);
fd.append('name', name); fd.append('description', desc);
try {
if (id) {
await API.put('/chargers/types/' + id, fd);
} else {
await API.post('/chargers/types', fd);
}
cancelEdit();
load();
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
if (id) { await API.put('/chargers/types/' + id, fd); }
else { await API.post('/chargers/types', fd); }
cancelEdit(); load();
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
}
/* ── 삭제 ── */
async function del(id) {
if (!confirm('삭제하시겠습니까?')) return;
try {
await API.delete('/chargers/types/' + id);
load();
} catch(e) {
alert(e.message);
}
try { await API.delete('/chargers/types/' + id); load(); }
catch(e) { alert(e.message); }
}
load();

View File

@@ -4,6 +4,37 @@
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>충전기 관리</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
tr.selected td { background:var(--gray2) !important; }
#btnDelete { display:none; }
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
.view-btn { padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white; cursor:pointer; color:var(--gray4); transition:all .15s; }
.view-btn.active { background:var(--navy); color:white; }
#mapWrap {
display:none;
height: calc(100vh - 220px);
min-height: 420px;
border-radius:10px;
overflow:hidden;
border:1px solid var(--border);
margin-top:12px;
isolation: isolate;
}
#chargerMap { width:100%; height:100%; }
.ck-pin {
width:28px; height:28px; border-radius:50% 50% 50% 0;
transform:rotate(-45deg); border:3px solid white;
box-shadow:0 2px 6px rgba(0,0,0,.35);
}
.ck-pin.fault { background:#EF4444; }
.ck-pin.normal { background:#22C55E; }
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
@@ -17,23 +48,54 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html" class="active">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">충전기 관리</h2>
<button class="btn btn-primary" onclick="openModal()">+ 충전기 등록</button>
<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>
<div class="card">
<!-- 목록 뷰 -->
<div id="listWrap" class="card">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap">
<input type="text" id="searchInput" placeholder="충전기ID / 충전소명 / CPO 검색..." style="flex:1;min-width:180px;padding:7px 10px;border:1px solid var(--gray3);border-radius:7px;font-size:13px;outline:none">
<select id="filterFault" onchange="renderTable()" style="width:auto">
<option value="">전체</option>
<option value="fault">미처리 있음</option>
<option value="ok">정상</option>
</select>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th></tr></thead>
<thead><tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<!-- 지도 뷰 -->
<div id="mapWrap">
<div id="chargerMap"></div>
</div>
<div id="mapLegend" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:16px;flex-wrap:wrap">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>미처리 신고 있음</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#22C55E;margin-right:4px"></span>정상</span>
<span id="noGpsNote" style="color:var(--gray4)"></span>
</div>
</div>
</div>
@@ -65,17 +127,89 @@
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
let types = [], isEdit = false;
let allChargers = [];
let types = [];
let curView = 'list';
let chargerMap = null;
let mapMarkers = [];
// ── 뷰 전환 ──
function setView(v) {
curView = v;
document.getElementById('btnList').classList.toggle('active', v === 'list');
document.getElementById('btnMap').classList.toggle('active', v === 'map');
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
document.getElementById('mapLegend').style.display = v === 'map' ? 'flex' : 'none';
document.getElementById('btnDelete').style.display = v === 'map' ? 'none' :
(document.querySelectorAll('.row-chk:checked').length > 0 ? 'inline-flex' : 'none');
if (v === 'map') {
initChargerMap();
renderChargerMap();
setTimeout(() => chargerMap && chargerMap.invalidateSize(), 50);
}
}
function updateDeleteBtn() {
const checked = document.querySelectorAll('.row-chk:checked');
document.getElementById('selCount').textContent = checked.length;
document.getElementById('btnDelete').style.display =
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
}
function toggleAll(chkAll) {
document.querySelectorAll('.row-chk').forEach(c => {
c.checked = chkAll.checked;
c.closest('tr').classList.toggle('selected', chkAll.checked);
});
updateDeleteBtn();
}
async function bulkDelete() {
const checked = [...document.querySelectorAll('.row-chk:checked')];
if (!checked.length) return;
if (!confirm(`선택한 충전기 ${checked.length}대를 삭제합니다. 신고 내역이 있는 충전기는 삭제되지 않습니다. 계속하시겠습니까?`)) return;
const ids = checked.map(c => c.dataset.id);
try { await API.delete('/chargers/bulk', ids); load(); }
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
}
// ── 데이터 로드 ──
async function load() {
[types] = await Promise.all([API.get('/chargers/types')]);
const chargers = await API.get('/chargers');
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 +221,85 @@ async function load() {
<td><button class="btn btn-outline btn-sm" onclick="editCharger('${c.id}')">수정</button></td>
</tr>`).join('');
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('searchInput').addEventListener('input', renderTable);
});
function openModal(id=null) { isEdit=!!id; document.getElementById('modal').classList.remove('hidden'); document.getElementById('modalTitle').textContent = id?'충전기 수정':'충전기 등록'; }
// ── 지도 초기화 ──
function initChargerMap() {
if (chargerMap) return;
chargerMap = L.map('chargerMap', { zoomControl: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(chargerMap);
}
// ── 지도 마커 렌더 ──
function renderChargerMap() {
if (!chargerMap) return;
mapMarkers.forEach(m => m.remove());
mapMarkers = [];
const visible = allChargers.filter(c => c.gps_lat && c.gps_lng);
const noGps = allChargers.length - visible.length;
document.getElementById('noGpsNote').textContent =
noGps ? `📍 GPS 미등록 ${noGps}대 미표시` : '';
if (!visible.length) {
chargerMap.setView([36.5, 127.8], 7);
return;
}
visible.forEach(c => {
const hasFault = c.pending_reports > 0;
const icon = L.divIcon({
className: '',
html: `<div class="ck-pin ${hasFault ? 'fault' : 'normal'}"></div>`,
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
});
const popup = `
<div style="min-width:200px">
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">⚡ ${c.id}</div>
<div style="font-size:12px;color:#6b7280;margin-bottom:8px;line-height:1.6">
📍 ${c.station_name}${c.location_detail ? '<br>' + c.location_detail : ''}
${c.charger_type ? '<br>종류: ' + c.charger_type : ''}
${c.cpo_name ? '<br>CPO: ' + c.cpo_name : ''}
</div>
<div style="margin-bottom:10px">
<span class="badge ${hasFault ? 's-pending' : 's-done'}" style="font-size:12px">${hasFault ? '⚠ 미처리 ' + c.pending_reports + '건' : '✅ 정상'}</span>
</div>
<div style="display:flex;gap:6px">
${hasFault
? `<a href="/pages/admin/reports.html?charger_id=${c.id}" style="flex:1;text-align:center;background:#EF4444;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none">📋 신고 보기</a>`
: ''}
<button onclick="editCharger('${c.id}')" style="flex:1;background:#1e3a5f;color:white;padding:6px 0;border-radius:6px;font-size:12px;font-weight:600;border:none;cursor:pointer">✏ 수정</button>
</div>
</div>`;
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
.addTo(chargerMap)
.bindPopup(popup, { maxWidth: 260 });
mapMarkers.push(m);
});
const bounds = L.latLngBounds(visible.map(c => [c.gps_lat, c.gps_lng]));
chargerMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
if (visible.length === 1) chargerMap.setZoom(14);
}
// ── 모달 ──
function openModal(id=null) {
document.getElementById('modal').classList.remove('hidden');
document.getElementById('modalTitle').textContent = id ? '충전기 수정' : '충전기 등록';
}
function closeModal() { document.getElementById('modal').classList.add('hidden'); clearForm(); }
function clearForm() { ['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id=>document.getElementById(id).value=''); document.getElementById('modalErr').style.display='none'; }
function clearForm() {
['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id => document.getElementById(id).value = '');
document.getElementById('fId').disabled = false;
document.getElementById('modalErr').style.display = 'none';
}
async function editCharger(id) {
const c = await API.get('/chargers/'+id);
@@ -125,6 +334,7 @@ async function save() {
closeModal(); load();
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
}
load();
</script>
</body>

View File

@@ -4,6 +4,12 @@
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>출장비 관리</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
tr.selected td { background:var(--gray2) !important; }
#btnDelete { display:none; }
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
@@ -17,6 +23,7 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
@@ -24,7 +31,10 @@
<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>
<button class="btn btn-success btn-sm" onclick="API.download('/export/costs','출장비목록.xlsx')">📥 엑셀 다운로드</button>
<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">
@@ -48,7 +58,10 @@
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th></tr></thead>
<thead><tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
@@ -59,6 +72,28 @@
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function updateDeleteBtn() {
const checked = document.querySelectorAll('.row-chk:checked');
document.getElementById('selCount').textContent = checked.length;
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
}
function toggleAll(chkAll) {
document.querySelectorAll('.row-chk').forEach(c => {
c.checked = chkAll.checked;
c.closest('tr').classList.toggle('selected', chkAll.checked);
});
updateDeleteBtn();
}
async function bulkDelete() {
const checked = [...document.querySelectorAll('.row-chk:checked')];
if (!checked.length) return;
if (!confirm(`선택한 출장비 내역 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
const ids = checked.map(c => parseInt(c.dataset.id));
try { await API.delete('/costs/bulk', ids); load(); }
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
async function load() {
@@ -67,17 +102,23 @@ async function load() {
<div class="stat"><div class="stat-num">${statsData.monthly_total.toLocaleString()}</div><div class="stat-label">이달 출장비 합계(원)</div></div>
<div class="stat danger"><div class="stat-num">${statsData.pending_count}</div><div class="stat-label">미처리 건수</div></div>`;
const tbody = document.getElementById('tbody');
document.getElementById('chkAll').checked = false;
updateDeleteBtn();
document.getElementById('empty').style.display = costs.length ? 'none' : 'block';
tbody.innerHTML = costs.map(c => `
<tr onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'">
<td>${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
<td>${c.charger_id||'-'}</td>
<td>${c.station_name||'-'}</td>
<td>${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
<td>${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td>
<td style="font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}</td>
<td>${Auth.costStatusBadge(c.cost_status)}</td>
<td>${Auth.fmtDt(c.reviewed_at)}</td>
<tr>
<td class="cb-cell" onclick="event.stopPropagation()">
<input type="checkbox" class="row-chk" data-id="${c.id}"
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.charger_id||'-'}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.station_name||'-'}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer;font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${Auth.costStatusBadge(c.cost_status)}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||}'" style="cursor:pointer">${Auth.fmtDt(c.reviewed_at)}</td>
</tr>`).join('');
}
load();

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,28 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="main" style="max-width:860px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">개선항목 상세</h2>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">개선항목 상세</h2>
</div>
<div id="content"></div>
</div>
<div id="content"></div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
@@ -99,4 +115,4 @@ async function changeStatus() {
load();
}
load();
</script></body></html>
</script></div></div></body></html>

View File

@@ -1,4 +1,10 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 관리</title><link rel="stylesheet" href="/css/style.css"></head>
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 관리</title><link rel="stylesheet" href="/css/style.css">
<style>
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
tr.selected td { background:var(--gray2) !important; }
#btnDelete { display:none; }
</style></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
@@ -11,6 +17,7 @@
<a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
@@ -18,7 +25,8 @@
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">개선항목 관리</h2>
<div style="display:flex;gap:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
<button class="btn btn-success btn-sm" onclick="API.download('/export/improvements','개선항목목록.xlsx')">📥 엑셀</button>
<button class="btn btn-primary" onclick="openModal()">+ 개선항목 등록</button>
</div>
@@ -35,7 +43,10 @@
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</div>
<div class="tbl-wrap"><table>
<thead><tr><th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th></tr></thead>
<thead><tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table></div>
<div id="empty" class="alert alert-info" style="display:none">등록된 개선항목이 없습니다.</div>
@@ -90,6 +101,28 @@
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function updateDeleteBtn() {
const checked = document.querySelectorAll('.row-chk:checked');
document.getElementById('selCount').textContent = checked.length;
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
}
function toggleAll(chkAll) {
document.querySelectorAll('.row-chk').forEach(c => {
c.checked = chkAll.checked;
c.closest('tr').classList.toggle('selected', chkAll.checked);
});
updateDeleteBtn();
}
async function bulkDelete() {
const checked = [...document.querySelectorAll('.row-chk:checked')];
if (!checked.length) return;
if (!confirm(`선택한 개선항목 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
const ids = checked.map(c => parseInt(c.dataset.id));
try { await API.delete('/improvements/bulk', ids); load(); }
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
const selectedReports = new Set();
@@ -105,18 +138,24 @@ async function load() {
if (mfrSel.options.length <= 1)
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=`${m.company||''} / ${m.name}`; mfrSel.appendChild(o); });
document.getElementById('chkAll').checked = false;
updateDeleteBtn();
document.getElementById('empty').style.display = imps.length ? 'none' : 'block';
document.getElementById('tbody').innerHTML = imps.map(i => `
<tr onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'">
<td>#${i.id}</td>
<td style="max-width:200px"><strong>${i.title}</strong></td>
<td>${CAT[i.category]||i.category}</td>
<td>${PRI[i.priority]||i.priority}</td>
<td>${i.manufacturer_company||'-'}<br><small>${i.manufacturer_name||''}</small></td>
<td><span class="badge s-pending">${i.report_count}</span></td>
<td>${Auth.statusBadge(i.status)}</td>
<td>${Auth.fmtDt(i.created_at)}</td>
<td>${i.sw_deployed_at||'-'}</td>
<tr>
<td class="cb-cell" onclick="event.stopPropagation()">
<input type="checkbox" class="row-chk" data-id="${i.id}"
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">#${i.id}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer;max-width:200px"><strong>${i.title}</strong></td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${CAT[i.category]||i.category}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${PRI[i.priority]||i.priority}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.manufacturer_company||'-'}<br><small>${i.manufacturer_name||''}</small></td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer"><span class="badge s-pending">${i.report_count}건</span></td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.statusBadge(i.status)}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.fmtDt(i.created_at)}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.sw_deployed_at||'-'}</td>
</tr>`).join('');
}

View File

@@ -0,0 +1,441 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>유형관리</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--gray2); margin-bottom:20px; }
.tab-btn {
padding:9px 22px; font-size:14px; font-weight:600; border:none; background:none;
cursor:pointer; color:var(--gray4); border-bottom:3px solid transparent; margin-bottom:-2px;
transition:color .15s, border-color .15s;
}
.tab-btn.active { color:var(--navy); border-bottom-color:var(--accent); }
.tab-pane { display:none; }
.tab-pane.active { display:block; }
/* 유형 공통 */
.type-list { border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.type-row {
display:grid; grid-template-columns:36px 1fr 1fr 80px;
align-items:center; gap:10px; padding:10px 14px;
border-bottom:1px solid var(--border); background:#fff;
}
.type-row:last-child { border-bottom:none; }
.type-row.header { background:var(--gray1); font-size:11px; font-weight:700; color:var(--gray4); text-transform:uppercase; letter-spacing:.5px; }
.type-row input[type=text] { font-size:13px; padding:5px 8px; border:1px solid var(--border); border-radius:5px; width:100%; }
.order-btns { display:flex; flex-direction:column; gap:2px; }
.order-btn { background:none; border:1px solid var(--border); border-radius:3px; padding:0 6px; font-size:11px; cursor:pointer; color:var(--gray4); line-height:18px; }
.order-btn:hover { background:var(--gray1); color:var(--text); }
.del-btn { background:none; border:none; color:#e53e3e; font-size:18px; cursor:pointer; padding:0 4px; }
.del-btn:hover { color:#c53030; }
.add-row { display:grid; grid-template-columns:36px 1fr 1fr 80px; gap:10px; align-items:center; padding:12px 14px; background:#f9faff; border-top:2px dashed var(--blue); border-radius:0 0 8px 8px; }
.add-row input[type=text] { font-size:13px; padding:5px 8px; border:1px solid var(--border); border-radius:5px; width:100%; }
.hint { font-size:11px; color:var(--gray4); margin-top:4px; }
/* 제조사 테이블 */
.mfr-table { width:100%; border-collapse:collapse; font-size:13px; }
.mfr-table th { background:var(--gray1); color:var(--gray4); font-size:11px; font-weight:700; padding:8px 10px; text-align:left; border-bottom:1px solid var(--border); }
.mfr-table td { padding:9px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
.mfr-table tr:last-child td { border-bottom:none; }
.mfr-table tr:hover td { background:#f8faff; }
.badge-active { background:#D1FAE5; color:#065F46; font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; }
.badge-inactive { background:#FEE2E2; color:#991B1B; font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; }
/* 제조사 추가/편집 모달 */
.mfr-modal-bg { display:none; position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:200; align-items:center; justify-content:center; }
.mfr-modal-bg.open { display:flex; }
.mfr-modal { background:white; border-radius:12px; width:480px; max-width:calc(100vw - 32px); padding:28px 28px 22px; box-shadow:0 8px 32px rgba(0,0,0,.2); }
.mfr-modal h3 { font-size:16px; font-weight:700; color:var(--navy); margin-bottom:18px; }
.mfr-field { margin-bottom:12px; }
.mfr-field label { display:block; font-size:12px; font-weight:600; color:var(--navy2); margin-bottom:4px; }
.mfr-field input, .mfr-field textarea {
width:100%; padding:8px 10px; border:1px solid var(--gray3); border-radius:7px;
font-size:13px; box-sizing:border-box; font-family:inherit; outline:none;
}
.mfr-field input:focus, .mfr-field textarea:focus { border-color:var(--accent); }
.mfr-field .opt { font-size:11px; color:var(--gray4); font-weight:400; margin-left:4px; }
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html" class="active">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">📝 유형관리</h2>
</div>
<!---->
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('issue')">📋 문제유형</button>
<button class="tab-btn" onclick="switchTab('repair')">🔧 조치유형</button>
<button class="tab-btn" onclick="switchTab('mfr')">🏢 제조사</button>
</div>
<!-- ── 문제유형 탭 ── -->
<div class="tab-pane active" id="pane-issue">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<p style="font-size:13px;color:var(--text2);margin:0">신고 접수 시 사용자가 선택하는 문제 유형입니다. 저장키는 기존 신고 데이터와 연결되므로 수정 시 주의하세요.</p>
<button class="btn btn-primary btn-sm" style="margin-left:16px;white-space:nowrap" onclick="saveIssue()">💾 저장</button>
</div>
<div class="type-list" id="issueList"></div>
<div class="add-row">
<span style="text-align:center;font-size:18px;color:var(--blue)"></span>
<div>
<input type="text" id="iNewLabel" placeholder="표시명 예) ⚡ 충전 불가">
<div class="hint">신고 화면에 보이는 이름 (이모지 포함 가능)</div>
</div>
<div>
<input type="text" id="iNewKey" placeholder="저장키 예) 충전불가">
<div class="hint">공백 없는 한글/영문 권장</div>
</div>
<div style="text-align:center"><button class="btn btn-outline btn-sm" onclick="addIssueRow()">추가</button></div>
</div>
<div id="issueSaveMsg" style="display:none;margin-top:14px"></div>
</div>
<div class="card" style="margin-top:18px">
<div class="card-title">👁 신고 화면 미리보기</div>
<div id="issuePreview" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:420px"></div>
</div>
</div>
<!-- ── 조치유형 탭 ── -->
<div class="tab-pane" id="pane-repair">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<p style="font-size:13px;color:var(--text2);margin:0">정비사가 조치 입력 시 선택하는 조치 유형입니다. 저장키는 기존 조치 기록과 연결되므로 수정 시 주의하세요.</p>
<button class="btn btn-primary btn-sm" style="margin-left:16px;white-space:nowrap" onclick="saveRepair()">💾 저장</button>
</div>
<div class="type-list" id="repairList"></div>
<div class="add-row">
<span style="text-align:center;font-size:18px;color:var(--blue)"></span>
<div>
<input type="text" id="rNewLabel" placeholder="표시명 예) 🔩 부품 교체">
<div class="hint">조치 화면에 보이는 이름 (이모지 포함 가능)</div>
</div>
<div>
<input type="text" id="rNewKey" placeholder="저장키 예) 부품교체">
<div class="hint">공백 없는 한글/영문 권장</div>
</div>
<div style="text-align:center"><button class="btn btn-outline btn-sm" onclick="addRepairRow()">추가</button></div>
</div>
<div id="repairSaveMsg" style="display:none;margin-top:14px"></div>
</div>
<div class="card" style="margin-top:18px">
<div class="card-title">👁 조치 화면 미리보기</div>
<div id="repairPreview" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:420px"></div>
</div>
</div>
<!-- ── 제조사 탭 ── -->
<div class="tab-pane" id="pane-mfr">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<p style="font-size:13px;color:var(--text2);margin:0">
개선항목·출장비에 연결하거나 정비사 가입 시 선택할 수 있는 회사 목록입니다.
</p>
<button class="btn btn-primary btn-sm" style="white-space:nowrap;margin-left:16px" onclick="openMfrModal()"> 제조사 등록</button>
</div>
<div class="tbl-wrap">
<table class="mfr-table" id="mfrTable">
<thead>
<tr>
<th>회사명</th><th>대표자명</th><th>사업자번호</th><th>대표전화</th><th>주소</th><th>상태</th><th style="width:90px">관리</th>
</tr>
</thead>
<tbody id="mfrTbody"></tbody>
</table>
</div>
<div id="mfrEmpty" class="alert alert-info" style="display:none;margin-top:12px">등록된 제조사가 없습니다.</div>
</div>
</div>
</div>
</div>
<!-- 제조사 등록/편집 모달 -->
<div class="mfr-modal-bg" id="mfrModal">
<div class="mfr-modal">
<h3 id="mfrModalTitle">제조사 등록</h3>
<input type="hidden" id="mfrEditId">
<div class="mfr-field">
<label>회사명 <span style="color:var(--red)">*</span></label>
<input type="text" id="mfrName" placeholder="예) (주)한국EV">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div class="mfr-field">
<label>대표자명 <span class="opt">(선택)</span></label>
<input type="text" id="mfrRep" placeholder="예) 홍길동">
</div>
<div class="mfr-field">
<label>사업자번호 <span class="opt">(선택)</span></label>
<input type="text" id="mfrBiz" placeholder="예) 123-45-67890">
</div>
<div class="mfr-field">
<label>대표전화 <span class="opt">(선택)</span></label>
<input type="tel" id="mfrPhone" placeholder="예) 02-1234-5678">
</div>
</div>
<div class="mfr-field">
<label>주소 <span class="opt">(선택)</span></label>
<input type="text" id="mfrAddr" placeholder="예) 서울시 강남구 ...">
</div>
<div class="mfr-field" id="mfrActiveField" style="display:none">
<label>상태</label>
<select id="mfrActive" style="width:auto;padding:7px 10px;border:1px solid var(--gray3);border-radius:7px;font-size:13px">
<option value="true">활성</option>
<option value="false">비활성</option>
</select>
</div>
<div id="mfrModalErr" class="alert alert-danger" style="display:none;margin-top:10px"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:18px">
<button class="btn btn-outline" onclick="closeMfrModal()">취소</button>
<button class="btn btn-primary" onclick="saveMfr()">저장</button>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
let issueTypes = [];
let repairTypes = [];
let manufacturers = [];
// ── 탭 전환 ──
const TAB_NAMES = ['issue','repair','mfr'];
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach((b, i) =>
b.classList.toggle('active', TAB_NAMES[i] === name));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.getElementById('pane-' + name).classList.add('active');
}
// ── 유형 공통 렌더러 ──
function renderList(listId, types, varName, moveFn, delFn, previewFn) {
const header = `
<div class="type-row header">
<span style="text-align:center">순서</span><span>표시명</span><span>저장키</span><span style="text-align:center">삭제</span>
</div>`;
const rows = types.map((t, i) => `
<div class="type-row">
<div class="order-btns">
<button class="order-btn" onclick="${moveFn}(${i},-1)" ${i===0?'disabled':''}>▲</button>
<button class="order-btn" onclick="${moveFn}(${i},1)" ${i===types.length-1?'disabled':''}>▼</button>
</div>
<input type="text" value="${esc(t.label)}" onchange="${varName}[${i}].label=this.value;${previewFn}()">
<input type="text" value="${esc(t.key)}" onchange="${varName}[${i}].key=this.value">
<div style="text-align:center"><button class="del-btn" onclick="${delFn}(${i})">×</button></div>
</div>`).join('');
document.getElementById(listId).innerHTML = header + rows;
}
function renderPreview(previewId, types) {
document.getElementById(previewId).innerHTML = types.map(t => `
<label style="border:1.5px solid var(--border);border-radius:8px;padding:8px 12px;font-size:13px;
display:flex;align-items:center;gap:8px;background:#fff;cursor:pointer;">
<input type="checkbox" style="accent-color:var(--accent)">${esc(t.label)}
</label>`).join('');
}
// ── 문제유형 ──
function renderIssue() {
renderList('issueList', issueTypes, 'issueTypes', 'moveIssue', 'delIssue', 'renderIssuePreview');
renderIssuePreview();
}
function renderIssuePreview() { renderPreview('issuePreview', issueTypes); }
function moveIssue(idx, dir) {
const t = idx+dir; if(t<0||t>=issueTypes.length) return;
[issueTypes[idx],issueTypes[t]]=[issueTypes[t],issueTypes[idx]]; renderIssue();
}
function delIssue(idx) {
if(!confirm(`"${issueTypes[idx].label}" 유형을 삭제하시겠습니까?\n기존 신고 기록에는 저장키가 남습니다.`)) return;
issueTypes.splice(idx,1); renderIssue();
}
function addIssueRow() {
const label=document.getElementById('iNewLabel').value.trim();
const key=document.getElementById('iNewKey').value.trim();
if(!label||!key){alert('표시명과 저장키를 모두 입력하세요.');return;}
if(issueTypes.some(t=>t.key===key)){alert(`저장키 "${key}"가 이미 존재합니다.`);return;}
issueTypes.push({key,label});
document.getElementById('iNewLabel').value=''; document.getElementById('iNewKey').value='';
renderIssue();
}
async function saveIssue() {
if(!issueTypes.length){alert('최소 1개 이상 필요합니다.');return;}
if(issueTypes.find(t=>!t.key.trim()||!t.label.trim())){alert('빈 항목이 있습니다.');return;}
try{await API.put('/settings/issue-types',issueTypes);showMsg('issueSaveMsg');}
catch(e){alert('저장 실패: '+e.message);}
}
// ── 조치유형 ──
function renderRepair() {
renderList('repairList', repairTypes, 'repairTypes', 'moveRepair', 'delRepair', 'renderRepairPreview');
renderRepairPreview();
}
function renderRepairPreview() { renderPreview('repairPreview', repairTypes); }
function moveRepair(idx, dir) {
const t=idx+dir; if(t<0||t>=repairTypes.length) return;
[repairTypes[idx],repairTypes[t]]=[repairTypes[t],repairTypes[idx]]; renderRepair();
}
function delRepair(idx) {
if(!confirm(`"${repairTypes[idx].label}" 유형을 삭제하시겠습니까?\n기존 조치 기록에는 저장키가 남습니다.`)) return;
repairTypes.splice(idx,1); renderRepair();
}
function addRepairRow() {
const label=document.getElementById('rNewLabel').value.trim();
const key=document.getElementById('rNewKey').value.trim();
if(!label||!key){alert('표시명과 저장키를 모두 입력하세요.');return;}
if(repairTypes.some(t=>t.key===key)){alert(`저장키 "${key}"가 이미 존재합니다.`);return;}
repairTypes.push({key,label});
document.getElementById('rNewLabel').value=''; document.getElementById('rNewKey').value='';
renderRepair();
}
async function saveRepair() {
if(!repairTypes.length){alert('최소 1개 이상 필요합니다.');return;}
if(repairTypes.find(t=>!t.key.trim()||!t.label.trim())){alert('빈 항목이 있습니다.');return;}
try{await API.put('/settings/repair-types',repairTypes);showMsg('repairSaveMsg');}
catch(e){alert('저장 실패: '+e.message);}
}
// ── 제조사 ──
function renderMfr() {
const tbody = document.getElementById('mfrTbody');
document.getElementById('mfrEmpty').style.display = manufacturers.length ? 'none' : 'block';
if (!manufacturers.length) { tbody.innerHTML = ''; return; }
tbody.innerHTML = manufacturers.map(m => `
<tr>
<td><strong>${esc(m.name)}</strong></td>
<td style="color:var(--text2)">${esc(m.representative_name||'-')}</td>
<td style="color:var(--gray4);font-size:12px">${esc(m.business_number||'-')}</td>
<td>${esc(m.phone||'-')}</td>
<td style="font-size:12px;color:var(--text2);max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(m.address||'-')}</td>
<td><span class="${m.is_active?'badge-active':'badge-inactive'}">${m.is_active?'활성':'비활성'}</span></td>
<td>
<div style="display:flex;gap:4px">
<button class="btn btn-outline btn-sm" style="font-size:11px" onclick="editMfr(${m.id})">편집</button>
<button class="btn btn-sm" style="font-size:11px;background:#fee2e2;color:#991b1b;border:none"
onclick="deleteMfr(${m.id},'${esc(m.name)}')">삭제</button>
</div>
</td>
</tr>`).join('');
}
function openMfrModal(id) {
document.getElementById('mfrEditId').value = id || '';
document.getElementById('mfrModalTitle').textContent = id ? '제조사 편집' : '제조사 등록';
document.getElementById('mfrActiveField').style.display = id ? 'block' : 'none';
document.getElementById('mfrModalErr').style.display = 'none';
if (!id) {
['mfrName','mfrRep','mfrBiz','mfrPhone','mfrAddr'].forEach(i => document.getElementById(i).value = '');
document.getElementById('mfrActive').value = 'true';
}
document.getElementById('mfrModal').classList.add('open');
}
function closeMfrModal() { document.getElementById('mfrModal').classList.remove('open'); }
function editMfr(id) {
const m = manufacturers.find(x => x.id === id);
if (!m) return;
openMfrModal(id);
document.getElementById('mfrName').value = m.name || '';
document.getElementById('mfrRep').value = m.representative_name || '';
document.getElementById('mfrBiz').value = m.business_number || '';
document.getElementById('mfrPhone').value = m.phone || '';
document.getElementById('mfrAddr').value = m.address || '';
document.getElementById('mfrActive').value = m.is_active ? 'true' : 'false';
}
async function saveMfr() {
const id = document.getElementById('mfrEditId').value;
const name = document.getElementById('mfrName').value.trim();
if (!name) { showMfrErr('회사명은 필수입니다.'); return; }
const fd = new FormData();
fd.append('name', name);
fd.append('representative_name', document.getElementById('mfrRep').value.trim());
fd.append('business_number', document.getElementById('mfrBiz').value.trim());
fd.append('phone', document.getElementById('mfrPhone').value.trim());
fd.append('address', document.getElementById('mfrAddr').value.trim());
if (id) fd.append('is_active', document.getElementById('mfrActive').value);
try {
if (id) await API.put('/manufacturers/' + id, fd);
else await API.post('/manufacturers', fd);
closeMfrModal();
await loadMfr();
} catch(e) { showMfrErr(e.message); }
}
async function deleteMfr(id, name) {
if (!confirm(`"${name}" 제조사를 삭제하시겠습니까?`)) return;
try { await API.delete('/manufacturers/' + id); await loadMfr(); }
catch(e) { alert('삭제 실패: ' + e.message); }
}
function showMfrErr(msg) {
const el = document.getElementById('mfrModalErr');
el.textContent = msg; el.style.display = 'block';
}
async function loadMfr() {
manufacturers = await API.get('/manufacturers');
renderMfr();
}
// ── 공용 ──
function showMsg(id) {
const el = document.getElementById(id);
el.className = 'alert alert-success';
el.textContent = '✅ 저장되었습니다.';
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 3000);
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>

View File

@@ -11,6 +11,7 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>

View File

@@ -4,6 +4,8 @@
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>신고 상세</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
/* 출장비 요약 카드 */
.cost-summary {
@@ -104,6 +106,14 @@
padding-top: 16px;
margin-top: 4px;
}
/* 신고 인라인 편집 */
.report-view { display:block; }
.report-edit { display:none; }
.report-edit.active { display:block; }
.report-view.hidden { display:none; }
.issue-chk-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:6px; }
.issue-chk-item { display:flex; align-items:center; gap:6px; font-size:13px; }
.issue-chk-item input { width:15px; height:15px; }
</style>
</head>
<body>
@@ -111,16 +121,33 @@
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="main" style="max-width:860px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">신고 상세</h2>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">신고 상세</h2>
</div>
<div id="content"></div>
</div>
<div id="content"></div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/imageCompress.js"></script>
<script>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
@@ -165,11 +192,20 @@ function toggleEdit() {
}
}
const IMP_CAT_LABEL = {
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
installation:'설치환경', other:'기타'
};
async function load() {
const r = await API.get('/reports/' + reportId);
const repair = r.repair;
const cost = repair?.cost;
const manufacturers = await API.get('/accounts?role=manufacturer');
const [r, issueTypes, manufacturers, improvements] = await Promise.all([
API.get('/reports/' + reportId),
API.get('/settings/issue-types'),
API.get('/accounts?role=manufacturer'),
API.get('/improvements'),
]);
const repair = r.repair;
const cost = repair?.cost;
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`;
@@ -321,34 +357,130 @@ async function load() {
}
document.getElementById('content').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="detail-grid">
<!-- 신고 정보 -->
<div class="card">
<div class="card-title">📋 신고 정보</div>
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong></td></tr>
<tr><td style="color:var(--gray4)">충전기명</td><td>${r.charger_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">종류</td><td>${r.charger_type || '-'}</td></tr>
<tr><td style="color:var(--gray4)">설치일</td><td>${r.installed_at || '-'}</td></tr>
<tr><td style="color:var(--gray4)">문제유형</td><td>${(r.issue_types || []).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">에러코드</td><td>${r.error_code || '-'}</td></tr>
<tr><td style="color:var(--gray4)">상세설명</td><td>${r.issue_detail || '-'}</td></tr>
<tr><td style="color:var(--gray4)">연락처</td><td>${r.contact || '-'}</td></tr>
<tr><td style="color:var(--gray4)">발생시각</td><td>${Auth.fmtDt(r.occurred_at)}</td></tr>
<tr><td style="color:var(--gray4)">신고일시</td><td>${Auth.fmtDt(r.reported_at)}</td></tr>
<tr><td style="color:var(--gray4)">상태</td><td>${Auth.statusBadge(r.status)}</td></tr>
</table>
${r.status === 'pending_approval' ? `
<button class="btn btn-success btn-sm" style="margin-top:12px"
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''}
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;">
<span>📋 신고 정보</span>
<button class="edit-toggle-btn" id="reportEditBtn" onclick="toggleReportEdit()">✏️ 내용 수정</button>
</div>
<!-- 보기 모드 -->
<div class="report-view" id="reportView">
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong></td></tr>
<tr><td style="color:var(--gray4)">충전기명</td><td>${r.charger_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">종류</td><td>${r.charger_type || '-'}</td></tr>
<tr><td style="color:var(--gray4)">설치일</td><td>${r.installed_at || '-'}</td></tr>
<tr><td style="color:var(--gray4)">문제유형</td><td>${(r.issue_types || []).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">에러코드</td><td>${r.error_code || '-'}</td></tr>
<tr><td style="color:var(--gray4)">상세설명</td><td>${r.issue_detail || '-'}</td></tr>
<tr><td style="color:var(--gray4)">연락처</td><td>${r.contact || '-'}</td></tr>
<tr><td style="color:var(--gray4)">발생시각</td><td>${Auth.fmtDt(r.occurred_at)}</td></tr>
<tr><td style="color:var(--gray4)">신고일시</td><td>${Auth.fmtDt(r.reported_at)}</td></tr>
<tr><td style="color:var(--gray4)">상태</td><td>${Auth.statusBadge(r.status)}</td></tr>
<tr><td style="color:var(--gray4)">신고 출처</td><td>${r.source === 'dashboard'
? `<span style="background:#F5F3FF;color:#7C3AED;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">🖥 대시보드 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
: r.source === 'admin'
? `<span style="background:#EFF6FF;color:#1565C0;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">⚙️ 관리자 접수${r.reported_by_name ? ' — ' + r.reported_by_name : ''}</span>`
: `<span style="background:#F0FDF4;color:#166534;padding:2px 8px;border-radius:8px;font-size:12px;font-weight:700">📱 QR 스캔</span>`
}</td></tr>
</table>
${r.ocpp_log ? `
<div style="margin-top:12px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그</label>
<pre style="margin-top:6px;background:var(--gray1);border:1px solid var(--gray3);border-radius:6px;padding:10px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;">${escHtmlDetail(r.ocpp_log)}</pre>
</div>` : ''}
${r.status === 'pending_approval' ? `
<button class="btn btn-success btn-sm" style="margin-top:12px"
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''}
</div>
<!-- 편집 모드 -->
<div class="report-edit" id="reportEdit">
<div class="form-group" style="margin-bottom:10px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">문제 유형 <span class="req">*</span></label>
<div class="issue-chk-grid">
${issueTypes.map(i => `
<label class="issue-chk-item">
<input type="checkbox" class="r-issue-chk" value="${i.key}"
${(r.issue_types||[]).includes(i.key) ? 'checked' : ''}>
${i.label}
</label>`).join('')}
</div>
</div>
<div class="form-group" style="margin-bottom:10px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">에러 코드</label>
<input type="text" id="rEditErrorCode" value="${r.error_code||''}" placeholder="에러 코드">
</div>
<div class="form-group" style="margin-bottom:10px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">상세 설명</label>
<textarea id="rEditDetail" rows="3" placeholder="문제 상황 설명">${r.issue_detail||''}</textarea>
</div>
<div class="form-row" style="margin-bottom:10px">
<div class="form-group">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">연락처</label>
<input type="text" id="rEditContact" value="${r.contact||''}" placeholder="연락처">
</div>
<div class="form-group">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">발생 시각</label>
<input type="datetime-local" id="rEditOccurred"
value="${r.occurred_at ? r.occurred_at.slice(0,16) : ''}">
</div>
</div>
<div class="form-group" style="margin-bottom:14px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 상태</label>
<select id="rEditStatus">
<option value="pending_approval" ${r.status==='pending_approval'?'selected':''}>승인대기</option>
<option value="pending" ${r.status==='pending' ?'selected':''}>접수</option>
<option value="in_progress" ${r.status==='in_progress' ?'selected':''}>처리중</option>
<option value="done" ${r.status==='done' ?'selected':''}>완료</option>
<option value="waiting" ${r.status==='waiting' ?'selected':''}>부품대기</option>
<option value="revisit" ${r.status==='revisit' ?'selected':''}>재방문</option>
</select>
</div>
<div class="form-group" style="margin-bottom:14px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">📡 OCPP 로그 <span style="font-weight:400;color:var(--gray4)">(선택)</span></label>
<textarea id="rEditOcppLog" rows="5"
style="width:100%;padding:8px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:12px;font-family:monospace;resize:vertical;box-sizing:border-box;"
placeholder="OCPP 통신 로그 붙여넣기...">${r.ocpp_log || ''}</textarea>
<label style="display:flex;align-items:center;gap:8px;margin-top:5px;cursor:pointer;font-size:12px;color:var(--blue);">
<input type="file" id="rEditOcppFile" accept=".txt,.csv,.log" style="display:none" onchange="readOcppFileEdit(this)">
📄 파일 선택 (.txt/.csv/.log)
<span id="rEditOcppFileName" style="color:var(--gray4);font-weight:400"></span>
</label>
</div>
<div class="form-group" style="margin-bottom:14px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">사진 관리</label>
${(r.photos||[]).length ? `
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
${(r.photos||[]).map(p => `
<div style="position:relative;">
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
<button onclick="deleteReportPhoto(${r.id},${p.id})"
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
</div>`).join('')}
</div>` : ''}
<label class="upload-area" for="rEditPhoto" style="padding:10px;font-size:12px;">📷 사진 추가 (선택 · 여러 장)</label>
<input type="file" id="rEditPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="rEditPhotoPreview"></div>
<div class="photo-info" id="rEditPhotoInfo" style="color:var(--gray4)"></div>
</div>
<div id="rEditErr" class="alert alert-danger" style="display:none"></div>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary btn-sm" onclick="saveReport(${r.id})">💾 저장</button>
<button class="btn btn-outline btn-sm" onclick="toggleReportEdit()">취소</button>
</div>
</div>
<div style="margin-top:12px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 사진</label>
<div class="photo-preview">
${(r.photos || []).map(p =>
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
`<img src="${p.path}" onclick="window.open('${p.path}')" style="cursor:zoom-in">`
).join('') || '<span style="font-size:12px;color:var(--gray4)">첨부 없음</span>'}
</div>
</div>
@@ -356,7 +488,17 @@ async function load() {
<!-- 조치 정보 -->
<div class="card">
<div class="card-title">🔧 조치 정보</div>
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>🔧 조치 정보</span>
${repair ? `
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
${repair.approved_at
? `<span style="font-size:12px;background:#D1FAE5;color:#065F46;padding:3px 12px;border-radius:10px;font-weight:700;">✅ 승인완료 · ${repair.approved_by_name||''}</span>`
: `<button onclick="toggleApprovePanel()" id="approvePanelBtn" style="padding:5px 14px;border:none;border-radius:7px;background:var(--green);color:white;font-size:12px;font-weight:700;cursor:pointer;">✅ 조치 승인</button>`
}
<button onclick="cancelRepair(${repair.id}, ${!!repair.approved_at})" style="padding:5px 14px;border:none;border-radius:7px;background:#FEE2E2;color:#991B1B;font-size:12px;font-weight:700;cursor:pointer;">🔄 조치취소</button>
</div>` : ''}
</div>
${repair ? `
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${repair.mechanic_name || '-'}</td></tr>
@@ -381,6 +523,105 @@ async function load() {
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
</div>
</div>
${renderLocationMap(repair)}
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
repair.linked_improvements && repair.linked_improvements.length ? `
<div style="margin-top:14px;padding:12px 14px;background:#EDE9FE;border-radius:8px;">
<div style="font-size:12px;font-weight:700;color:#5B21B6;margin-bottom:8px;">🔧 연결된 개선항목</div>
${repair.linked_improvements.map(i => `
<a href="/pages/admin/improvement-detail.html?id=${i.id}"
style="display:flex;align-items:center;gap:8px;font-size:13px;color:#5B21B6;text-decoration:none;padding:4px 0;">
<span style="background:#DDD6FE;border-radius:4px;padding:1px 7px;font-size:11px;">${IMP_CAT_LABEL[i.category]||i.category}</span>
<strong>#${i.id}</strong> ${i.title}
</a>`).join('')}
</div>` : ''}
${/* ── 승인 패널 (미승인 시) ── */
!repair.approved_at ? `
<div id="approvePanel" style="display:none;border-top:1px dashed var(--gray3);margin-top:16px;padding-top:16px;">
<div style="font-size:14px;font-weight:700;color:var(--navy);margin-bottom:14px;">✅ 조치 승인 — 개선항목 연결</div>
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:14px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
<input type="radio" name="impAction" value="none" checked onchange="updateImpSection()">
<span>개선항목 연결 안 함</span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
<input type="radio" name="impAction" value="link" onchange="updateImpSection()">
<span>기존 개선항목에 연결 — <span style="color:var(--gray4);font-size:12px;">이전에 등록된 항목 선택</span></span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
<input type="radio" name="impAction" value="create" onchange="updateImpSection()">
<span>신규 개선항목 생성 — <span style="color:var(--gray4);font-size:12px;">이번 조치 기반으로 새 항목 등록</span></span>
</label>
</div>
<!-- 기존 항목 연결 -->
<div id="impLinkSection" style="display:none;margin-bottom:14px;">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;">개선항목 검색</div>
<input type="text" id="impSearch" placeholder="제목으로 검색..."
style="width:100%;padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;margin-bottom:6px;"
oninput="filterImpOptions()">
<select id="impSelect" size="5"
style="width:100%;border:1px solid var(--gray3);border-radius:6px;font-size:13px;padding:4px;">
${improvements.length
? improvements.map(i =>
`<option value="${i.id}">[${IMP_CAT_LABEL[i.category]||i.category}] #${i.id} ${i.title}</option>`
).join('')
: '<option disabled>등록된 개선항목이 없습니다</option>'}
</select>
</div>
<!-- 신규 항목 생성 -->
<div id="impCreateSection" style="display:none;margin-bottom:14px;padding:14px;background:var(--gray1);border-radius:8px;">
<div class="form-group" style="margin-bottom:10px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">제목 <span class="req">*</span></label>
<input type="text" id="impTitle" placeholder="개선항목 제목">
</div>
<div class="form-row" style="margin-bottom:10px">
<div class="form-group">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">분류 <span class="req">*</span></label>
<select id="impCategory">
<option value="">선택</option>
<option value="hardware">하드웨어</option>
<option value="software">소프트웨어</option>
<option value="firmware">펌웨어</option>
<option value="installation">설치환경</option>
<option value="other">기타</option>
</select>
</div>
<div class="form-group">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">우선순위</label>
<select id="impPriority">
<option value="low">낮음</option>
<option value="normal" selected>보통</option>
<option value="high">높음</option>
<option value="critical">긴급</option>
</select>
</div>
</div>
<div class="form-group" style="margin-bottom:10px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">내용 <span class="req">*</span></label>
<textarea id="impDesc" rows="3" placeholder="개선이 필요한 내용을 구체적으로 기술해 주세요."></textarea>
</div>
<div class="form-group">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">담당 제조사</label>
<select id="impMfr">
<option value="">미지정 (나중에 설정)</option>
${manufacturers.map(m =>
`<option value="${m.id}">${m.company ? m.company+' / ' : ''}${m.name}</option>`
).join('')}
</select>
</div>
</div>
<div id="approveErr" class="alert alert-danger" style="display:none;margin-bottom:10px;"></div>
<div style="display:flex;gap:8px;">
<button class="btn btn-success btn-sm" onclick="doApproveRepair(${repair.id})">✅ 승인 완료</button>
<button class="btn btn-outline btn-sm" onclick="toggleApprovePanel()">취소</button>
</div>
</div>` : ''}
` : '<div class="alert alert-info">아직 정비사가 조치를 입력하지 않았습니다.</div>'}
</div>
</div>
@@ -388,18 +629,256 @@ async function load() {
${costHtml}
`;
// 신고 편집 폼 사진 압축 설정
if (document.getElementById('rEditPhoto')) {
ImageCompressor.setupPreview('rEditPhoto', 'rEditPhotoPreview', 'rEditPhotoInfo');
}
// 지도 초기화 (수리 정보가 있을 때만)
if (repair) initRepairMap(repair);
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
if (!editOpen) return;
const wrap = document.getElementById('costEditWrap');
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
}
/* ── 방문 위치 지도 ── */
function renderLocationMap(repair) {
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
const cLat = repair.charger_lat, cLng = repair.charger_lng;
if (!mLat && !cLat) return '';
let distHtml = '';
if (mLat && cLat) {
const d = haversineM(mLat, mLng, cLat, cLng);
const within = d <= 200;
distHtml = `
<div style="display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:8px;">
<span style="padding:3px 10px;border-radius:12px;font-weight:700;
background:${within ? '#D1FAE5' : '#FEE2E2'};color:${within ? '#065F46' : '#991B1B'}">
${within ? '✅ 현장 방문 확인' : '⚠️ 현장 거리 초과'}
</span>
<span style="color:var(--gray4)">충전기와의 거리: <strong>${d < 1000 ? Math.round(d)+'m' : (d/1000).toFixed(1)+'km'}</strong></span>
</div>`;
}
return `
<div style="margin-top:16px">
<label style="font-size:12px;font-weight:700;color:var(--navy2);display:block;margin-bottom:6px">📍 조치 위치</label>
${distHtml}
<div id="repairMap" style="height:220px;border-radius:8px;border:1px solid var(--gray3);overflow:hidden"></div>
<div style="display:flex;gap:16px;font-size:11px;color:var(--gray4);margin-top:5px;">
${mLat ? '<span>🔵 정비사 위치</span>' : ''}
${cLat ? '<span>🔴 충전기 등록 위치</span>' : ''}
</div>
</div>`;
}
function initRepairMap(repair) {
const mLat = repair.mechanic_lat, mLng = repair.mechanic_lng;
const cLat = repair.charger_lat, cLng = repair.charger_lng;
if (!document.getElementById('repairMap')) return;
const center = mLat ? [mLat, mLng] : [cLat, cLng];
const map = L.map('repairMap', { zoomControl: true, scrollWheelZoom: false })
.setView(center, 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 19,
}).addTo(map);
const bounds = [];
if (mLat) {
const icon = L.divIcon({
html: '<div style="width:14px;height:14px;border-radius:50%;background:#2563EB;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
});
L.marker([mLat, mLng], { icon })
.addTo(map)
.bindPopup('<b>정비사 위치</b><br>조치 제출 시점 기록')
.openPopup();
bounds.push([mLat, mLng]);
}
if (cLat) {
const icon = L.divIcon({
html: '<div style="width:14px;height:14px;border-radius:50%;background:#DC2626;border:2px solid white;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: ''
});
L.marker([cLat, cLng], { icon })
.addTo(map)
.bindPopup('<b>충전기 등록 위치</b>');
bounds.push([cLat, cLng]);
}
if (mLat && cLat) {
L.polyline([[mLat, mLng], [cLat, cLng]], {
color: '#6366F1', weight: 2, dashArray: '5 5', opacity: 0.7
}).addTo(map);
map.fitBounds(bounds, { padding: [30, 30] });
}
}
function haversineM(lat1, lng1, lat2, lng2) {
const R = 6371000;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 +
Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function escHtmlDetail(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function readOcppFileEdit(input) {
const file = input.files[0];
if (!file) return;
document.getElementById('rEditOcppFileName').textContent = file.name;
const reader = new FileReader();
reader.onload = e => { document.getElementById('rEditOcppLog').value = e.target.result; };
reader.readAsText(file, 'UTF-8');
}
async function deleteReportPhoto(reportId, photoId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
await API.delete(`/reports/${reportId}/photos/${photoId}`);
load();
} catch(e) { alert(e.message); }
}
function toggleReportEdit() {
const view = document.getElementById('reportView');
const edit = document.getElementById('reportEdit');
const btn = document.getElementById('reportEditBtn');
const isEditing = edit.classList.contains('active');
if (isEditing) {
edit.classList.remove('active');
view.classList.remove('hidden');
btn.innerHTML = '✏️ 내용 수정';
} else {
view.classList.add('hidden');
edit.classList.add('active');
btn.innerHTML = '✕ 취소';
}
}
async function saveReport(reportId) {
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
if (!issues.length) {
const err = document.getElementById('rEditErr');
err.textContent = '문제 유형을 1개 이상 선택해 주세요.';
err.style.display = 'block';
return;
}
document.getElementById('rEditErr').style.display = 'none';
const fd = new FormData();
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('rEditDetail').value);
fd.append('error_code', document.getElementById('rEditErrorCode').value);
fd.append('contact', document.getElementById('rEditContact').value);
fd.append('occurred_at', document.getElementById('rEditOccurred').value);
fd.append('status', document.getElementById('rEditStatus').value);
fd.append('ocpp_log', document.getElementById('rEditOcppLog').value);
const newPhotos = document.getElementById('rEditPhoto')?.files || [];
Array.from(newPhotos).forEach(f => fd.append('photos', f));
try {
await API.patch(`/reports/${reportId}`, fd);
load();
} catch(e) {
const err = document.getElementById('rEditErr');
err.textContent = e.message;
err.style.display = 'block';
}
}
function toggleParty() {
const v = document.getElementById('partyType').value;
document.getElementById('mfrWrap').style.display = v === 'manufacturer' ? 'block' : 'none';
document.getElementById('customWrap').style.display = v === 'other' ? 'block' : 'none';
}
function toggleApprovePanel() {
const panel = document.getElementById('approvePanel');
const btn = document.getElementById('approvePanelBtn');
if (!panel) return;
const opening = panel.style.display === 'none';
panel.style.display = opening ? 'block' : 'none';
if (btn) btn.textContent = opening ? '✕ 취소' : '✅ 조치 승인';
}
function updateImpSection() {
const action = document.querySelector('input[name="impAction"]:checked')?.value;
document.getElementById('impLinkSection').style.display = action === 'link' ? 'block' : 'none';
document.getElementById('impCreateSection').style.display = action === 'create' ? 'block' : 'none';
}
function filterImpOptions() {
const q = document.getElementById('impSearch').value.toLowerCase();
[...document.getElementById('impSelect').options].forEach(opt => {
opt.hidden = q && !opt.text.toLowerCase().includes(q);
});
}
async function doApproveRepair(repairId) {
const action = document.querySelector('input[name="impAction"]:checked')?.value || 'none';
const fd = new FormData();
fd.append('improvement_action', action);
if (action === 'link') {
const sel = document.getElementById('impSelect');
const impId = sel?.value;
if (!impId) { showApproveErr('연결할 개선항목을 선택해 주세요.'); return; }
fd.append('improvement_id', impId);
} else if (action === 'create') {
const title = document.getElementById('impTitle').value.trim();
const cat = document.getElementById('impCategory').value;
const desc = document.getElementById('impDesc').value.trim();
if (!title || !cat || !desc) { showApproveErr('제목, 분류, 내용을 모두 입력해 주세요.'); return; }
fd.append('imp_title', title);
fd.append('imp_category', cat);
fd.append('imp_description', desc);
fd.append('imp_priority', document.getElementById('impPriority').value);
const mfr = document.getElementById('impMfr').value;
if (mfr) fd.append('imp_manufacturer_id', mfr);
}
if (!confirm('이 조치 내역을 승인하시겠습니까?\n승인 후에는 정비사가 수정할 수 없습니다.')) return;
try {
const res = await API.post(`/repairs/${repairId}/approve`, fd);
let msg = '✅ 조치가 승인되었습니다.';
if (res.improvement_id) {
msg += `\n개선항목 #${res.improvement_id}에 연결되었습니다.`;
}
alert(msg);
load();
} catch(e) { showApproveErr(e.message); }
}
function showApproveErr(msg) {
const el = document.getElementById('approveErr');
if (!el) return;
el.textContent = msg;
el.style.display = 'block';
}
async function cancelRepair(repairId, isApproved) {
const msg = isApproved
? '⚠️ 승인된 조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?'
: '조치를 취소합니다.\n\n연결된 신고가 접수(pending) 상태로 되돌아가며\n정비사가 다시 조치해야 합니다.\n\n계속하시겠습니까?';
if (!confirm(msg)) return;
try {
await API.delete('/repairs/' + repairId);
load();
} catch(e) { alert('조치취소 오류: ' + e.message); }
}
async function approveReport(id) {
if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return;
await API.patch(`/reports/${id}/approve`);
@@ -434,5 +913,7 @@ function showCostErr(msg) {
load();
</script>
</div>
</div>
</body>
</html>

View File

@@ -4,6 +4,40 @@
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>신고 목록</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
tr.selected { background:var(--light-gray,#f0f4ff); }
#btnDelete { display:none; }
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
.view-btn { padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white; cursor:pointer; color:var(--gray4); transition:all .15s; }
.view-btn.active { background:var(--navy); color:white; }
#mapWrap {
display:none;
height: calc(100vh - 230px);
min-height: 420px;
border-radius:10px;
overflow:hidden;
border:1px solid var(--border);
isolation: isolate;
}
#reportMap { width:100%; height:100%; }
.rp-pin {
width:28px; height:28px; border-radius:50% 50% 50% 0;
transform:rotate(-45deg); border:3px solid white;
box-shadow:0 2px 6px rgba(0,0,0,.35);
}
.rp-pin.pending { background:#EF4444; }
.rp-pin.in_progress { background:#F59E0B; }
.rp-pin.waiting { background:#3B82F6; }
.rp-pin.revisit { background:#8B5CF6; }
.rp-pin.done { background:#9CA3AF; }
.rp-pin.multi { background:#7C3AED; }
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
@@ -17,6 +51,7 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
@@ -24,10 +59,19 @@
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 신고 목록</h2>
<button class="btn btn-success btn-sm" onclick="API.download('/export/reports','AS신고목록.xlsx')">📥 엑셀 다운로드</button>
<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>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<!-- 필터 -->
<div class="card" style="padding:12px 16px;margin-bottom:12px">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending_approval">승인대기</option>
@@ -39,42 +83,256 @@
</select>
<input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px">
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
<span id="resultCount" style="font-size:13px;color:var(--gray4);margin-left:4px"></span>
</div>
</div>
<!-- 목록 뷰 -->
<div id="listWrap" class="card" style="padding:0">
<div class="tbl-wrap">
<table>
<thead><tr><th>#</th><th>충전기ID</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>상태</th><th>정비사</th></tr></thead>
<thead>
<tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>#</th><th>충전기ID</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>신고자</th><th>상태</th><th>정비사</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none">조회된 신고가 없습니다.</div>
<div id="empty" class="alert alert-info" style="display:none;margin:14px">조회된 신고가 없습니다.</div>
</div>
<!-- 지도 뷰 -->
<div id="mapWrap"><div id="reportMap"></div></div>
<div id="mapMeta" style="display:none;margin-top:8px;font-size:12px;color:var(--gray4);gap:14px;flex-wrap:wrap;align-items:center">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EF4444;margin-right:4px"></span>접수</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#F59E0B;margin-right:4px"></span>처리중</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;margin-right:4px"></span>부품대기</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8B5CF6;margin-right:4px"></span>재방문</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#9CA3AF;margin-right:4px"></span>완료</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#7C3AED;margin-right:4px"></span>복수신고</span>
<span id="mapNoGps" style="margin-left:auto"></span>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
let allRows = [];
let curView = 'list';
let reportMap = null;
let mapMarkers = [];
// ── URL 파라미터 초기값 ──
const _p = new URLSearchParams(location.search);
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
// ── 뷰 전환 ──
function setView(v) {
sessionStorage.setItem('reportsView', v);
curView = v;
document.getElementById('btnList').classList.toggle('active', v === 'list');
document.getElementById('btnMap').classList.toggle('active', v === 'map');
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
document.getElementById('mapMeta').style.display = v === 'map' ? 'flex' : 'none';
document.getElementById('btnDelete').style.display =
(v === 'list' && document.querySelectorAll('.row-chk:checked').length > 0) ? 'inline-flex' : 'none';
if (v === 'map') {
initReportMap();
renderReportMap();
setTimeout(() => reportMap && reportMap.invalidateSize(), 50);
}
}
// ── 체크박스 ──
function updateDeleteBtn() {
const checked = document.querySelectorAll('.row-chk:checked');
document.getElementById('selCount').textContent = checked.length;
document.getElementById('btnDelete').style.display =
(curView === 'list' && checked.length > 0) ? 'inline-flex' : 'none';
}
function toggleAll(chkAll) {
document.querySelectorAll('.row-chk').forEach(c => {
c.checked = chkAll.checked;
c.closest('tr').classList.toggle('selected', chkAll.checked);
});
updateDeleteBtn();
}
async function bulkDelete() {
const checked = [...document.querySelectorAll('.row-chk:checked')];
if (!checked.length) return;
if (!confirm(`선택한 신고 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
const ids = checked.map(c => parseInt(c.dataset.id));
try { await API.delete('/reports/bulk', ids); await load(); }
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
function maskPhone(p) {
const d = (p||'').replace(/\D/g,'');
if (d.length >= 10) return d.slice(0,3) + '-****-' + d.slice(-4);
return p;
}
// ── 데이터 로드 ──
async function load() {
document.getElementById('chkAll').checked = false;
updateDeleteBtn();
let url = '/reports?';
const s = document.getElementById('fStatus').value;
const c = document.getElementById('fCharger').value.trim();
if (s) url += 'status='+s+'&';
if (c) url += 'charger_id='+c+'&';
const rows = await API.get(url);
if (s) url += 'status=' + s + '&';
if (c) url += 'charger_id=' + c + '&';
allRows = await API.get(url);
document.getElementById('resultCount').textContent = allRows.length + '건';
renderTable();
if (curView === 'map') renderReportMap();
}
// ── 목록 렌더 ──
function renderTable() {
const tbody = document.getElementById('tbody');
document.getElementById('empty').style.display = rows.length ? 'none' : 'block';
tbody.innerHTML = rows.map(r => `
<tr onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>
<td style="max-width:200px">${(r.issue_types||[]).join(', ')}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${Auth.statusBadge(r.status)}</td>
<td>${r.repair?.mechanic_name||'-'}</td>
document.getElementById('empty').style.display = allRows.length ? 'none' : 'block';
tbody.innerHTML = allRows.map((r, i) => `
<tr>
<td class="cb-cell" onclick="event.stopPropagation()">
<input type="checkbox" class="row-chk" data-id="${r.id}"
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
<span style="font-weight:700">${i+1}</span>
<span style="display:block;font-size:10px;color:var(--gray4);font-weight:400">#${r.id}</span>
</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"><strong>${r.charger_id}</strong></td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.station_name||'-'}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.charger_type||'-'}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer;max-width:200px">${(r.issue_types||[]).join(', ')}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.fmtDt(r.reported_at)}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
${r.source === 'dashboard'
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:#7C3AED">🖥 대시보드</div>`
: r.source === 'admin'
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.reported_by_name||'관리자'}</div><div style="font-size:11px;color:var(--blue)">⚙️ 관리자</div>`
: `<div style="font-size:12px;color:var(--text)">${r.contact ? maskPhone(r.contact) : '익명'}</div><div style="font-size:11px;color:#166534">📱 QR</div>`}
</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${Auth.statusBadge(r.status)}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
${r.mechanic_name
? `<div style="font-size:12px;font-weight:600;color:var(--navy)">${r.mechanic_name}</div>${r.mechanic_company ? `<div style="font-size:11px;color:var(--gray4)">${r.mechanic_company}</div>` : ''}`
: '<span style="color:var(--gray4)">-</span>'}
</td>
</tr>`).join('');
}
load();
// ── 지도 초기화 ──
function initReportMap() {
if (reportMap) return;
reportMap = L.map('reportMap', { zoomControl: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(reportMap);
}
// 상태별 마커 색상
const STATUS_CLASS = {
pending: 'pending', pending_approval: 'pending',
in_progress: 'in_progress',
waiting: 'waiting', revisit: 'revisit',
done: 'done',
};
// ── 지도 마커 렌더 ──
function renderReportMap() {
if (!reportMap) return;
mapMarkers.forEach(m => m.remove());
mapMarkers = [];
// 충전기별 그룹핑 (charger GPS 우선, 없으면 신고 GPS)
const grouped = {};
allRows.forEach(r => {
const lat = r.charger_lat || r.gps_lat;
const lng = r.charger_lng || r.gps_lng;
if (!lat || !lng) return;
if (!grouped[r.charger_id]) {
grouped[r.charger_id] = {
charger_id: r.charger_id, charger_name: r.charger_name,
station_name: r.station_name, location_detail: r.location_detail,
lat, lng, reports: [],
};
}
grouped[r.charger_id].reports.push(r);
});
const groups = Object.values(grouped);
const noGps = allRows.filter(r => !r.charger_lat && !r.gps_lat).length;
document.getElementById('mapNoGps').textContent = noGps ? `📍 GPS 미등록 ${noGps}건 미표시` : '';
if (!groups.length) {
reportMap.setView([36.5, 127.8], 7);
return;
}
groups.forEach(g => {
// 대표 상태 결정 (우선순위: pending > in_progress > waiting > revisit > done)
const priority = ['pending','pending_approval','in_progress','waiting','revisit','done'];
const topStatus = priority.find(s => g.reports.some(r => r.status === s)) || 'pending';
const pinClass = g.reports.length > 1 ? 'multi' : (STATUS_CLASS[topStatus] || 'pending');
const icon = L.divIcon({
className: '',
html: `<div class="rp-pin ${pinClass}"></div>`,
iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30],
});
const m = L.marker([g.lat, g.lng], { icon }).addTo(reportMap);
if (g.reports.length === 1) {
const r = g.reports[0];
m.on('click', () => { location.href = `/pages/admin/report-detail.html?id=${r.id}`; });
} else {
const rowsHtml = g.reports.map(r => {
const h = (Date.now() - new Date(r.reported_at)) / 3600000;
const age = h < 1 ? Math.round(h*60)+'분' : h < 24 ? Math.round(h)+'h' : (h/24).toFixed(1)+'일';
return `<a href="/pages/admin/report-detail.html?id=${r.id}"
style="display:flex;justify-content:space-between;align-items:center;
padding:6px 8px;border-radius:6px;font-size:12px;text-decoration:none;
color:inherit;background:#f9fafb;border:1px solid #e5e7eb;margin-bottom:5px">
<span><strong>#${r.id}</strong> ${(r.issue_types||[]).join(', ')}</span>
<span style="margin-left:8px;white-space:nowrap">${Auth.statusBadge(r.status)}</span>
</a>`;
}).join('');
const popup = `
<div style="min-width:230px">
<div style="font-size:14px;font-weight:700;color:#1e3a5f;margin-bottom:4px">
${g.charger_id}
<span style="font-size:12px;color:#7C3AED;font-weight:600">${g.reports.length}건</span>
</div>
<div style="font-size:12px;color:#6b7280;margin-bottom:10px;line-height:1.5">
📍 ${g.station_name||'-'}${g.location_detail ? '<br>'+g.location_detail : ''}
</div>
${rowsHtml}
</div>`;
m.bindPopup(popup, { maxWidth: 300 });
}
mapMarkers.push(m);
});
const bounds = L.latLngBounds(groups.map(g => [g.lat, g.lng]));
reportMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
if (groups.length === 1) reportMap.setZoom(14);
}
load().then(() => {
if (sessionStorage.getItem('reportsView') === 'map') setView('map');
});
</script>
</body>
</html>

View File

@@ -36,6 +36,7 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html" class="active">⚙️ 설정</a>
@@ -69,6 +70,103 @@
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:4px">전체 설정 저장</button>
</div>
<!-- 처리시간 지표 기준 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">⏱ 처리시간 지표 기준</div>
<div class="alert alert-info" style="margin-bottom:14px">
대시보드의 <strong>처리시간 평균</strong><strong>대기 심각도</strong> 지표를 계산할 때<br>
시작 시점으로 사용할 기준을 선택합니다.
</div>
<div class="form-group">
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-occurred">
<input type="radio" name="timeBase" value="occurred" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
<div>
<div style="font-weight:700">📅 발생시각 기준 (권장)</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">실제 고장이 발생한 시각부터 계산합니다. 발생시각이 없으면 등록시간으로 대체됩니다.</div>
</div>
</label>
<label class="check-item" style="display:flex;gap:12px;padding:14px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-reported">
<input type="radio" name="timeBase" value="reported" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
<div>
<div style="font-weight:700">🕐 등록시간 기준</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">신고가 시스템에 접수된 시각부터 계산합니다.</div>
</div>
</label>
</div>
</div>
<!-- 처리시간 집계 방식 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🏢 처리시간 집계 방식</div>
<div class="alert alert-info" style="margin-bottom:14px">
대기·처리시간 지표를 산출할 때 공휴일·주말을 처리하는 방식을 선택합니다.
</div>
<div class="form-group">
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-wt-off">
<input type="radio" name="worktimeMode" value="off" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
<div>
<div style="font-weight:700">📅 달력 기준 (기본)</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">주말·공휴일 포함 모든 경과시간을 그대로 집계합니다.</div>
</div>
</label>
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-wt-holiday24h">
<input type="radio" name="worktimeMode" value="holiday_24h" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
<div>
<div style="font-weight:700">🗓 공휴일 제외 24시간</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">공휴일만 제외하고, 주말을 포함한 나머지 날은 하루 24시간 전체를 카운트합니다.</div>
</div>
</label>
<label class="check-item" style="display:flex;gap:12px;padding:14px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-wt-worktime">
<input type="radio" name="worktimeMode" value="worktime" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0" onchange="updateWorktimeModeLabels()">
<div>
<div style="font-weight:700">💼 업무시간 기준 (09:0018:00)</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">주말·공휴일 제외 후, 평일 업무시간(09:0018:00) 내 경과시간만 집계합니다.</div>
</div>
</label>
</div>
<!-- 공휴일 관리 (공휴일 제외 모드일 때만 표시) -->
<div id="holidaySection" style="display:none;margin-top:18px;border-top:1px solid var(--gray2);padding-top:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
<div style="font-size:13px;font-weight:700;color:var(--navy)">
📅 공휴일 관리
<select id="holidayYear" onchange="loadHolidays()" style="margin-left:10px;width:auto;font-size:13px;padding:4px 8px">
</select>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-sm btn-outline" onclick="addFixedHolidays()">📋 고정 공휴일 추가</button>
<button class="btn btn-sm btn-primary" onclick="openHolidayModal()">+ 공휴일 추가</button>
</div>
</div>
<div style="font-size:12px;color:var(--gray4);margin-bottom:10px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;padding:8px 12px">
<strong>설날·추석·부처님오신날</strong> 등 음력 공휴일과 <strong>대체공휴일</strong>은 매년 직접 추가해야 합니다.
</div>
<div id="holidayList" style="max-height:300px;overflow-y:auto">
<div style="color:var(--gray4);font-size:13px;text-align:center;padding:20px">불러오는 중...</div>
</div>
</div>
</div>
<!-- 공휴일 추가 모달 -->
<div class="modal-bg hidden" id="holidayModal">
<div class="modal" style="max-width:380px">
<div class="modal-title">공휴일 추가</div>
<div class="form-group">
<label>날짜 <span class="req">*</span></label>
<input type="date" id="hDate">
</div>
<div class="form-group">
<label>공휴일명 <span class="req">*</span></label>
<input type="text" id="hName" placeholder="예) 추석">
</div>
<div id="hErr" class="alert alert-danger" style="display:none"></div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeHolidayModal()">취소</button>
<button class="btn btn-primary" onclick="saveHoliday()">추가</button>
</div>
</div>
</div>
<!-- 이미지 압축 설정 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🖼️ 사진 업로드 압축 설정</div>
@@ -203,9 +301,20 @@ function updateEffect() {
async function load() {
const s = await API.get('/settings');
const policy = s.report_visibility_policy || 'immediate';
document.querySelector(`input[value="${policy}"]`).checked = true;
document.querySelector(`input[name="policy"][value="${policy}"]`).checked = true;
updateLabels();
const timeBase = s.time_metric_base || 'occurred';
document.querySelector(`input[name="timeBase"][value="${timeBase}"]`).checked = true;
updateTimeBaseLabels();
const wtMode = ['off','holiday_24h','worktime'].includes(s.time_metric_worktime)
? s.time_metric_worktime
: (s.time_metric_worktime === 'true' ? 'worktime' : 'off');
const wtRadio = document.querySelector(`input[name="worktimeMode"][value="${wtMode}"]`);
if (wtRadio) wtRadio.checked = true;
updateWorktimeModeLabels();
const enabled = s.image_compress_enabled !== 'false';
document.getElementById('compressEnabled').checked = enabled;
@@ -227,12 +336,22 @@ function updateLabels() {
lbl.style.background = r.checked ? '#E3EDFF' : 'white';
});
}
function updateTimeBaseLabels() {
document.querySelectorAll('input[name="timeBase"]').forEach(r => {
const lbl = r.closest('label');
lbl.style.borderColor = r.checked ? 'var(--accent)' : 'var(--gray3)';
lbl.style.background = r.checked ? '#E3EDFF' : 'white';
});
}
document.querySelectorAll('input[name="policy"]').forEach(r => r.addEventListener('change', updateLabels));
document.querySelectorAll('input[name="timeBase"]').forEach(r => r.addEventListener('change', updateTimeBaseLabels));
document.getElementById('compressEnabled').addEventListener('change', updateEffect);
async function saveAll() {
const fd = new FormData();
fd.append('report_visibility_policy', document.querySelector('input[name="policy"]:checked').value);
fd.append('time_metric_base', document.querySelector('input[name="timeBase"]:checked').value);
fd.append('time_metric_worktime', document.querySelector('input[name="worktimeMode"]:checked').value);
fd.append('image_compress_enabled', document.getElementById('compressEnabled').checked ? 'true' : 'false');
fd.append('image_max_px', document.getElementById('maxPx').value);
fd.append('image_quality', document.getElementById('quality').value);
@@ -263,6 +382,100 @@ async function changePw() {
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
}
// ── 처리시간 집계 방식 ──
function updateWorktimeModeLabels() {
document.querySelectorAll('input[name="worktimeMode"]').forEach(r => {
const lbl = r.closest('label');
lbl.style.borderColor = r.checked ? 'var(--accent)' : 'var(--gray3)';
lbl.style.background = r.checked ? '#E3EDFF' : 'white';
});
const mode = document.querySelector('input[name="worktimeMode"]:checked')?.value || 'off';
const showHoliday = mode === 'holiday_24h' || mode === 'worktime';
document.getElementById('holidaySection').style.display = showHoliday ? 'block' : 'none';
if (showHoliday && !document.getElementById('holidayYear').options.length) initHolidayYear();
}
function initHolidayYear() {
const sel = document.getElementById('holidayYear');
const cur = new Date().getFullYear();
for (let y = cur + 1; y >= cur - 2; y--) {
const opt = document.createElement('option');
opt.value = y; opt.textContent = y + '년';
if (y === cur) opt.selected = true;
sel.appendChild(opt);
}
loadHolidays();
}
async function loadHolidays() {
const year = document.getElementById('holidayYear').value;
const list = await API.get('/holidays?year=' + year);
const el = document.getElementById('holidayList');
if (!list.length) {
el.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:20px">등록된 공휴일이 없습니다.</div>';
return;
}
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="background:var(--gray2)">
<th style="padding:7px 10px;text-align:left">날짜</th>
<th style="padding:7px 10px;text-align:left">공휴일명</th>
<th style="padding:7px 10px;width:50px"></th>
</tr></thead>
<tbody>${list.map(h => `
<tr style="border-bottom:1px solid var(--gray2)">
<td style="padding:7px 10px">${h.date}</td>
<td style="padding:7px 10px">${h.name}</td>
<td style="padding:7px 10px;text-align:center">
<button onclick="deleteHoliday('${h.date}')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:15px" title="삭제">✕</button>
</td>
</tr>`).join('')}
</tbody></table>`;
}
function openHolidayModal() {
document.getElementById('holidayModal').classList.remove('hidden');
document.getElementById('hErr').style.display = 'none';
document.getElementById('hDate').value = '';
document.getElementById('hName').value = '';
}
function closeHolidayModal() { document.getElementById('holidayModal').classList.add('hidden'); }
async function saveHoliday() {
const d = document.getElementById('hDate').value;
const n = document.getElementById('hName').value.trim();
const errEl = document.getElementById('hErr');
if (!d || !n) { errEl.textContent = '날짜와 공휴일명을 입력하세요.'; errEl.style.display = 'block'; return; }
try {
const fd = new FormData(); fd.append('holiday_date', d); fd.append('name', n);
await API.post('/holidays', fd);
closeHolidayModal(); loadHolidays();
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
}
async function deleteHoliday(date) {
if (!confirm(`${date} 공휴일을 삭제하시겠습니까?`)) return;
await API.delete('/holidays/' + date);
loadHolidays();
}
// 고정 공휴일 (양력) 일괄 추가
async function addFixedHolidays() {
const year = parseInt(document.getElementById('holidayYear').value);
const fixed = [
{ date: `${year}-01-01`, name: '신정' },
{ date: `${year}-03-01`, name: '삼일절' },
{ date: `${year}-05-05`, name: '어린이날' },
{ date: `${year}-06-06`, name: '현충일' },
{ date: `${year}-08-15`, name: '광복절' },
{ date: `${year}-10-03`, name: '개천절' },
{ date: `${year}-10-09`, name: '한글날' },
{ date: `${year}-12-25`, name: '성탄절' },
];
const res = await API.post('/holidays/bulk', fixed);
alert(`${res.added}개 고정 공휴일이 추가되었습니다.\n설날·추석·부처님오신날·대체공휴일은 직접 추가해 주세요.`);
loadHolidays();
}
load();
</script>
</body>

View File

@@ -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,20 +43,81 @@ 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="form-group">
<label>아이디</label>
<input type="text" id="username" placeholder="아이디 입력" autofocus>
<div class="tab-row">
<button id="tabLogin" class="active" onclick="switchTab('login')">로그인</button>
<button id="tabRegister" onclick="switchTab('register')">회원가입</button>
</div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="password" placeholder="비밀번호 입력">
<!-- 로그인 -->
<div class="pane active" id="paneLogin">
<div class="form-group">
<label>아이디</label>
<input type="text" id="username" placeholder="아이디 입력" autofocus>
</div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="password" placeholder="비밀번호 입력">
</div>
<div id="err"></div>
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button>
</div>
<div id="err"></div>
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button>
<!-- 회원가입 -->
<div class="pane" id="paneRegister">
<div class="reg-notice">
📌 정비사 계정으로 가입됩니다.<br>
가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.
</div>
<div class="form-group">
<label>이름 <span style="color:var(--red)">*</span></label>
<input type="text" id="regName" placeholder="실명 입력">
</div>
<div class="form-group">
<label>아이디 <span style="color:var(--red)">*</span></label>
<input type="text" id="regUsername" placeholder="영문·숫자 조합">
</div>
<div class="form-group">
<label>비밀번호 <span style="color:var(--red)">*</span></label>
<input type="password" id="regPassword" placeholder="8자 이상 권장">
</div>
<div class="form-group">
<label>비밀번호 확인 <span style="color:var(--red)">*</span></label>
<input type="password" id="regPassword2" placeholder="비밀번호 재입력">
</div>
<div class="form-group">
<label>전화번호 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
<input type="tel" id="regPhone" placeholder="예) 010-1234-5678">
</div>
<div class="form-group">
<label>회사명 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
<select id="regCompany">
<option value="">-- 소속 제조사 없음 --</option>
</select>
</div>
<div id="regErr"></div>
<button class="btn btn-primary btn-lg" id="regBtn">가입 신청</button>
<div id="regOk" class="alert alert-success" style="display:none;margin-top:14px;text-align:center">
✅ 가입 신청이 완료되었습니다.<br>
<span style="font-size:12px">관리자 승인 후 로그인 가능합니다.</span>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
function switchTab(name) {
document.getElementById('tabLogin').classList.toggle('active', name==='login');
document.getElementById('tabRegister').classList.toggle('active', name==='register');
document.getElementById('paneLogin').classList.toggle('active', name==='login');
document.getElementById('paneRegister').classList.toggle('active', name==='register');
document.getElementById('err').textContent = '';
document.getElementById('regErr').textContent = '';
}
// ── 로그인 ──
async function doLogin() {
const u = document.getElementById('username').value.trim();
const p = document.getElementById('password').value;
@@ -47,16 +131,70 @@ 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';
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html';
if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html';
} catch(e) {
document.getElementById('err').textContent = e.message;
document.getElementById('loginBtn').disabled = false;
}
}
// ── 제조사 목록 로드 (비인증) ──
async function loadCompanies() {
try {
const list = await fetch('/api/manufacturers/public').then(r => r.json());
const sel = document.getElementById('regCompany');
list.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name; opt.textContent = m.name;
sel.appendChild(opt);
});
} catch {}
}
loadCompanies();
// ── 회원가입 ──
async function doRegister() {
const name = document.getElementById('regName').value.trim();
const uname = document.getElementById('regUsername').value.trim();
const pw = document.getElementById('regPassword').value;
const pw2 = document.getElementById('regPassword2').value;
const phone = document.getElementById('regPhone').value.trim();
const company = document.getElementById('regCompany').value;
const errEl = document.getElementById('regErr');
errEl.textContent = '';
if (!name) { errEl.textContent = '이름을 입력하세요.'; return; }
if (!uname) { errEl.textContent = '아이디를 입력하세요.'; return; }
if (!pw) { errEl.textContent = '비밀번호를 입력하세요.'; return; }
if (pw !== pw2) { errEl.textContent = '비밀번호가 일치하지 않습니다.'; return; }
document.getElementById('regBtn').disabled = true;
try {
const fd = new FormData();
fd.append('username', uname);
fd.append('password', pw);
fd.append('name', name);
fd.append('phone', phone);
fd.append('company', company);
const res = await fetch('/api/auth/register', { method:'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
document.getElementById('regOk').style.display = 'block';
document.getElementById('regBtn').style.display = 'none';
['regName','regUsername','regPassword','regPassword2','regPhone'].forEach(id =>
document.getElementById(id).value = '');
document.getElementById('regCompany').value = '';
} catch(e) {
errEl.textContent = e.message;
document.getElementById('regBtn').disabled = false;
}
}
document.getElementById('loginBtn').addEventListener('click', doLogin);
document.getElementById('password').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
document.getElementById('regBtn').addEventListener('click', doRegister);
document.getElementById('regPassword2').addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); });
</script>
</body>
</html>

View File

@@ -4,12 +4,57 @@
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>정비사 대시보드</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
.view-toggle { display:flex; gap:0; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
.view-btn {
padding:6px 16px; font-size:13px; font-weight:600; border:none; background:white;
cursor:pointer; color:var(--gray4); transition:all .15s;
}
.view-btn.active { background:var(--navy); color:white; }
#mapWrap {
display:none;
height: calc(100vh - 200px);
min-height: 420px;
border-radius:10px;
overflow:hidden;
border:1px solid var(--border);
}
#map { width:100%; height:100%; }
/* 마커 커스텀 */
.mk-pin {
width:32px; height:32px; border-radius:50% 50% 50% 0;
transform:rotate(-45deg); border:3px solid white;
box-shadow:0 2px 6px rgba(0,0,0,.35);
}
.mk-pin.pending { background:#EF4444; }
.mk-pin.in_progress{ background:#F59E0B; }
.leaflet-popup-content { min-width:200px; font-size:13px; }
.popup-title { font-size:14px; font-weight:700; color:var(--navy); margin-bottom:6px; }
.popup-meta { font-size:12px; color:var(--gray4); margin-bottom:8px; line-height:1.6; }
.popup-tags { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:10px; }
.popup-tag { font-size:11px; padding:2px 7px; background:var(--gray1); border-radius:8px; border:1px solid var(--gray2); }
.popup-count { font-size:12px; font-weight:700; color:#DC2626; margin-bottom:10px; }
.no-gps-notice {
font-size:12px; color:var(--gray4); padding:6px 10px;
background:var(--gray1); border-radius:6px; margin-bottom:10px;
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html" class="active">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
@@ -18,18 +63,31 @@
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 처리 목록</h2>
<a href="/pages/mechanic/scan.html" class="btn btn-accent">📷 QR 스캔하여 조치 시작</a>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="filterStatus" style="width:auto">
<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="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center;">
<select id="filterStatus" style="width:auto" onchange="load()">
<option value="">전체 상태</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
</select>
<button class="btn btn-outline btn-sm" onclick="load()">새로고침</button>
<div style="margin-left:auto">
<div class="view-toggle">
<button class="view-btn active" id="btnList" onclick="setView('list')">📋 목록</button>
<button class="view-btn" id="btnMap" onclick="setView('map')">🗺 지도</button>
</div>
</div>
</div>
<!-- 목록 뷰 -->
<div id="listWrap" class="card" style="padding:0">
<div style="padding:14px 16px 0">
<div id="noGpsNotice" class="no-gps-notice" style="display:none"></div>
</div>
<div class="tbl-wrap">
<table>
@@ -37,23 +95,68 @@
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none">처리 대기 중인 AS가 없습니다.</div>
<div id="empty" class="alert alert-info" style="display:none;margin:14px">처리 대기 중인 AS가 없습니다.</div>
</div>
<!-- 지도 뷰 -->
<div id="mapWrap">
<div id="map"></div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
let allRows = [];
let mapObj = null;
let markers = [];
let curView = 'list';
// ── 뷰 전환 ──
function setView(v) {
curView = v;
document.getElementById('btnList').classList.toggle('active', v === 'list');
document.getElementById('btnMap').classList.toggle('active', v === 'map');
document.getElementById('listWrap').style.display = v === 'list' ? 'block' : 'none';
document.getElementById('mapWrap').style.display = v === 'map' ? 'block' : 'none';
if (v === 'map') {
initMap();
renderMap();
// 컨테이너가 보인 직후 Leaflet에 크기 재계산 알림
setTimeout(() => mapObj && mapObj.invalidateSize(), 50);
}
}
// ── 데이터 로드 ──
async function load() {
const status = document.getElementById('filterStatus').value;
const rows = await API.get('/repairs/pending' + (status ? '?status='+status : ''));
allRows = await API.get('/repairs/pending' + (status ? '?status=' + status : ''));
renderList();
if (curView === 'map') renderMap();
}
// ── 목록 렌더 ──
function renderList() {
const tbody = document.getElementById('tbody');
if (!rows.length) { tbody.innerHTML=''; document.getElementById('empty').style.display='block'; return; }
document.getElementById('empty').style.display='none';
tbody.innerHTML = rows.map(r => `
<tr onclick="location.href='/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}'">
const empty = document.getElementById('empty');
if (!allRows.length) {
tbody.innerHTML = '';
empty.style.display = 'block';
document.getElementById('noGpsNotice').style.display = 'none';
return;
}
empty.style.display = 'none';
tbody.innerHTML = allRows.map(r => {
const href = r.repair_id
? `/pages/mechanic/repair.html?repair_id=${r.repair_id}`
: `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`;
return `
<tr onclick="location.href='${href}'">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
<td>${r.station_name||'-'}</td>
@@ -61,9 +164,120 @@ async function load() {
<td>${(r.issue_types||[]).join(', ')}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${Auth.statusBadge(r.status)}</td>
<td><a class="btn btn-primary btn-sm" href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}" onclick="event.stopPropagation()">조치</a></td>
</tr>`).join('');
<td><a class="btn btn-primary btn-sm" href="${href}" onclick="event.stopPropagation()">조치</a></td>
</tr>`;
}).join('');
const noGps = allRows.filter(r => !r.gps_lat || !r.gps_lng).length;
const noticeEl = document.getElementById('noGpsNotice');
if (noGps) {
noticeEl.textContent = `📍 GPS 미등록 충전기 ${noGps}건은 지도에 표시되지 않습니다.`;
noticeEl.style.display = 'block';
} else {
noticeEl.style.display = 'none';
}
}
// ── 지도 초기화 ──
function initMap() {
if (mapObj) return;
mapObj = L.map('map', { zoomControl: true });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(mapObj);
}
// ── 지도 마커 렌더 ──
function renderMap() {
if (!mapObj) return;
// 기존 마커 제거
markers.forEach(m => m.remove());
markers = [];
// 충전기별로 그룹핑
const chargerMap = {};
allRows.forEach(r => {
if (!r.gps_lat || !r.gps_lng) return;
const key = r.charger_id;
if (!chargerMap[key]) {
chargerMap[key] = {
charger_id: r.charger_id,
charger_name: r.charger_name,
station_name: r.station_name,
location_detail:r.location_detail,
gps_lat: r.gps_lat,
gps_lng: r.gps_lng,
reports: [],
};
}
chargerMap[key].reports.push(r);
});
const chargers = Object.values(chargerMap);
if (!chargers.length) {
// GPS 없는 경우 한국 중심으로
mapObj.setView([36.5, 127.8], 7);
return;
}
chargers.forEach(c => {
const hasInProgress = c.reports.some(r => r.status === 'in_progress');
const statusClass = hasInProgress ? 'in_progress' : 'pending';
const color = hasInProgress ? '#F59E0B' : '#EF4444';
const icon = L.divIcon({
className: '',
html: `<div class="mk-pin ${statusClass}"></div>`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -34],
});
// 팝업 내용
const allIssues = [...new Set(c.reports.flatMap(r => r.issue_types || []))];
const firstReport = c.reports[0];
const href = firstReport.repair_id
? `/pages/mechanic/repair.html?repair_id=${firstReport.repair_id}`
: `/pages/mechanic/repair.html?charger_id=${c.charger_id}&report_id=${firstReport.id}`;
const popup = `
<div class="popup-title">⚡ ${c.charger_id}</div>
<div class="popup-meta">
📍 ${c.station_name || '-'}${c.location_detail ? '<br>' + c.location_detail : ''}
${c.charger_name ? '<br>' + c.charger_name : ''}
</div>
${c.reports.length > 1
? `<div class="popup-count">📋 신고 ${c.reports.length}건</div>`
: ''}
<div class="popup-tags">
${allIssues.map(t => `<span class="popup-tag">${t}</span>`).join('')}
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<a href="${href}" class="btn btn-primary btn-sm" style="font-size:12px;text-decoration:none">🔧 조치 시작</a>
${c.reports.length > 1
? c.reports.map(r =>
`<a href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}"
style="font-size:11px;color:var(--blue);text-decoration:none;align-self:center">#${r.id}</a>`
).join('')
: ''}
</div>`;
const m = L.marker([c.gps_lat, c.gps_lng], { icon })
.addTo(mapObj)
.bindPopup(popup, { maxWidth: 280 });
markers.push(m);
});
// 모든 마커가 보이도록 뷰 조정
const bounds = L.latLngBounds(chargers.map(c => [c.gps_lat, c.gps_lng]));
mapObj.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
// 마커 1개면 줌 고정
if (chargers.length === 1) mapObj.setZoom(15);
}
load();
</script>
</body>

View File

@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>처리 이력</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.history-card {
border: 1px solid var(--gray2);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 10px;
background: white;
cursor: pointer;
transition: box-shadow .15s;
}
.history-card:hover { box-shadow: 0 3px 12px rgba(0,0,0,.1); }
.history-card.approved { border-left: 4px solid var(--green); }
.history-card.pending { border-left: 4px solid var(--orange); }
.hc-top { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; margin-bottom:8px; }
.hc-title { font-size:14px; font-weight:700; color:var(--navy); }
.hc-meta { font-size:12px; color:var(--gray4); margin-top:3px; }
.hc-tags { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; }
.hc-tag { font-size:11px; padding:2px 8px; border-radius:10px; background:var(--gray1); color:var(--text2); border:1px solid var(--gray2); }
.badge-approved { background:#D1FAE5; color:#065F46; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
.badge-pending { background:#FEF3C7; color:#92400E; font-size:11px; font-weight:700; padding:3px 10px; border-radius:10px; white-space:nowrap; }
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html" class="active">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html" class="active">🗂 처리 이력</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">내 처리 이력</h2>
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체</option>
<option value="approved">승인 완료</option>
<option value="pending">승인 대기</option>
</select>
<select id="fResult" style="width:auto">
<option value="">전체 처리상태</option>
<option value="done">완료</option>
<option value="in_progress">진행중</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
</select>
</div>
<div id="loading" class="alert alert-info">이력을 불러오는 중...</div>
<div id="error" class="alert alert-danger" style="display:none"></div>
<div id="list"></div>
<div id="empty" class="alert alert-info" style="display:none">처리 이력이 없습니다.</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const RESULT_LABEL = {
done: '✅ 완료',
in_progress: '🔧 진행중',
waiting: '⏳ 부품대기',
revisit: '🔄 재방문',
};
let allRepairs = [];
async function load() {
document.getElementById('loading').style.display = 'block';
document.getElementById('error').style.display = 'none';
try {
allRepairs = await API.get('/repairs/my');
render();
} catch(e) {
document.getElementById('error').textContent = '이력을 불러오지 못했습니다: ' + e.message;
document.getElementById('error').style.display = 'block';
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function render() {
const fStatus = document.getElementById('fStatus').value;
const fResult = document.getElementById('fResult').value;
let list = allRepairs;
if (fStatus === 'approved') list = list.filter(r => r.approved_at);
if (fStatus === 'pending') list = list.filter(r => !r.approved_at);
if (fResult) list = list.filter(r => r.result_status === fResult);
document.getElementById('empty').style.display = list.length ? 'none' : 'block';
document.getElementById('list').innerHTML = list.map(r => {
const isApproved = !!r.approved_at;
const dt = r.completed_at
? new Date(r.completed_at).toLocaleDateString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit'})
: '';
return `
<div class="history-card ${isApproved ? 'approved' : 'pending'}"
onclick="location.href='/pages/mechanic/repair.html?repair_id=${r.id}'">
<div class="hc-top">
<div>
<div class="hc-title">
${r.station_name || '-'} · ${r.charger_id || '-'}
</div>
<div class="hc-meta">${r.charger_name || ''} · 신고 ${r.report_count}건 · ${dt}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;">
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
</span>
<span style="font-size:11px;color:var(--gray4)">${RESULT_LABEL[r.result_status] || r.result_status}</span>
</div>
</div>
<div class="hc-tags">
${(r.repair_types||[]).map(t => `<span class="hc-tag">${t}</span>`).join('')}
</div>
</div>`;
}).join('');
}
document.getElementById('fStatus').onchange = render;
document.getElementById('fResult').onchange = render;
load();
</script>
</body>
</html>

View File

@@ -17,7 +17,20 @@
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="main" style="max-width:640px;margin:0 auto;">
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="max-width:640px;margin:0 auto;">
<div style="margin-bottom:14px;">
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
</div>
@@ -34,10 +47,7 @@
<div class="form-group">
<label>조치 유형 <span class="req">*</span></label>
<div class="check-group" id="repairTypes">
<label class="check-item"><input type="checkbox" value="부품교체"> 부품 교체</label>
<label class="check-item"><input type="checkbox" value="재시작"> 재시작</label>
<label class="check-item"><input type="checkbox" value="설정변경"> 설정 변경</label>
<label class="check-item"><input type="checkbox" value="기타"> 기타</label>
<div style="color:var(--gray4);font-size:12px">불러오는 중...</div>
</div>
</div>
@@ -46,6 +56,13 @@
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
</div>
<!-- 사진 안내 -->
<div style="background:#FFF8E6;border:1px solid #FFD600;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;line-height:1.7;">
📌 <strong>촬영 필수 항목</strong><br>
· 충전기 <strong>명판(제조사·모델명)</strong><strong>충전기 식별 ID</strong>가 선명하게 보이도록 촬영해 주세요.<br>
· 조치 전·후 상태를 각각 촬영하면 검증에 도움이 됩니다.
</div>
<!-- 조치 전 사진 -->
<div class="form-row">
<div class="form-group">
@@ -64,23 +81,50 @@
</div>
</div>
<div class="form-group">
<label>처리 상태 <span class="req">*</span></label>
<select id="resultStatus">
<option value="done">✅ 처리 완료</option>
<option value="waiting">⏳ 부품 대기</option>
<option value="revisit">🔄 재방문 필요</option>
</select>
</div>
<div class="alert alert-info" style="margin-bottom:14px;">
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록)
</div>
<div id="gpsStatus" class="alert alert-info" style="margin-bottom:14px;">
📍 위치 정보 수집 중...
</div>
<input type="hidden" id="mechanicLat">
<input type="hidden" id="mechanicLng">
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">조치 완료 저장</button>
<!-- 저장 버튼 영역 -->
<div style="background:var(--gray1);border:1px solid var(--gray2);border-radius:10px;padding:16px;margin-top:4px;">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:10px;">💾 저장 방식 선택</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="btn btn-outline btn-lg" id="saveBtn" style="flex:1;min-width:140px;" onclick="submitForm(false)">
💾 상태 저장
</button>
<button class="btn btn-primary btn-lg" id="doneBtn" style="flex:1;min-width:140px;" onclick="submitForm(true)">
✅ 조치 완료 저장
</button>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;">
<div style="flex:1;min-width:140px;">
<label style="font-size:11px;color:var(--gray4)">저장 상태 선택</label>
<select id="resultStatus" style="width:100%;margin-top:4px;font-size:13px;">
<option value="in_progress">🔧 계속 진행 중</option>
<option value="waiting">⏳ 부품 대기</option>
<option value="revisit">🔄 재방문 필요</option>
</select>
</div>
<div style="flex:1;min-width:140px;display:flex;align-items:flex-end;">
<div style="font-size:11px;color:var(--gray4);padding-bottom:6px;line-height:1.6;">
<strong>조치 완료 저장</strong>은 처리 완료로 확정됩니다.<br>
💾 <strong>상태 저장</strong>은 왼쪽 상태로 임시 저장됩니다.
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- max-width wrapper -->
</div><!-- .main -->
</div><!-- .layout -->
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
@@ -89,16 +133,20 @@
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search);
const chargerId = params.get('charger_id');
const params = new URLSearchParams(location.search);
const repairId = params.get('repair_id'); // 편집 모드
const chargerId = params.get('charger_id'); // 신규 모드
const initReportId = params.get('report_id');
const startTime = new Date();
const isEditMode = !!repairId;
const startTime = new Date();
document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR');
const selectedReports = new Set();
if (initReportId) selectedReports.add(parseInt(initReportId));
async function load() {
// ── 신규 모드 ──
async function loadCreate() {
const charger = await API.get('/chargers/' + chargerId);
document.getElementById('chargerCard').innerHTML = `
<div class="card-title">⚡ 충전기 정보</div>
@@ -106,79 +154,219 @@ async function load() {
<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><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name||'-'}</strong></div>
</div>`;
const reports = await API.get('/repairs/charger/' + chargerId + '/open');
const list = document.getElementById('reportList');
if (!reports.length) {
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
return;
}
const list = document.getElementById('reportList');
if (!reports.length) { list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>'; return; }
list.innerHTML = reports.map(r => `
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:${selectedReports.has(r.id)?'#E3EDFF':'white'}">
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''}
value="${r.id}"
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}"
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
onchange="toggleReport(${r.id}, this.checked, this.closest('label'))">
onchange="toggleReport(${r.id},this.checked,this.closest('label'))">
<div>
<div><strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
<div style="font-size:11px;color:var(--gray4)">${Auth.fmtDt(r.reported_at)}</div>
${r.photos.length
? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>`
: ''}
${r.photos.length ? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>` : ''}
</div>
</label>`).join('');
}
function toggleReport(id, checked, label) {
if (checked) { selectedReports.add(id); label.style.background = '#E3EDFF'; }
else { selectedReports.delete(id); label.style.background = 'white'; }
if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; }
else { selectedReports.delete(id); label.style.background='white'; }
}
// ── 편집 모드 ──
async function loadEdit() {
let repair;
try { repair = await API.get('/repairs/' + repairId); }
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
// 헤더 업데이트
document.querySelector('h2, .main h2') && (document.querySelector('.main > div > h2') || document.querySelector('h2'))?.remove?.();
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
// 충전기 카드
document.getElementById('chargerCard').innerHTML = `
<div class="card-title">⚡ 충전기 정보</div>
<div class="form-row">
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${repair.charger_id||'-'}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${repair.charger_name||'-'}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${repair.station_name||'-'}</strong></div>
</div>`;
// 연결된 신고 (읽기 전용)
document.getElementById('reportList').innerHTML = (repair.reports||[]).length
? (repair.reports||[]).map(r => `
<div style="padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;background:#F8FAFF;">
<strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
</div>`).join('')
: '<div class="alert alert-info">연결된 신고 없음</div>';
// 승인 완료 → 잠금
if (repair.approved_at) {
const dt = new Date(repair.approved_at).toLocaleString('ko-KR');
document.querySelector('.card:last-child').innerHTML = `
<div class="alert alert-success" style="margin-bottom:0">
✅ <strong>관리자 승인 완료</strong> (${repair.approved_by_name||''} · ${dt})<br>
<span style="font-size:12px;">승인된 조치는 수정할 수 없습니다.</span>
</div>
${renderRepairView(repair)}`;
return;
}
// 폼 미리채우기 — 조치유형 동적 로드 후 체크 복원
await loadRepairTypes(repair.repair_types || []);
document.getElementById('description').value = repair.description || '';
const sel = document.getElementById('resultStatus');
if (repair.result_status && sel.querySelector(`option[value="${repair.result_status}"]`))
sel.value = repair.result_status;
// 기존 사진 표시
renderExistingPhotos(repair);
}
function renderRepairView(r) {
const LABEL = {done:'✅ 완료',in_progress:'🔧 진행중',waiting:'⏳ 부품대기',revisit:'🔄 재방문'};
const photoHtml = (type, list) => (list||[]).length
? `<div style="margin-top:8px"><label style="font-size:11px;font-weight:700;color:var(--navy2)">${type}</label>
<div class="photo-preview">${(list||[]).map(p=>`<img src="${p.path||p}" onclick="window.open('${p.path||p}')" style="cursor:zoom-in">`).join('')}</div></div>`
: '';
return `<div style="padding:14px 0">
<table style="font-size:13px;width:100%">
<tr><td style="color:var(--gray4);width:90px">조치유형</td><td>${(r.repair_types||[]).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">조치내용</td><td>${r.description||'-'}</td></tr>
<tr><td style="color:var(--gray4)">처리결과</td><td>${LABEL[r.result_status]||r.result_status}</td></tr>
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(r.completed_at)}</td></tr>
</table>
${photoHtml('조치 전 사진', r.photos_before)}
${photoHtml('조치 후 사진', r.photos_after)}
</div>`;
}
function renderExistingPhotos(repair) {
const mkGrid = (list, type) => {
if (!list || !list.length) return '';
return `<div style="display:flex;flex-wrap:wrap;gap:7px;margin-bottom:8px;">
${list.map(p => `
<div style="position:relative;">
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
<button onclick="deleteRepairPhoto(${repair.id},${p.id},'${type}')"
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
</div>`).join('')}
</div>`;
};
const bWrap = document.getElementById('previewBefore');
const aWrap = document.getElementById('previewAfter');
if (repair.photos_before?.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
if (repair.photos_after?.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
}
async function deleteRepairPhoto(rId, pId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
await API.delete(`/repairs/${rId}/photos/${pId}`);
location.reload();
} catch(e) { alert(e.message); }
}
// GPS 수집
navigator.geolocation?.getCurrentPosition(
pos => {
document.getElementById('mechanicLat').value = pos.coords.latitude;
document.getElementById('mechanicLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').className = 'alert alert-success';
document.getElementById('gpsStatus').innerHTML =
`📍 위치 수집 완료 <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
},
() => {
document.getElementById('gpsStatus').className = 'alert alert-warn';
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다.';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
// 이미지 압축 + 다중 선택 프리뷰
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
document.getElementById('submitBtn').addEventListener('click', async () => {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
async function submitForm(isDone) {
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; }
const desc = document.getElementById('description').value.trim();
if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; }
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '저장 중...';
if (!isEditMode) {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
}
const saveBtn = document.getElementById('saveBtn');
const doneBtn = document.getElementById('doneBtn');
saveBtn.disabled = doneBtn.disabled = true;
(isDone ? doneBtn : saveBtn).textContent = '저장 중...';
const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value;
const lat = document.getElementById('mechanicLat').value;
const lng = document.getElementById('mechanicLng').value;
const fd = new FormData();
fd.append('report_ids', JSON.stringify(rids));
fd.append('repair_types', JSON.stringify(types));
fd.append('description', desc);
fd.append('result_status', document.getElementById('resultStatus').value);
fd.append('result_status', resultStatus);
if (lat) fd.append('mechanic_lat', lat);
if (lng) fd.append('mechanic_lng', lng);
Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f));
Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f));
try {
await API.post('/repairs', fd);
alert('✅ 조치 완료 저장되었습니다.');
location.href = '/pages/mechanic/dashboard.html';
if (isEditMode) {
await API.put('/repairs/' + repairId, fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
} else {
fd.append('report_ids', JSON.stringify([...selectedReports]));
await API.post('/repairs', fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
}
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '조치 완료 저장';
saveBtn.disabled = doneBtn.disabled = false;
saveBtn.textContent = '💾 상태 저장';
doneBtn.textContent = '✅ 조치 완료 저장';
}
});
}
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
}
load();
async function loadRepairTypes(preChecked = []) {
try {
const types = await API.get('/settings/repair-types');
document.getElementById('repairTypes').innerHTML = types.map(t => `
<label class="check-item">
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
${t.label}
</label>`).join('');
} catch(e) {
document.getElementById('repairTypes').innerHTML =
'<div class="alert alert-danger" style="margin:0">조치유형을 불러오지 못했습니다.</div>';
}
}
if (isEditMode) {
loadEdit();
} else {
loadRepairTypes();
loadCreate();
}
</script>
</body>
</html>

View File

@@ -6,24 +6,39 @@
<link rel="stylesheet" href="/css/style.css">
<style>
#reader{width:100%;max-width:400px;margin:0 auto;border-radius:10px;overflow:hidden;}
.scan-wrap{max-width:480px;margin:0 auto;padding:20px;}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">QR 스캔</span>
<span class="nav-brand">EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="scan-wrap">
<div class="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>
<div style="margin-top:16px;">
<div class="form-group">
<label>충전기 ID 직접 입력</label>
<div style="display:flex;gap:8px;">
<input type="text" id="manualId" placeholder="예: CG-003">
<button class="btn btn-primary" onclick="goManual()">이동</button>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html" class="active">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="max-width:480px;margin:0 auto;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:16px">📷 QR 스캔</h2>
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
<div id="reader"></div>
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
<div style="margin-top:16px;">
<div class="form-group">
<label>충전기 ID 직접 입력</label>
<div style="display:flex;gap:8px;">
<input type="text" id="manualId" placeholder="예: CG-003">
<button class="btn btn-primary" onclick="goManual()">이동</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -221,7 +221,8 @@ body { background: var(--gray1); }
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
<div class="issue-grid" id="issueGrid"></div>
<div id="errorCodeWrap" style="margin-top:10px;display:none;">
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">
<!-- populated as dropdown or text input depending on chargerErrors -->
<div id="errorCodeInner"></div>
</div>
<div id="etcWrap" style="margin-top:10px;display:none;">
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
@@ -260,6 +261,24 @@ body { background: var(--gray1); }
</div>
</div>
<div class="section">
<h3>📡 신고 범위</h3>
<div style="display:flex;flex-direction:column;gap:10px">
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
<input type="radio" name="scope" value="single" checked style="width:auto;accent-color:var(--accent)">
<div><strong>이 충전기만</strong><div style="font-size:11px;color:var(--gray4)">현재 스캔한 충전기에만 신고</div></div>
</label>
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
<input type="radio" name="scope" value="station" style="width:auto;accent-color:var(--accent)">
<div><strong>충전소 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전소의 모든 충전기에 신고</div></div>
</label>
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
<input type="radio" name="scope" value="type" style="width:auto;accent-color:var(--accent)">
<div><strong>동일 모델 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전기 모델 전체에 신고</div></div>
</label>
</div>
</div>
<div class="section">
<h3>📝 상세 설명 (선택)</h3>
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
@@ -294,7 +313,7 @@ body { background: var(--gray1); }
<script src="/js/api.js"></script>
<script src="/js/imageCompress.js"></script>
<script>
const ISSUES = [
let ISSUES = [
{key:'충전불가', label:'⚡ 충전 불가'},
{key:'화면오류', label:'🖥 화면 오류'},
{key:'케이블불량',label:'🔌 케이블 불량'},
@@ -303,6 +322,7 @@ const ISSUES = [
{key:'에러발생', label:'⚠️ 에러 발생'},
{key:'기타', label:'📋 기타'},
];
let chargerErrors = [];
const STATUS_ICON = {
pending_approval: '🕐',
@@ -327,7 +347,11 @@ let isStatusOpen = true;
// ── 충전기 정보 로드 ──
async function loadCharger() {
try {
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
const [c, errs] = await Promise.all([
fetch('/api/chargers/' + chargerId).then(r => r.json()),
fetch('/api/chargers/' + chargerId + '/errors').then(r => r.json()).catch(() => []),
]);
chargerErrors = errs;
document.getElementById('chargerInfo').innerHTML = `
<h2>⚡ ${c.name}</h2>
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
@@ -435,23 +459,67 @@ navigator.geolocation?.getCurrentPosition(
}
);
// ── 에러코드 UI 갱신 ──
function updateErrorCodeUI() {
const wrap = document.getElementById('errorCodeWrap');
const inner = document.getElementById('errorCodeInner');
if (!selected.has('에러발생')) { wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
if (chargerErrors.length > 0) {
inner.innerHTML = `
<select id="errorCode" style="width:100%">
<option value="">-- 에러코드 선택 --</option>
${chargerErrors.map(e =>
`<option value="${e.error_code}">${e.error_code}${e.error_name}${e.range_condition ? ' ('+e.range_condition+')' : ''}</option>`
).join('')}
<option value="__other__">기타 (직접 입력)</option>
</select>
<input type="text" id="errorCodeCustom" placeholder="에러코드 직접 입력" style="margin-top:6px;display:none">`;
document.getElementById('errorCode').onchange = function() {
document.getElementById('errorCodeCustom').style.display =
this.value === '__other__' ? 'block' : 'none';
};
} else {
inner.innerHTML = `<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">`;
}
}
// ── 에러코드 값 가져오기 ──
function getErrorCodeValue() {
const sel = document.getElementById('errorCode');
if (!sel) return '';
if (sel.tagName === 'SELECT') {
if (sel.value === '__other__') return document.getElementById('errorCodeCustom')?.value || '';
return sel.value;
}
return sel.value;
}
// ── 문제 유형 버튼 ──
const grid = document.getElementById('issueGrid');
ISSUES.forEach(issue => {
const btn = document.createElement('button');
btn.className = 'issue-btn';
btn.textContent = issue.label;
btn.type = 'button';
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';
document.getElementById('etcWrap').style.display =
selected.has('기타') ? 'block' : 'none';
};
grid.appendChild(btn);
});
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;
btn.type = 'button';
btn.onclick = () => {
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
else { selected.add(issue.key); btn.classList.add('sel'); }
updateErrorCodeUI();
document.getElementById('etcWrap').style.display =
selected.has('기타') ? 'block' : 'none';
};
grid.appendChild(btn);
});
}
fetch('/api/settings/issue-types')
.then(r => r.json())
.then(data => { if (Array.isArray(data) && data.length) { ISSUES = data; } })
.catch(() => {})
.finally(() => renderIssueButtons(ISSUES));
// ── 이미지 압축 + 다중 선택 ──
ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2');
@@ -474,11 +542,14 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...';
const scope = document.querySelector('input[name="scope"]:checked')?.value || 'single';
const fd = new FormData();
fd.append('charger_id', chargerId);
fd.append('scope', scope);
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('detail').value);
fd.append('error_code', document.getElementById('errorCode').value);
fd.append('error_code', getErrorCodeValue());
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
fd.append('contact', contact);
fd.append('consent', consent);
@@ -488,12 +559,13 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports', { method: 'POST', body: fd });
const res = await fetch('/api/reports/batch', { method: 'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
document.getElementById('mainForm').style.display = 'none';
document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`;
const label = data.count > 1 ? `접수번호: #${data.primary_id}${data.count-1}` : `접수번호: #${data.primary_id}`;
document.getElementById('resultMsg').textContent = label;
// 현황 새로고침
document.getElementById('statusSection').style.display = 'none';
document.getElementById('noReportNotice').style.display = 'none';