1차완료

This commit is contained in:
byun
2026-06-02 19:34:36 +09:00
parent 9f0f4326fe
commit b6863cd260
28 changed files with 1667 additions and 460 deletions

View File

@@ -542,22 +542,52 @@ def stats_top_chargers(limit: int = 10):
db.close()
@app.get("/api/stats/charger-error-codes")
def stats_charger_error_codes(code_limit: int = 10):
"""에러코드별 누적 건수 Top N (단순 순위)."""
@app.get("/api/stats/top-stations")
def stats_top_stations(limit: int = 10):
"""충전소별 누적 고장 신고 건수 Top N."""
from database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
try:
rows = db.execute(text("""
SELECT TRIM(error_code) AS error_code, COUNT(*) AS cnt
SELECT COALESCE(c.station_name, rep.charger_id) AS station_name,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE rep.status = 'done') AS done_cnt,
COUNT(*) FILTER (WHERE rep.status != 'done') AS active_cnt
FROM reports rep
LEFT JOIN chargers c ON c.id = rep.charger_id
GROUP BY COALESCE(c.station_name, rep.charger_id)
ORDER BY total DESC
LIMIT :lim
"""), {"lim": limit}).fetchall()
return [
{
"station_name": row[0],
"total": int(row[1]),
"done": int(row[2]),
"active": int(row[3]),
}
for row in rows
]
finally:
db.close()
@app.get("/api/stats/charger-error-codes")
def stats_charger_error_codes(code_limit: int = 10):
"""에러코드별 누적 건수 Top N (에러코드 없음 포함)."""
from database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
try:
rows = db.execute(text("""
SELECT COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음') AS error_code,
COUNT(*) AS cnt
FROM reports
WHERE error_code IS NOT NULL AND TRIM(error_code) != ''
GROUP BY TRIM(error_code)
GROUP BY COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음')
ORDER BY cnt DESC
LIMIT :limit
"""), {"limit": code_limit}).fetchall()
# 역순: 차트 Y축에서 1위가 맨 위
result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)]
return {"error_codes": result}
finally:

View File

@@ -64,6 +64,9 @@ class Report(Base):
closed_at = Column(TIMESTAMP)
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
re_dispatch_count = Column(Integer, default=0)
report_scope = Column(String(20), default="single") # single | station | type | multi
scope_charger_count = Column(Integer, default=1)
charger_ids = Column(ARRAY(Text), nullable=True) # multi 범위일 때 선택된 충전기 ID 목록
charger = relationship("Charger", back_populates="reports")
photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan")
repair_links = relationship("RepairReport", back_populates="report")
@@ -121,16 +124,20 @@ class RepairCost(Base):
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), unique=True)
root_cause = Column(Text)
admin_note = Column(Text)
cost_party_type = Column(String(20))
cost_party_manufacturer_id = Column(Integer, ForeignKey("users.id"))
cost_party_custom = Column(String(100))
cost_party_type = Column(String(50))
cost_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
cost_party_custom = Column(String(200))
recv_party_type = Column(String(50))
recv_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
recv_party_custom = Column(String(200))
cost_amount = Column(Integer, default=0)
cost_status = Column(String(20), default="pending")
reviewed_by = Column(Integer, ForeignKey("users.id"))
reviewed_at = Column(TIMESTAMP)
repair = relationship("Repair", back_populates="cost")
reviewer = relationship("User", foreign_keys=[reviewed_by])
manufacturer = relationship("User", foreign_keys=[cost_party_manufacturer_id])
cost_manufacturer = relationship("Manufacturer", foreign_keys=[cost_party_manufacturer_id])
recv_manufacturer = relationship("Manufacturer", foreign_keys=[recv_party_manufacturer_id])
class Improvement(Base):
__tablename__ = "improvements"
@@ -141,13 +148,13 @@ class Improvement(Base):
priority = Column(String(10), default="normal")
part_name = Column(String(100))
status = Column(String(20), default="registered")
manufacturer_id = Column(Integer, ForeignKey("users.id"))
manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
created_by = Column(Integer, ForeignKey("users.id"))
sw_deploy_target = Column(Date)
sw_deployed_at = Column(Date)
manufacturer_memo = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
manufacturer = relationship("User", foreign_keys=[manufacturer_id])
manufacturer = relationship("Manufacturer", foreign_keys=[manufacturer_id])
creator = relationship("User", foreign_keys=[created_by])
report_links = relationship("ImprovementReport", back_populates="improvement", cascade="all, delete-orphan")
attachments = relationship("ImprovementAttachment", back_populates="improvement", cascade="all, delete-orphan")

View File

@@ -40,9 +40,14 @@ def list_costs(
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"root_cause": cost.root_cause, "admin_note": cost.admin_note,
"cost_party_type": cost.cost_party_type,
"cost_party_manufacturer_id": cost.cost_party_manufacturer_id,
"cost_party_custom": cost.cost_party_custom,
"cost_manufacturer_name": cost.cost_manufacturer.name if cost.cost_manufacturer else None,
"recv_party_type": cost.recv_party_type,
"recv_party_manufacturer_id": cost.recv_party_manufacturer_id,
"recv_party_custom": cost.recv_party_custom,
"recv_manufacturer_name": cost.recv_manufacturer.name if cost.recv_manufacturer else None,
"cost_amount": cost.cost_amount, "cost_status": cost.cost_status,
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
"reviewed_by_name": cost.reviewer.name if cost.reviewer else None,
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
})
@@ -79,6 +84,9 @@ def upsert_cost(
cost_party_type: str = Form(...),
cost_party_manufacturer_id: Optional[int] = Form(None),
cost_party_custom: str = Form(""),
recv_party_type: str = Form(""),
recv_party_manufacturer_id: Optional[int] = Form(None),
recv_party_custom: str = Form(""),
cost_amount: int = Form(0),
cost_status: str = Form("pending"),
db: Session = Depends(get_db),
@@ -93,6 +101,9 @@ def upsert_cost(
cost.cost_party_type = cost_party_type
cost.cost_party_manufacturer_id = cost_party_manufacturer_id or None
cost.cost_party_custom = cost_party_custom or None
cost.recv_party_type = recv_party_type or None
cost.recv_party_manufacturer_id = recv_party_manufacturer_id or None
cost.recv_party_custom = recv_party_custom or None
cost.cost_amount = cost_amount; cost.cost_status = cost_status
cost.reviewed_by = current_user.id; cost.reviewed_at = datetime.now()
else:
@@ -101,6 +112,9 @@ def upsert_cost(
cost_party_type=cost_party_type,
cost_party_manufacturer_id=cost_party_manufacturer_id or None,
cost_party_custom=cost_party_custom or None,
recv_party_type=recv_party_type or None,
recv_party_manufacturer_id=recv_party_manufacturer_id or None,
recv_party_custom=recv_party_custom or None,
cost_amount=cost_amount, cost_status=cost_status,
reviewed_by=current_user.id, reviewed_at=datetime.now()
)

View File

@@ -1,10 +1,11 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from io import BytesIO
from datetime import datetime
from datetime import datetime, timedelta
from urllib.parse import quote
from typing import Optional
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from database import get_db
@@ -15,17 +16,27 @@ router = APIRouter(prefix="/api/export", tags=["export"])
NAVY = "0B1E3D"
LIGHT = "D6EAF8"
GREEN = "1B5E20"
ORANGE = "E65100"
PURPLE = "4A148C"
TEAL = "004D40"
def style_header(ws, headers, row=1):
def _hdr_cell(ws, row, col, value, color=NAVY):
bd = Side(style="thin", color="AAAAAA")
c = ws.cell(row=row, column=col, value=value)
c.font = Font(bold=True, color="FFFFFF", size=10)
c.fill = PatternFill("solid", fgColor=color)
c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
c.border = Border(left=bd, right=bd, top=bd, bottom=bd)
def style_header(ws, headers, row=1, color=NAVY):
for col, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=h)
cell.font = Font(bold=True, color="FFFFFF", size=11)
cell.fill = PatternFill("solid", fgColor=NAVY)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
_hdr_cell(ws, row, col, h, color)
ws.row_dimensions[row].height = 20
def style_row(ws, row_num, num_cols, even=True):
bd = Side(style="thin", color="DDDDDD")
for col in range(1, num_cols + 1):
@@ -35,51 +46,357 @@ def style_row(ws, row_num, num_cols, even=True):
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
cell.alignment = Alignment(vertical="center", wrap_text=True)
def fmt_dt(dt):
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
def fmt_d(d):
return str(d) if d else ""
def elapsed(start, end):
if not start or not end: return ""
diff = end - start
total = int(diff.total_seconds())
if total < 0: return ""
h, m = divmod(total // 60, 60)
return f"{h}시간 {m}"
def set_col_widths(ws, widths):
for i, w in enumerate(widths, 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
def make_response(wb: openpyxl.Workbook, korean_name: str) -> StreamingResponse:
"""엑셀 파일을 StreamingResponse로 반환 — 한글 파일명 URL 인코딩 처리"""
buf = BytesIO()
wb.save(buf)
buf.seek(0)
date_str = datetime.now().strftime("%Y%m%d_%H%M")
filename = f"{korean_name}_{date_str}.xlsx"
encoded = quote(filename, safe="") # 한글 URL 인코딩
cd_header = f"attachment; filename*=UTF-8''{encoded}"
encoded = quote(filename, safe="")
cd = f"attachment; filename*=UTF-8''{encoded}"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": cd_header},
headers={"Content-Disposition": cd},
)
def _parse_dates(date_from, date_to):
try:
dt_from = datetime.strptime(date_from, "%Y-%m-%d") if date_from else None
dt_to = (datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)) if date_to else None
except ValueError:
raise HTTPException(400, "날짜 형식 오류 (YYYY-MM-DD)")
return dt_from, dt_to
# ─────────────────────────────────────────────
# 1. AS 신고 목록
# 시트 빌더 — 공용
# ─────────────────────────────────────────────
CLOSURE_LABEL = {
"natural": "증상자연소거",
"remote_reset": "원격리셋후증상소거",
"false_alarm": "인지오류",
"other": "기타",
}
SOURCE_LABEL = {
"qr": "QR스캔",
"admin": "관리자접수",
"dashboard": "대시보드접수",
}
COST_STATUS_LABEL = {
"pending": "미처리", "billed": "청구완료", "waived": "면제", "settled": "정산완료",
}
PARTY_LABEL = {
"cpo": "CPO(운영사)", "manufacturer": "업체", "self": "자체부담",
"user": "사용자과실", "other": "기타",
}
IMP_STATUS_LABEL = {
"registered": "등록", "reviewing": "검토중", "developing": "개발중",
"deployed": "배포완료", "done": "완료",
}
RESULT_LABEL = {
"done": "완료", "in_progress": "진행중", "waiting": "부품대기", "revisit": "재방문",
}
def _ws_reports(wb, db, dt_from, dt_to):
ws = wb.create_sheet("AS신고이력")
ws.freeze_panes = "A2"
headers = [
"신고번호", "충전기ID", "충전기종류", "충전기명", "충전소명", "CPO",
"문제유형", "에러코드", "상세설명",
"신고자연락처", "문제발생시각", "신고일시", "신고출처", "신고자",
"처리상태", "담당정비사", "소속",
"조치유형", "조치내용", "조치시작", "조치완료", "작업소요시간",
"재조치횟수", "문제원인", "비고",
"출장비부담", "출장비금액(원)", "출장비상태",
"상황종료사유", "상황종료일시",
]
style_header(ws, headers, color=NAVY)
set_col_widths(ws, [10,14,14,14,18,12,22,12,26,14,16,16,10,14,12,12,12,16,26,16,16,12,8,24,24,14,12,12,18,16])
q = db.query(models.Report)
if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
if dt_to: q = q.filter(models.Report.reported_at < dt_to)
rows = q.order_by(desc(models.Report.reported_at)).all()
for rn, r in enumerate(rows, 2):
c = r.charger
repair = r.repair_links[0].repair if r.repair_links else None
cost = repair.cost if repair else None
ws.append([
r.id,
r.charger_id,
c.charger_type.name if c and c.charger_type else "",
c.name if c else "",
c.station_name if c else "",
c.cpo_name if c else "",
", ".join(r.issue_types or []),
r.error_code or "",
r.issue_detail or "",
r.contact or "",
fmt_dt(r.occurred_at),
fmt_dt(r.reported_at),
SOURCE_LABEL.get(r.source or "qr", r.source or ""),
r.reporter.name if r.reporter else "",
r.status,
repair.mechanic.name if repair and repair.mechanic else "",
repair.mechanic.company if repair and repair.mechanic else "",
", ".join(repair.repair_types or []) if repair else "",
repair.description if repair else "",
fmt_dt(repair.started_at) if repair else "",
fmt_dt(repair.completed_at) if repair else "",
elapsed(repair.started_at, repair.completed_at) if repair else "",
r.re_dispatch_count or 0,
cost.root_cause if cost else "",
cost.admin_note if cost else "",
PARTY_LABEL.get(cost.cost_party_type, cost.cost_party_type or "") if cost else "",
cost.cost_amount if cost else "",
COST_STATUS_LABEL.get(cost.cost_status, cost.cost_status or "") if cost else "",
CLOSURE_LABEL.get(r.closure_type or "", ""),
fmt_dt(r.closed_at),
])
style_row(ws, rn, len(headers), rn % 2 == 0)
ws.row_dimensions[rn].height = 16
return len(rows)
def _ws_repairs(wb, db, dt_from, dt_to):
ws = wb.create_sheet("조치이력")
ws.freeze_panes = "A2"
headers = [
"조치번호", "연결신고번호", "충전기ID", "충전소명", "충전기종류",
"정비사", "소속",
"조치유형", "조치내용",
"시작시각", "완료시각", "소요시간",
"처리결과", "재조치요청",
"승인완료", "승인자", "승인일시",
]
style_header(ws, headers, color=GREEN)
set_col_widths(ws, [10,16,14,18,14,12,14,18,30,16,16,12,10,10,10,12,16])
q = db.query(models.Repair)
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
rows = q.order_by(desc(models.Repair.completed_at)).all()
for rn, rep in enumerate(rows, 2):
rids = [rr.report_id for rr in rep.report_links]
charger_id = station = ctype = ""
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r and r.charger:
charger_id = r.charger_id
station = r.charger.station_name or ""
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
ws.append([
rep.id,
", ".join(str(i) for i in rids),
charger_id, station, ctype,
rep.mechanic.name if rep.mechanic else "",
rep.mechanic.company if rep.mechanic else "",
", ".join(rep.repair_types or []),
rep.description or "",
fmt_dt(rep.started_at),
fmt_dt(rep.completed_at),
elapsed(rep.started_at, rep.completed_at),
RESULT_LABEL.get(rep.result_status or "", rep.result_status or ""),
"" if rep.re_dispatch_requested else "아니오",
"" if rep.approved_at else "아니오",
rep.approver.name if rep.approver else "",
fmt_dt(rep.approved_at),
])
style_row(ws, rn, len(headers), rn % 2 == 0)
ws.row_dimensions[rn].height = 16
return len(rows)
def _ws_improvements(wb, db, dt_from, dt_to):
ws = wb.create_sheet("개선항목")
ws.freeze_panes = "A2"
headers = [
"번호", "제목", "분류", "우선순위", "개선내용", "관련부품",
"담당업체", "담당자(대표)", "연락처",
"연결AS건수", "연결AS번호",
"진행상태", "SW배포목표일", "SW실제배포일",
"제조사메모", "등록자", "등록일시",
]
style_header(ws, headers, color=PURPLE)
set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16])
q = db.query(models.Improvement)
if dt_from: q = q.filter(models.Improvement.created_at >= dt_from)
if dt_to: q = q.filter(models.Improvement.created_at < dt_to)
rows = q.order_by(desc(models.Improvement.created_at)).all()
for rn, imp in enumerate(rows, 2):
rids = [ir.report_id for ir in imp.report_links]
ws.append([
imp.id, imp.title, imp.category, imp.priority,
imp.description, imp.part_name or "",
imp.manufacturer.name if imp.manufacturer else "",
imp.manufacturer.representative_name if imp.manufacturer else "",
imp.manufacturer.phone if imp.manufacturer else "",
len(rids),
", ".join(str(i) for i in rids),
IMP_STATUS_LABEL.get(imp.status, imp.status),
fmt_d(imp.sw_deploy_target),
fmt_d(imp.sw_deployed_at),
imp.manufacturer_memo or "",
imp.creator.name if imp.creator else "",
fmt_dt(imp.created_at),
])
style_row(ws, rn, len(headers), rn % 2 == 0)
ws.row_dimensions[rn].height = 16
return len(rows)
def _ws_costs(wb, db, dt_from, dt_to):
ws = wb.create_sheet("출장비정산")
ws.freeze_panes = "A2"
headers = [
"신고번호", "충전기ID", "충전소명", "충전기종류",
"정비사", "소속", "조치완료일",
"문제원인", "비고",
"부담주체유형", "부담업체명", "부담기타",
"수급주체유형", "수급업체명", "수급기타",
"금액(원)", "처리상태",
"처리담당자", "처리일시",
]
style_header(ws, headers, color=ORANGE)
set_col_widths(ws, [14,14,18,14,12,14,16,26,26,12,16,16,12,16,16,12,12,12,16])
q = db.query(models.RepairCost).join(models.Repair)
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
rows = q.order_by(desc(models.Repair.completed_at)).all()
for rn, cost in enumerate(rows, 2):
repair = cost.repair
rids = [rr.report_id for rr in repair.report_links]
charger_id = station = ctype = ""
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r and r.charger:
charger_id = r.charger_id
station = r.charger.station_name or ""
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
ws.append([
", ".join(str(i) for i in rids),
charger_id, station, ctype,
repair.mechanic.name if repair.mechanic else "",
repair.mechanic.company if repair.mechanic else "",
fmt_dt(repair.completed_at),
cost.root_cause or "",
cost.admin_note or "",
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""),
cost.cost_manufacturer.name if cost.cost_manufacturer else "",
cost.cost_party_custom or "",
PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""),
cost.recv_manufacturer.name if cost.recv_manufacturer else "",
cost.recv_party_custom or "",
cost.cost_amount or 0,
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""),
cost.reviewer.name if cost.reviewer else "",
fmt_dt(cost.reviewed_at),
])
style_row(ws, rn, len(headers), rn % 2 == 0)
ws.row_dimensions[rn].height = 16
return len(rows)
def _ws_summary(wb, counts, dt_from, dt_to, date_from, date_to):
ws = wb.create_sheet("요약", 0) # 맨 앞에 삽입
ws.sheet_view.showGridLines = False
period = f"{date_from or '전체'} ~ {date_to or '전체'}"
def lbl(row, col, text, bold=False, size=11, color="1A2B4A"):
c = ws.cell(row=row, column=col, value=text)
c.font = Font(bold=bold, size=size, color=color)
c.alignment = Alignment(vertical="center")
def val(row, col, text, color="1A2B4A", bold=True):
c = ws.cell(row=row, column=col, value=text)
c.font = Font(bold=bold, size=13, color=color)
c.alignment = Alignment(vertical="center", horizontal="center")
ws.row_dimensions[1].height = 16
ws.row_dimensions[2].height = 36
ws.row_dimensions[3].height = 14
c = ws.cell(row=2, column=2, value="EV AS 관리 통합 이력")
c.font = Font(bold=True, size=18, color=NAVY)
c.alignment = Alignment(vertical="center")
ws.cell(row=2, column=5, value=f"조회 기간: {period}").font = Font(size=11, color="666666")
sheet_names = ["AS신고이력", "조치이력", "개선항목", "출장비정산"]
colors = [NAVY, GREEN, PURPLE, ORANGE]
labels = ["AS 신고", "조치 이력", "개선항목", "출장비 정산"]
for i, (name, color, label, cnt) in enumerate(zip(sheet_names, colors, labels, counts)):
row = 5 + i * 3
ws.row_dimensions[row].height = 24
ws.row_dimensions[row+1].height = 22
cell = ws.cell(row=row, column=2, value=f"{label}")
cell.font = Font(bold=True, size=12, color=color)
cell.alignment = Alignment(vertical="center")
ws.cell(row=row, column=3, value=f"{cnt}").font = Font(bold=True, size=14, color=color)
ws.cell(row=row+1, column=2,
value=f'자세한 내용은 "{name}" 시트를 확인하세요').font = Font(size=9, color="888888")
ws.column_dimensions["A"].width = 4
ws.column_dimensions["B"].width = 22
ws.column_dimensions["C"].width = 14
ws.column_dimensions["D"].width = 10
ws.column_dimensions["E"].width = 30
# ─────────────────────────────────────────────
# 1. AS 신고 목록 (개별)
# ─────────────────────────────────────────────
@router.get("/reports")
def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
def export_reports(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
_=Depends(require_admin)
):
dt_from, dt_to = _parse_dates(date_from, date_to)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "AS신고목록"
ws.freeze_panes = "A2"
CLOSURE_LABEL = {
"natural": "증상자연소거",
"remote_reset": "원격리셋후증상소거",
"false_alarm": "인지오류",
"other": "기타",
}
headers = [
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
@@ -92,14 +409,15 @@ 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,10,16,12,
set_col_widths(ws, [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18,
18,24,16,14,10]
for i, w in enumerate(col_widths, 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
18,24,16,14,10])
q = db.query(models.Report)
if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
if dt_to: q = q.filter(models.Report.reported_at < dt_to)
reports = q.order_by(desc(models.Report.reported_at)).all()
reports = db.query(models.Report).order_by(desc(models.Report.reported_at)).all()
for row_num, r in enumerate(reports, 2):
c = r.charger
repair = r.repair_links[0].repair if r.repair_links else None
@@ -108,10 +426,8 @@ 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 = [
seq_no,
r.id,
r.charger_id,
c.charger_type.name if c and c.charger_type else "",
c.name if c else "",
@@ -120,18 +436,18 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
fmt_d(c.installed_at) if c else "",
r.gps_lat or "",
r.gps_lng or "",
", ".join(r.issue_types) if r.issue_types else "",
", ".join(r.issue_types or []),
r.error_code or "",
r.issue_detail or "",
r.contact or "",
fmt_dt(r.occurred_at),
fmt_dt(r.reported_at),
{"qr": "QR스캔", "admin": "관리자접수", "dashboard": "대시보드접수"}.get(r.source or "qr", r.source or "qr"),
SOURCE_LABEL.get(r.source or "qr", r.source or ""),
r.reporter.name if r.reporter else "",
r.status,
repair.mechanic.name if repair and repair.mechanic else "",
repair.mechanic.company if repair and repair.mechanic else "",
", ".join(repair.repair_types) if repair and repair.repair_types else "",
", ".join(repair.repair_types or []) if repair else "",
repair.description if repair else "",
fmt_dt(repair.started_at) if repair else "",
fmt_dt(repair.completed_at) if repair else "",
@@ -139,13 +455,13 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None),
cost.root_cause if cost else "",
cost.admin_note if cost else "",
cost.cost_party_type if cost else "",
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or "") if cost else "",
cost.cost_amount if cost else "",
cost.cost_status if cost else "",
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or "") if cost else "",
cost.reviewer.name if cost and cost.reviewer else "",
fmt_dt(cost.reviewed_at) if cost else "",
", ".join(str(i) for i in imp_ids) if imp_ids else "",
CLOSURE_LABEL.get(r.closure_type, "") if r.closure_type else "",
CLOSURE_LABEL.get(r.closure_type or "", ""),
r.closure_note or "",
fmt_dt(r.closed_at),
r.closer.name if r.closer else "",
@@ -160,10 +476,16 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
# ─────────────────────────────────────────────
# 2. 출장비 목록
# 2. 출장비 목록 (개별)
# ─────────────────────────────────────────────
@router.get("/costs")
def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
def export_costs(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
_=Depends(require_admin)
):
dt_from, dt_to = _parse_dates(date_from, date_to)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "출장비목록"
@@ -172,39 +494,45 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
headers = [
"신고번호","충전기ID","충전기종류","충전소명","조치완료일",
"정비사","소속","문제원인","비고",
"출장비부담주체","제조사","금액(원)","처리상태",
"처리담당자","처리일시"
"부담주체유형","부담업체","부담기타",
"수급주체유형","수급업체명","수급기타",
"금액(원)","처리상태","처리담당자","처리일시"
]
style_header(ws, headers)
for i, w in enumerate([10,14,14,18,16,12,14,24,24,16,16,12,12,12,16], 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
set_col_widths(ws, [14,14,14,18,16,12,14,26,26,12,16,16,12,16,16,12,12,12,16])
costs = db.query(models.RepairCost).join(models.Repair).order_by(
desc(models.RepairCost.reviewed_at)).all()
q = db.query(models.RepairCost).join(models.Repair)
if dt_from: q = q.filter(models.Repair.completed_at >= dt_from)
if dt_to: q = q.filter(models.Repair.completed_at < dt_to)
costs = q.order_by(desc(models.Repair.completed_at)).all()
for row_num, cost in enumerate(costs, 2):
repair = cost.repair
rids = [rr.report_id for rr in repair.report_links]
charger_id = station_name = charger_type = ""
charger_id = station = ctype = ""
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r and r.charger:
charger_id = r.charger_id
station_name = r.charger.station_name
charger_type = r.charger.charger_type.name if r.charger.charger_type else ""
station = r.charger.station_name or ""
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
row_data = [
", ".join(str(i) for i in rids),
charger_id, charger_type, station_name,
charger_id, ctype, station,
fmt_dt(repair.completed_at),
repair.mechanic.name if repair.mechanic else "",
repair.mechanic.company if repair.mechanic else "",
cost.root_cause or "",
cost.admin_note or "",
cost.cost_party_type or "",
cost.manufacturer.company if cost.manufacturer else (cost.cost_party_custom or ""),
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""),
cost.cost_manufacturer.name if cost.cost_manufacturer else "",
cost.cost_party_custom or "",
PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""),
cost.recv_manufacturer.name if cost.recv_manufacturer else "",
cost.recv_party_custom or "",
cost.cost_amount or 0,
cost.cost_status or "",
COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""),
cost.reviewer.name if cost.reviewer else "",
fmt_dt(cost.reviewed_at),
]
@@ -217,10 +545,16 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
# ─────────────────────────────────────────────
# 3. 개선항목 목록
# 3. 개선항목 목록 (개별)
# ─────────────────────────────────────────────
@router.get("/improvements")
def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)):
def export_improvements(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
_=Depends(require_admin)
):
dt_from, dt_to = _parse_dates(date_from, date_to)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "개선항목목록"
@@ -228,40 +562,29 @@ 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,24,12,14,14,24,12,16], 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16])
q = db.query(models.Improvement)
if dt_from: q = q.filter(models.Improvement.created_at >= dt_from)
if dt_to: q = q.filter(models.Improvement.created_at < dt_to)
imps = q.order_by(desc(models.Improvement.created_at)).all()
imps = db.query(models.Improvement).order_by(desc(models.Improvement.created_at)).all()
for row_num, imp in enumerate(imps, 2):
rids = [ir.report_id for ir in imp.report_links]
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 "",
imp.manufacturer.company if imp.manufacturer else "",
imp.manufacturer.name if imp.manufacturer else "",
imp.manufacturer.representative_name if imp.manufacturer else "",
imp.manufacturer.phone if imp.manufacturer else "",
len(rids),
", ".join(str(i) for i in rids),
"\n".join(reporters),
imp.status,
IMP_STATUS_LABEL.get(imp.status, imp.status),
fmt_d(imp.sw_deploy_target),
fmt_d(imp.sw_deployed_at),
imp.manufacturer_memo or "",
@@ -274,3 +597,28 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
ws.row_dimensions[row_num].height = 16
return make_response(wb, "개선항목목록")
# ─────────────────────────────────────────────
# 4. 통합 다운로드 (멀티 시트)
# ─────────────────────────────────────────────
@router.get("/full")
def export_full(
date_from: Optional[str] = None,
date_to: Optional[str] = None,
db: Session = Depends(get_db),
_=Depends(require_admin)
):
dt_from, dt_to = _parse_dates(date_from, date_to)
wb = openpyxl.Workbook()
wb.remove(wb.active) # 기본 빈 시트 제거
c1 = _ws_reports(wb, db, dt_from, dt_to)
c2 = _ws_repairs(wb, db, dt_from, dt_to)
c3 = _ws_improvements(wb, db, dt_from, dt_to)
c4 = _ws_costs(wb, db, dt_from, dt_to)
_ws_summary(wb, [c1, c2, c3, c4], dt_from, dt_to, date_from, date_to)
period = f"{date_from or '전체'}~{date_to or '전체'}"
return make_response(wb, f"EV_AS_통합이력_{period}")

View File

@@ -1,7 +1,7 @@
import json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
from sqlalchemy.orm import Session
from sqlalchemy import desc, text
from sqlalchemy import desc, text, func
from typing import List, Optional
from datetime import datetime
from database import get_db
@@ -18,7 +18,7 @@ def _fmt(imp: models.Improvement):
"part_name": imp.part_name, "status": imp.status,
"manufacturer_id": imp.manufacturer_id,
"manufacturer_name": imp.manufacturer.name if imp.manufacturer else None,
"manufacturer_company": imp.manufacturer.company if imp.manufacturer else None,
"manufacturer_company": None,
"created_by_name": imp.creator.name if imp.creator else None,
"sw_deploy_target": str(imp.sw_deploy_target) if imp.sw_deploy_target else None,
"sw_deployed_at": str(imp.sw_deployed_at) if imp.sw_deployed_at else None,
@@ -27,7 +27,7 @@ def _fmt(imp: models.Improvement):
"report_ids": [ir.report_id for ir in imp.report_links],
"report_count": len(imp.report_links),
"attachments": [{"path": a.file_path, "name": a.file_name} for a in imp.attachments],
"logs": [{"old": l.old_status, "new": l.new_status, "memo": l.memo,
"logs": [{"old_status": l.old_status, "new_status": l.new_status, "memo": l.memo,
"changed_at": l.changed_at.isoformat(),
"by": l.changer.name if l.changer else None} for l in imp.logs],
}
@@ -52,7 +52,22 @@ def get_improvement(imp_id: int, db: Session = Depends(get_db),
if not imp: raise HTTPException(404)
if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id:
raise HTTPException(403)
return _fmt(imp)
result = _fmt(imp)
rids = [ir.report_id for ir in imp.report_links]
if rids:
seq_subq = db.query(
models.Report.id.label("rid"),
func.row_number().over(
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
).label("seq")
).subquery()
seqs = {row.rid: row.seq for row in
db.query(seq_subq.c.rid, seq_subq.c.seq)
.filter(seq_subq.c.rid.in_(rids)).all()}
result["report_links"] = [{"id": rid, "seq": seqs.get(rid, rid)} for rid in rids]
else:
result["report_links"] = []
return result
@router.post("")
async def create_improvement(

View File

@@ -36,6 +36,16 @@ def _fmt_repair(repair: models.Repair) -> dict:
"issue_types": r.issue_types,
"status": r.status,
})
# 같은 신고에 연결된 조치 목록에서 현재 조치의 순번 계산 (오래된 것=1차)
attempt = 1
if repair.report_links:
first_report = repair.report_links[0].report
if first_report and first_report.repair_links:
all_repair_ids = sorted(rl.repair_id for rl in first_report.repair_links)
if repair.id in all_repair_ids:
attempt = all_repair_ids.index(repair.id) + 1
return {
"id": repair.id,
"charger_id": charger_id,
@@ -56,6 +66,7 @@ def _fmt_repair(repair: models.Repair) -> dict:
"photos_after": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "after"],
"reports": reports,
"report_count": len(reports),
"attempt": attempt,
}

View File

@@ -49,6 +49,10 @@ def _fmt_report(r: models.Report, db: Session):
"closure_note": r.closure_note,
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
"closed_by_name": r.closer.name if r.closer else None,
"re_dispatch_count": r.re_dispatch_count or 0,
"report_scope": r.report_scope or "single",
"scope_charger_count": r.scope_charger_count or 1,
"charger_ids": r.charger_ids or [],
}
@router.post("")
@@ -105,7 +109,8 @@ async def create_report(
@router.post("/batch")
async def create_batch_report(
charger_id: str = Form(...),
scope: str = Form("single"), # single | station | type
scope: str = Form("single"), # single | station | type | multi
charger_ids: Optional[str] = Form(None), # JSON: ["id1","id2",...] for multi scope
issue_types: str = Form(...),
issue_detail: str = Form(""),
error_code: str = Form(""),
@@ -124,14 +129,28 @@ async def create_batch_report(
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(
selected_ids = None
if scope == "multi" and charger_ids:
selected_ids = json.loads(charger_ids)
all_targets = [charger]
report_scope = "multi"
scope_charger_count = len(selected_ids)
elif scope == "station":
all_targets = db.query(models.Charger).filter_by(
station_name=charger.station_name, is_active=True).all()
report_scope = "station"
scope_charger_count = len(all_targets)
elif scope == "type" and charger.charger_type_id:
targets = db.query(models.Charger).filter_by(
all_targets = db.query(models.Charger).filter_by(
charger_type_id=charger.charger_type_id, is_active=True).all()
report_scope = "type"
scope_charger_count = len(all_targets)
else:
targets = [charger]
all_targets = [charger]
report_scope = "single"
scope_charger_count = 1
targets = [charger] # 항상 단일 신고 생성
setting = db.query(models.SystemSetting).filter_by(key="report_visibility_policy").first()
policy = setting.value if setting else "immediate"
@@ -143,7 +162,6 @@ async def create_batch_report(
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:
@@ -160,6 +178,9 @@ async def create_batch_report(
ocpp_log=ocpp_log or None,
source=source_value,
reported_by=current_user.id if current_user else None,
report_scope=report_scope,
scope_charger_count=scope_charger_count,
charger_ids=selected_ids,
)
db.add(r); db.commit(); db.refresh(r)
@@ -181,6 +202,7 @@ async def create_batch_report(
def list_reports(
status: Optional[str] = None,
charger_id: Optional[str] = None,
station_name: Optional[str] = None,
active_only: bool = False,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
@@ -196,12 +218,17 @@ def list_reports(
q = (db.query(models.Report, seq_subq.c.seq)
.join(seq_subq, models.Report.id == seq_subq.c.rid)
.order_by(desc(models.Report.reported_at)))
if status:
if status == "pending_all":
q = q.filter(models.Report.status.in_(["pending", "pending_approval"]))
elif status:
q = q.filter(models.Report.status == status)
elif active_only:
q = q.filter(models.Report.status.in_(
["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
if charger_id: q = q.filter(models.Report.charger_id == charger_id)
if station_name:
q = (q.join(models.Charger, models.Report.charger_id == models.Charger.id, isouter=True)
.filter(models.Charger.station_name == station_name))
if current_user.role == "mechanic":
q = q.filter(models.Report.status != "pending_approval")
@@ -254,20 +281,26 @@ def get_report(report_id: int, db: Session = Depends(get_db),
"root_cause": cost.root_cause,
"admin_note": cost.admin_note,
"cost_party_type": cost.cost_party_type,
"cost_party_manufacturer_id": cost.cost_party_manufacturer_id,
"cost_party_custom": cost.cost_party_custom,
"cost_manufacturer_name": cost.cost_manufacturer.name if cost.cost_manufacturer else None,
"recv_party_type": cost.recv_party_type,
"recv_party_manufacturer_id": cost.recv_party_manufacturer_id,
"recv_party_custom": cost.recv_party_custom,
"recv_manufacturer_name": cost.recv_manufacturer.name if cost.recv_manufacturer else None,
"cost_amount": cost.cost_amount,
"cost_status": cost.cost_status,
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
} if (cost and include_cost) else None,
"reviewed_by_name": cost.reviewer.name if cost.reviewer else None,
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
} if cost else None,
"linked_improvements": _get_linked_improvements(repair, db) if include_cost else [],
}
if r.repair_links:
sorted_links = sorted(r.repair_links, key=lambda l: l.repair_id, reverse=True)
result["repair"] = _fmt_one_repair(sorted_links[0].repair)
# 재조치로 인한 이전 조치 이력 (최신 제외, re_dispatch_requested=True인 것)
result["prev_repairs"] = [
_fmt_one_repair(link.repair, include_cost=False)
_fmt_one_repair(link.repair, include_cost=True)
for link in sorted_links[1:]
if link.repair
]
@@ -306,6 +339,8 @@ def bulk_delete_reports(
@router.patch("/{report_id}")
async def update_report(
report_id: int,
charger_id: Optional[str] = Form(None),
scope: Optional[str] = Form(None),
issue_types: Optional[str] = Form(None),
issue_detail: Optional[str] = Form(None),
error_code: Optional[str] = Form(None),
@@ -320,6 +355,24 @@ async def update_report(
import json
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
if charger_id is not None and charger_id.strip():
ch = db.query(models.Charger).filter_by(id=charger_id.strip()).first()
if not ch: raise HTTPException(400, "충전기를 찾을 수 없습니다")
r.charger_id = charger_id.strip()
if scope is not None and scope in ("single", "station", "type"):
ref = db.query(models.Charger).filter_by(id=r.charger_id).first()
if scope == "station" and ref:
count = db.query(models.Charger).filter_by(
station_name=ref.station_name, is_active=True).count()
elif scope == "type" and ref and ref.charger_type_id:
count = db.query(models.Charger).filter_by(
charger_type_id=ref.charger_type_id, is_active=True).count()
else:
count = 1
r.report_scope = scope
r.scope_charger_count = count
if scope != "multi":
r.charger_ids = None
if issue_types is not None:
r.issue_types = json.loads(issue_types)
if issue_detail is not None:

View File

@@ -6,9 +6,10 @@
#btnDelete { display:none; }
</style></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -20,6 +21,7 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html" class="active">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -15,11 +15,12 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -31,6 +32,7 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -37,9 +37,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -51,6 +52,7 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -12,9 +12,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -26,6 +27,7 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
@@ -49,7 +51,7 @@
<select id="fParty" style="width:auto">
<option value="">전체 부담주체</option>
<option value="cpo">CPO</option>
<option value="manufacturer">제조사</option>
<option value="manufacturer">업체</option>
<option value="self">자체</option>
<option value="user">사용자과실</option>
<option value="other">기타</option>
@@ -60,7 +62,7 @@
<table>
<thead><tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th>
<th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>수급주체</th><th>금액</th><th>상태</th><th>처리일시</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
@@ -94,7 +96,7 @@ async function bulkDelete() {
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
const PARTY_LABEL = {cpo:'CPO',manufacturer:'업체',self:'자체',user:'사용자과실',other:'기타'};
async function load() {
const [statsData, costs] = await Promise.all([API.get('/costs/stats'), API.get('/costs?cost_status='+document.getElementById('fStatus').value+'&cost_party_type='+document.getElementById('fParty').value)]);
@@ -115,7 +117,8 @@ async function load() {
<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">${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'&&c.cost_manufacturer_name?`<br><small>${c.cost_manufacturer_name}</small>`:''}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${c.recv_party_type?(PARTY_LABEL[c.recv_party_type]||c.recv_party_type):'-'}${c.recv_party_type==='manufacturer'&&c.recv_manufacturer_name?`<br><small>${c.recv_manufacturer_name}</small>`:''}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer;font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${Auth.costStatusBadge(c.cost_status)}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${Auth.fmtDt(c.reviewed_at)}</td>

View File

@@ -70,12 +70,21 @@
.charger-option.selected { background: #EFF6FF; }
.charger-option .opt-name { font-weight: 600; color: var(--navy); }
.charger-option .opt-sub { font-size: 11px; color: var(--gray4); margin-top: 2px; }
.charger-selected-badge {
display: none; margin-top: 6px; padding: 7px 10px;
background: #EFF6FF; border: 1px solid #BFDBFE;
border-radius: 6px; font-size: 12px; color: var(--navy2);
.selected-chargers-list {
display: none; flex-wrap: wrap; gap: 5px;
margin-top: 8px;
}
.charger-selected-badge.show { display: flex; justify-content: space-between; align-items: center; }
.selected-chargers-list.show { display: flex; }
.sel-tag {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; background: #EFF6FF; border: 1px solid #BFDBFE;
border-radius: 20px; font-size: 12px; color: var(--navy2);
}
.sel-tag button {
background: none; border: none; cursor: pointer; color: var(--gray4);
font-size: 13px; line-height: 1; padding: 0 1px;
}
.sel-tag button:hover { color: var(--red); }
/* ── 증상 체크박스 ── */
.issue-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 4px; }
@@ -139,15 +148,31 @@
}
.detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.detail-tab:hover:not(.active) { color: var(--navy2); }
/* ── 대시보드 레이아웃 클래스 ── */
.dash-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:10px; }
.time-metrics-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; }
.dash-chart-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.dash-chart-wrap { position:relative; height:220px; }
.dash-bottom-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:768px) {
.time-metrics-grid { grid-template-columns:repeat(2,1fr); }
.dash-chart-wrap { height:170px; }
.dash-bottom-grid { grid-template-columns:1fr; }
#adminMapWrap { height:260px !important; }
.btn-report-new { font-size:12px; padding:7px 12px; }
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -159,10 +184,11 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<div class="dash-header">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin:0">대시보드</h2>
<button class="btn-report-new" onclick="openReportModal()">+ 신고 접수</button>
</div>
@@ -171,7 +197,7 @@
<!-- 처리 시간 지표 카드 -->
<div class="card" id="timeMetrics" style="margin-bottom:20px">
<div class="card-title">⏱ 처리 시간 지표</div>
<div id="timeMetricsBody" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:14px"></div>
<div id="timeMetricsBody" class="time-metrics-grid"></div>
</div>
<!-- 드릴다운 뒤로가기 -->
@@ -183,7 +209,7 @@
<!-- 월별 처리시간 차트 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0" id="monthlyChartTitle">📈 월별 평균 처리시간</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span id="monthlyChartMode" style="font-size:11px;color:var(--gray4)"></span>
@@ -194,28 +220,28 @@
</div>
</div>
</div>
<div style="position:relative;height:220px">
<div class="dash-chart-wrap">
<canvas id="monthlyChart"></canvas>
</div>
</div>
<!-- 월별 신고 접수 건수 차트 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0" id="monthlyReportChartTitle">📊 월별 신고 접수 건수</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#CBD5E1;margin-right:3px"></span>미처리</span>
</div>
</div>
<div style="position:relative;height:220px">
<div class="dash-chart-wrap">
<canvas id="monthlyReportChart"></canvas>
</div>
</div>
<!-- 충전기별 누적 고장 Top 10 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0">🏆 충전기별 누적 고장 건수 Top 10</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
@@ -227,18 +253,32 @@
</div>
</div>
<!-- 충전소별 누적 고장 Top 10 -->
<div class="card" style="margin-bottom:20px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0">🏢 충전소별 누적 고장 건수 Top 10</div>
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#3B82F6;margin-right:3px"></span>처리 완료</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#FCA5A5;margin-right:3px"></span>미처리</span>
</div>
</div>
<div style="position:relative" id="topStationsWrap">
<canvas id="topStationsChart"></canvas>
</div>
</div>
<!-- 충전기별 에러코드 누적 순위 -->
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div class="dash-chart-head">
<div class="card-title" style="margin:0">⚠️ 에러코드 누적 순위 Top 10</div>
<span style="font-size:11px;color:var(--gray4)">에러코드 입력된 신고 기준</span>
<span style="font-size:11px;color:var(--gray4)">전체 신고 기준 (에러코드 없음 포함)</span>
</div>
<div id="errorCodesChartWrap" style="position:relative">
<canvas id="errorCodesChart"></canvas>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="dash-bottom-grid">
<div class="card">
<div class="card-title">🔴 접수 대기 현황 <span id="pendingSort" style="font-size:11px;font-weight:400;color:var(--gray4)">(오래된 순)</span></div>
<div id="recentReports"></div>
@@ -284,10 +324,7 @@
onfocus="openDropdown()" autocomplete="off">
<div class="charger-dropdown" id="chargerDropdown"></div>
</div>
<div class="charger-selected-badge" id="selectedBadge">
<span id="selectedBadgeText"></span>
<button onclick="clearCharger()" style="background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px"></button>
</div>
<div class="selected-chargers-list" id="selectedChargersList"></div>
</div>
<!-- 발생 일시 -->
@@ -311,7 +348,7 @@
</div>
<!-- 신고 범위 -->
<div class="form-row">
<div class="form-row" id="scopeRow">
<label class="form-label">신고 범위</label>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
@@ -320,11 +357,11 @@
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="scope" value="station" style="width:auto;accent-color:var(--accent)">
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">같은 충전소 모든 충전기</span></span>
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">신고 1건으로 충전소 모든 충전기 대상</span></span>
</label>
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
<input type="radio" name="scope" value="type" style="width:auto;accent-color:var(--accent)">
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">— 같은 충전기 모델 전체</span></span>
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">신고 1건으로 같은 모델 전체 대상</span></span>
</label>
</div>
</div>
@@ -396,7 +433,7 @@ Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
let allChargers = [];
let selectedChargerId = null;
let selectedChargers = [];
let cachedIssueTypes = null;
let selectedChargerErrors = [];
@@ -425,6 +462,7 @@ async function load() {
]);
loadMonthlyChart();
loadTopChargersChart();
loadTopStationsChart();
loadErrorCodesChart();
/* ── 통계 카드 ── */
@@ -433,7 +471,7 @@ async function load() {
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html'">
<div class="stat-num">${stats.total}</div><div class="stat-label">전체 신고</div>
</div>
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'">
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'">
<div class="stat-num">${stats.pending}</div><div class="stat-label">접수 대기</div>
</div>
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=in_progress'">
@@ -448,11 +486,11 @@ async function load() {
<div class="stat warn stat-link" onclick="location.href='/pages/admin/improvements.html'">
<div class="stat-num">${stats.improvement_open}</div><div class="stat-label">개선항목 진행중</div>
</div>
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'" style="border-top:3px solid var(--accent)">
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'" style="border-top:3px solid var(--accent)">
<div class="stat-num" style="font-size:22px">${fmtHours(stats.avg_resolution_hours_30d)}</div>
<div class="stat-label">평균 처리 시간<br><small>(최근 30일)</small></div>
</div>
<div class="stat ${over72Class} stat-link" onclick="location.href='/pages/admin/reports.html?status=pending'" style="border-top:3px solid ${stats.pending_over_72h>0?'var(--red)':'var(--green)'}">
<div class="stat ${over72Class} stat-link" onclick="location.href='/pages/admin/reports.html?status=pending_all'" style="border-top:3px solid ${stats.pending_over_72h>0?'var(--red)':'var(--green)'}">
<div class="stat-num">${stats.pending_over_72h}</div>
<div class="stat-label">72h+ 장기 대기</div>
</div>
@@ -636,13 +674,103 @@ async function loadTopChargersChart() {
});
}
/* ── Top 10 충전소별 누적 고장 차트 ── */
let _topStationsChart = null;
async function loadTopStationsChart() {
const data = await API.get('/stats/top-stations');
if (!data.length) return;
const rev = [...data].reverse();
const labels = rev.map(d => {
const s = d.station_name || '-';
return s.length > 22 ? s.slice(0, 20) + '…' : s;
});
const rowH = 32;
const height = rev.length * rowH + 40;
document.getElementById('topStationsWrap').style.height = height + 'px';
if (_topStationsChart) _topStationsChart.destroy();
const ctx = document.getElementById('topStationsChart').getContext('2d');
_topStationsChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{
label: '처리 완료',
data: rev.map(d => d.done),
backgroundColor: '#3B82F6',
borderRadius: 3,
stack: 'top',
},
{
label: '미처리',
data: rev.map(d => d.active),
backgroundColor: '#FCA5A5',
borderRadius: 3,
stack: 'top',
},
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
onClick: (evt, elems) => {
if (!elems.length) return;
const d = rev[elems[0].index];
location.href = `/pages/admin/reports.html?station_name=${encodeURIComponent(d.station_name)}`;
},
onHover: (evt, elems) => {
evt.native.target.style.cursor = elems.length ? 'pointer' : 'default';
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: items => rev[items[0].dataIndex].station_name,
label: c => {
const d = rev[c.dataIndex];
return [
`총 누적: ${d.total}`,
`처리 완료: ${d.done}`,
`미처리: ${d.active}`,
];
},
afterLabel: () => '(클릭하면 신고 목록으로)',
}
}
},
scales: {
x: {
stacked: true,
grid: { color: '#F1F5F9' },
border: { dash: [3, 3] },
beginAtZero: true,
ticks: {
font: { size: 11 }, color: '#64748B', stepSize: 1,
callback: v => Number.isInteger(v) ? v + '건' : '',
}
},
y: {
stacked: true,
grid: { display: false },
ticks: { font: { size: 12 }, color: '#334155' }
}
}
}
});
}
/* ── 에러코드별 누적 순위 차트 ── */
let _errorCodesChart = null;
async function loadErrorCodesChart() {
const data = await API.get('/stats/charger-error-codes');
const wrap = document.getElementById('errorCodesChartWrap');
if (!data.error_codes || !data.error_codes.length) {
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">신고 데이터가 없습니다.</div>';
return;
}
const { error_codes } = data;
@@ -655,7 +783,7 @@ async function loadErrorCodesChart() {
labels: error_codes.map(e => e.error_code),
datasets: [{
data: error_codes.map(e => e.total),
backgroundColor: '#1565C0',
backgroundColor: error_codes.map(e => e.error_code === '에러코드 없음' ? '#94A3B8' : '#1565C0'),
borderRadius: 4,
}]
},
@@ -667,7 +795,9 @@ async function loadErrorCodesChart() {
legend: { display: false },
tooltip: {
callbacks: {
title: items => `에러코드: ${error_codes[items[0].dataIndex].error_code}`,
title: items => error_codes[items[0].dataIndex].error_code === '에러코드 없음'
? '에러코드 없음'
: `에러코드: ${error_codes[items[0].dataIndex].error_code}`,
label: c => `누적 ${c.raw}`,
}
}
@@ -983,11 +1113,12 @@ function closeReportModal() {
}
function resetModal() {
selectedChargerId = null;
selectedChargers = [];
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('chargerDropdown').classList.remove('open');
document.getElementById('selectedBadge').classList.remove('show');
renderSelectedChargers();
updateScopeVisibility();
document.getElementById('occurredAt').value = '';
document.getElementById('issueDetail').value = '';
document.getElementById('contact').value = '';
@@ -1016,7 +1147,7 @@ function filterChargers(q) {
: allChargers.slice(0, 50);
dd.innerHTML = filtered.map(c => `
<div class="charger-option ${c.id === selectedChargerId ? 'selected' : ''}"
<div class="charger-option ${selectedChargers.some(s => s.id === c.id) ? 'selected' : ''}"
onclick="selectCharger('${c.id}', '${escHtml(c.station_name)}', '${escHtml(c.name)}', '${escHtml(c.location_detail||'')}')">
<div class="opt-name">${escHtml(c.station_name)} · ${escHtml(c.name)}</div>
<div class="opt-sub">${c.id}${c.location_detail ? ' · ' + escHtml(c.location_detail) : ''}</div>
@@ -1030,18 +1161,59 @@ function openDropdown() {
}
async function selectCharger(id, station, name, region) {
selectedChargerId = id;
if (selectedChargers.some(c => c.id === id)) {
document.getElementById('chargerDropdown').classList.remove('open');
document.getElementById('chargerSearchInput').value = '';
return;
}
selectedChargers.push({ id, station, name, region });
document.getElementById('chargerSearchInput').value = '';
document.getElementById('chargerDropdown').classList.remove('open');
const badge = document.getElementById('selectedBadge');
document.getElementById('selectedBadgeText').textContent =
`${station} · ${name}${region ? ' (' + region + ')' : ''}`;
badge.classList.add('show');
// Load error codes for this charger type
renderSelectedChargers();
updateScopeVisibility();
if (selectedChargers.length === 1) {
try {
selectedChargerErrors = await API.get('/chargers/' + id + '/errors');
} catch { selectedChargerErrors = []; }
renderErrorCodeUI();
} else {
selectedChargerErrors = [];
renderErrorCodeUI();
}
}
function renderSelectedChargers() {
const el = document.getElementById('selectedChargersList');
if (!selectedChargers.length) {
el.innerHTML = '';
el.classList.remove('show');
return;
}
el.classList.add('show');
el.innerHTML = selectedChargers.map(c => `
<div class="sel-tag">
<span>${escHtml(c.station)} · ${escHtml(c.name)}</span>
<button onclick="removeCharger('${escHtml(c.id)}')" title="제거">✕</button>
</div>`).join('');
}
function removeCharger(id) {
selectedChargers = selectedChargers.filter(c => c.id !== id);
renderSelectedChargers();
updateScopeVisibility();
if (selectedChargers.length === 0) {
selectedChargerErrors = [];
renderErrorCodeUI();
} else if (selectedChargers.length === 1) {
API.get('/chargers/' + selectedChargers[0].id + '/errors')
.then(e => { selectedChargerErrors = e; renderErrorCodeUI(); })
.catch(() => { selectedChargerErrors = []; renderErrorCodeUI(); });
}
}
function updateScopeVisibility() {
const row = document.getElementById('scopeRow');
if (row) row.style.display = selectedChargers.length > 1 ? 'none' : '';
}
function renderErrorCodeUI() {
@@ -1075,10 +1247,11 @@ function getModalErrorCode() {
}
function clearCharger() {
selectedChargerId = null;
selectedChargers = [];
selectedChargerErrors = [];
document.getElementById('chargerSearchInput').value = '';
document.getElementById('selectedBadge').classList.remove('show');
renderSelectedChargers();
updateScopeVisibility();
renderErrorCodeUI();
}
@@ -1096,44 +1269,56 @@ function escHtml(s) {
/* ── 신고 제출 ── */
async function submitReport() {
if (!selectedChargerId) { alert('충전기를 선택해주세요.'); return; }
if (!selectedChargers.length) { alert('충전기를 선택해주세요.'); return; }
const issues = [...document.querySelectorAll('.issue-chk:checked')].map(c => c.value);
if (!issues.length) { alert('증상을 하나 이상 선택해주세요.'); return; }
const btn = document.getElementById('submitBtn');
btn.disabled = true; btn.textContent = '접수 중...';
const scope = document.querySelector('input[name="scope"]:checked')?.value || 'single';
const scope = selectedChargers.length === 1
? (document.querySelector('input[name="scope"]:checked')?.value || 'single')
: 'single';
const issueDetail = document.getElementById('issueDetail').value;
const errorCode = getModalErrorCode();
const contact = document.getElementById('contact').value;
const occurredAt = document.getElementById('occurredAt').value;
const ocppLog = document.getElementById('ocppLog').value.trim();
const photos = Array.from(document.getElementById('modalPhoto').files);
const isMulti = selectedChargers.length > 1;
const fd = new FormData();
fd.append('charger_id', selectedChargerId);
fd.append('scope', scope);
fd.append('charger_id', selectedChargers[0].id);
fd.append('scope', isMulti ? 'multi' : scope);
if (isMulti) fd.append('charger_ids', JSON.stringify(selectedChargers.map(c => c.id)));
fd.append('source', 'dashboard');
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('issueDetail').value);
fd.append('error_code', getModalErrorCode());
fd.append('contact', document.getElementById('contact').value);
fd.append('consent', 'false');
const occ = document.getElementById('occurredAt').value;
if (occ) fd.append('occurred_at', occ);
const ocppLogText = document.getElementById('ocppLog').value.trim();
if (ocppLogText) fd.append('ocpp_log', ocppLogText);
Array.from(document.getElementById('modalPhoto').files).forEach(f => fd.append('photos', f));
if (issueDetail) fd.append('issue_detail', issueDetail);
if (errorCode) fd.append('error_code', errorCode);
if (contact) fd.append('contact', contact);
if (occurredAt) fd.append('occurred_at', occurredAt);
if (ocppLog) fd.append('ocpp_log', ocppLog);
photos.forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports/batch', { method: 'POST', body: fd,
headers: { 'Authorization': 'Bearer ' + Auth.token() } });
const res = await fetch('/api/reports/batch', {
method: 'POST', body: fd,
headers: { 'Authorization': 'Bearer ' + Auth.token() },
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
closeReportModal();
load();
if (data.count > 1) {
if (isMulti) {
alert(`${selectedChargers.length}대 충전기 신고가 1건으로 접수되었습니다. (신고 #${data.primary_id})`);
} else if (data.count > 1) {
alert(`신고가 ${data.count}건 접수되었습니다. (첫 번째 신고 #${data.primary_id})`);
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
} else {
alert(`신고가 접수되었습니다. (신고 #${data.primary_id})`);
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
}
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
} catch(e) {
alert('오류: ' + e.message);
} finally {

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>데이터 내보내기</title><link rel="stylesheet" href="/css/style.css">
<style>
.export-card { background:white; border-radius:10px; padding:24px; box-shadow:0 2px 8px rgba(0,0,0,.06); margin-bottom:20px; }
.sheet-badge { display:inline-flex; align-items:center; gap:6px; padding:5px 14px; border-radius:20px; font-size:12px; font-weight:700; margin:3px; }
.date-row { display:flex; gap:14px; align-items:flex-end; flex-wrap:wrap; margin-top:18px; }
.date-row .form-group { margin:0; min-width:140px; }
.quick-btns { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
.quick-btn { padding:4px 12px; border:1px solid var(--gray3); border-radius:6px; background:white; font-size:12px; color:var(--navy); cursor:pointer; }
.quick-btn:hover { background:var(--gray1); border-color:var(--accent); }
.download-btn { padding:12px 28px; font-size:15px; font-weight:700; border-radius:8px; border:none;
background:var(--blue); color:white; cursor:pointer; display:flex; align-items:center; gap:8px;
transition:background .15s; min-width:220px; justify-content:center; }
.download-btn:hover { background:#1251A3; }
.download-btn:disabled { background:var(--gray3); cursor:not-allowed; }
.ind-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; margin-top:16px; }
.ind-item { border:1px solid var(--gray2); border-radius:8px; padding:14px 16px; }
.ind-item .ind-title { font-size:13px; font-weight:700; color:var(--navy); margin-bottom:8px; }
.ind-item .ind-desc { font-size:11px; color:var(--gray4); margin-bottom:10px; line-height:1.5; }
.status-msg { padding:10px 14px; border-radius:6px; font-size:13px; margin-top:12px; display:none; }
@media(max-width:768px){
.date-row { flex-direction:column; align-items:stretch; }
.download-btn { width:100%; }
.ind-grid { grid-template-columns:1fr; }
}
</style>
</head>
<body>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html" class="active">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">📥 데이터 내보내기</h2>
</div>
<!-- 통합 다운로드 -->
<div class="export-card">
<div class="card-title">📊 기간별 통합 다운로드</div>
<p style="font-size:13px;color:var(--gray4);margin-bottom:8px;line-height:1.7;">
설정한 기간의 모든 데이터를 <strong>하나의 엑셀 파일(4개 시트)</strong>로 다운로드합니다.
</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:4px;">
<span class="sheet-badge" style="background:#0B1E3D;color:white;">① AS 신고이력</span>
<span class="sheet-badge" style="background:#1B5E20;color:white;">② 조치이력</span>
<span class="sheet-badge" style="background:#4A148C;color:white;">③ 개선항목</span>
<span class="sheet-badge" style="background:#E65100;color:white;">④ 출장비정산</span>
<span class="sheet-badge" style="background:#37474F;color:white;">+ 요약</span>
</div>
<div class="date-row">
<div class="form-group">
<label>시작일</label>
<input type="date" id="dateFrom">
</div>
<div class="form-group">
<label>종료일</label>
<input type="date" id="dateTo">
</div>
<button class="download-btn" id="fullBtn" onclick="downloadFull()">
📥 통합 엑셀 다운로드
</button>
</div>
<div class="quick-btns">
<span style="font-size:11px;color:var(--gray4);align-self:center;">빠른 선택:</span>
<button class="quick-btn" onclick="setRange(7)">최근 7일</button>
<button class="quick-btn" onclick="setRange(30)">최근 30일</button>
<button class="quick-btn" onclick="setRange(90)">최근 3개월</button>
<button class="quick-btn" onclick="setThisMonth()">이번 달</button>
<button class="quick-btn" onclick="setLastMonth()">지난 달</button>
<button class="quick-btn" onclick="setThisYear()">올해 전체</button>
<button class="quick-btn" onclick="clearRange()">전체 기간</button>
</div>
<div id="statusMsg" class="status-msg"></div>
</div>
<!-- 개별 다운로드 -->
<div class="export-card">
<div class="card-title">📄 항목별 개별 다운로드</div>
<p style="font-size:13px;color:var(--gray4);margin-bottom:4px;">위 기간 설정이 동일하게 적용됩니다.</p>
<div class="ind-grid">
<div class="ind-item">
<div class="ind-title" style="color:#0B1E3D;">📋 AS 신고이력</div>
<div class="ind-desc">신고 접수일 기준 · 충전기/신고자/상태/<br>정비사/조치내용/출장비 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('reports')">📥 다운로드</button>
</div>
<div class="ind-item">
<div class="ind-title" style="color:#1B5E20;">🔧 조치이력</div>
<div class="ind-desc">조치 완료일 기준 · 정비사/조치유형/<br>소요시간/승인 여부 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('repairs')">📥 다운로드</button>
</div>
<div class="ind-item">
<div class="ind-title" style="color:#4A148C;">🔧 개선항목</div>
<div class="ind-desc">등록일 기준 · 분류/우선순위/<br>담당업체/진행상태 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('improvements')">📥 다운로드</button>
</div>
<div class="ind-item">
<div class="ind-title" style="color:#E65100;">💰 출장비 정산</div>
<div class="ind-desc">조치 완료일 기준 · 부담/수급 주체/<br>금액/정산상태 포함</div>
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('costs')">📥 다운로드</button>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
function pad(n) { return String(n).padStart(2,'0'); }
function fmtDate(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
function setRange(days) {
const to = new Date();
const from = new Date(); from.setDate(from.getDate() - days + 1);
document.getElementById('dateFrom').value = fmtDate(from);
document.getElementById('dateTo').value = fmtDate(to);
}
function setThisMonth() {
const now = new Date();
document.getElementById('dateFrom').value = `${now.getFullYear()}-${pad(now.getMonth()+1)}-01`;
document.getElementById('dateTo').value = fmtDate(now);
}
function setLastMonth() {
const now = new Date();
const y = now.getMonth() === 0 ? now.getFullYear()-1 : now.getFullYear();
const m = now.getMonth() === 0 ? 12 : now.getMonth();
const last = new Date(y, m, 0);
document.getElementById('dateFrom').value = `${y}-${pad(m)}-01`;
document.getElementById('dateTo').value = fmtDate(last);
}
function setThisYear() {
const now = new Date();
document.getElementById('dateFrom').value = `${now.getFullYear()}-01-01`;
document.getElementById('dateTo').value = fmtDate(now);
}
function clearRange() {
document.getElementById('dateFrom').value = '';
document.getElementById('dateTo').value = '';
}
function buildQuery() {
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
const p = [];
if (from) p.push('date_from=' + from);
if (to) p.push('date_to=' + to);
return p.length ? '?' + p.join('&') : '';
}
function showStatus(msg, type='info') {
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.style.display = 'block';
el.className = 'status-msg alert alert-' + type;
}
function hideStatus() { document.getElementById('statusMsg').style.display = 'none'; }
async function downloadFull() {
const btn = document.getElementById('fullBtn');
btn.disabled = true;
btn.textContent = '⏳ 생성 중...';
showStatus('엑셀 파일을 생성 중입니다. 데이터량에 따라 수 초가 걸릴 수 있습니다.', 'info');
try {
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
const period = (from || to) ? `${from||'전체'}~${to||'전체'}` : '전체기간';
await API.download('/export/full' + buildQuery(), `EV_AS_통합이력_${period}.xlsx`);
showStatus('✅ 다운로드가 완료되었습니다.', 'success');
} catch(e) {
showStatus('❌ 오류: ' + e.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '📥 통합 엑셀 다운로드';
setTimeout(hideStatus, 5000);
}
}
async function downloadIndividual(type) {
const names = {
reports: 'AS신고이력',
repairs: '조치이력',
improvements: '개선항목',
costs: '출장비정산',
};
try {
await API.download('/export/' + type + buildQuery(), `${names[type]}.xlsx`);
} catch(e) { alert('다운로드 오류: ' + e.message); }
}
// 기본값: 이번 달
setThisMonth();
</script>
</body></html>

View File

@@ -1,8 +1,40 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head>
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css">
<style>
.imp-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
.info-dl { display:grid; grid-template-columns:80px 1fr; gap:8px 14px; font-size:13px; align-items:start; }
.info-dl dt { color:var(--gray4); font-weight:600; padding-top:1px; }
.info-dl dd { word-break:break-word; }
.report-link { display:flex; align-items:center; gap:10px; padding:10px 12px;
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
cursor:pointer; font-size:13px; color:var(--navy); text-decoration:none; transition:background .15s; }
.report-link:hover { background:var(--gray1); }
.report-link-num { font-weight:700; color:var(--blue); flex-shrink:0; }
.file-link { display:flex; align-items:center; gap:8px; padding:9px 12px;
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
color:var(--navy); text-decoration:none; font-size:13px; transition:background .15s; overflow:hidden; }
.file-link:hover { background:var(--gray1); }
.file-link span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.page-header { display:flex; align-items:center; gap:10px; margin-bottom:18px; }
.page-header h2 { font-size:17px; font-weight:700; color:var(--navy); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.status-form { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
@media(max-width:768px) {
.imp-grid { grid-template-columns:1fr; }
.status-form { grid-template-columns:1fr; }
.info-dl { grid-template-columns:72px 1fr; gap:7px 10px; }
}
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -14,105 +46,139 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="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="page-header">
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm" style="flex-shrink:0">← 목록</a>
<h2 id="pageTitle">개선항목 상세</h2>
</div>
<div id="content"></div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
const id = new URLSearchParams(location.search).get('id');
const CAT={sw:'SW개선',hw:'HW개선',ui:'UI개선',firmware:'펌웨어',other:'기타'};
const CAT = {hardware:'하드웨어',software:'소프트웨어',firmware:'펌웨어',installation:'설치환경',ui:'UI 개선',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
const STATUS_OPTIONS = ['registered','reviewing','developing','deployed','done'];
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
async function load() {
const imp = await API.get('/improvements/'+id);
document.getElementById('pageTitle').textContent = `개선항목 #${imp.id}`;
document.getElementById('pageTitle').textContent = `#${imp.id} ${imp.title}`;
document.getElementById('content').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="imp-grid">
<!-- 기본 정보 -->
<div class="card">
<div class="card-title">📋 기본 정보</div>
<table class="no-hover" style="font-size:13px">
<tr><td style="color:var(--gray4);width:90px">제목</td><td><strong>${imp.title}</strong></td></tr>
<tr><td style="color:var(--gray4)">분류</td><td>${CAT[imp.category]||imp.category}</td></tr>
<tr><td style="color:var(--gray4)">우선순위</td><td>${imp.priority}</td></tr>
<tr><td style="color:var(--gray4)">관련 부품</td><td>${imp.part_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">담당 제조사</td><td><strong>${imp.manufacturer_company||'-'}</strong><br>${imp.manufacturer_name||''}</td></tr>
<tr><td style="color:var(--gray4)">등록자</td><td>${imp.created_by_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
<tr><td style="color:var(--gray4)">실제 배포일</td><td>${imp.sw_deployed_at||'-'}</td></tr>
<tr><td style="color:var(--gray4)">현재 상태</td><td>${Auth.statusBadge(imp.status)}</td></tr>
</table>
<div style="margin-top:12px">
<dl class="info-dl">
<dt>제목</dt> <dd><strong>${imp.title}</strong></dd>
<dt>분류</dt> <dd>${CAT[imp.category]||imp.category}</dd>
<dt>우선순위</dt> <dd>${PRI[imp.priority]||imp.priority}</dd>
<dt>관련 부품</dt> <dd>${imp.part_name||'-'}</dd>
<dt>담당 업체</dt> <dd><strong>${imp.manufacturer_name||'-'}</strong></dd>
<dt>등록자</dt> <dd>${imp.created_by_name||'-'}</dd>
<dt>등록일시</dt> <dd>${Auth.fmtDt(imp.created_at)}</dd>
<dt>배포 목표일</dt><dd>${imp.sw_deploy_target||'-'}</dd>
<dt>실제 배포일</dt><dd>${imp.sw_deployed_at||'-'}</dd>
<dt>상태</dt> <dd>${Auth.statusBadge(imp.status)}</dd>
</dl>
<div style="margin-top:14px">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">개선 내용</div>
<div style="background:var(--gray1);padding:12px;border-radius:6px;font-size:13px;white-space:pre-wrap">${imp.description}</div>
<div style="background:var(--gray1);padding:12px;border-radius:6px;font-size:13px;white-space:pre-wrap;line-height:1.7">${imp.description}</div>
</div>
${imp.manufacturer_memo?`<div style="margin-top:12px"><div style="font-size:12px;font-weight:700;color:var(--orange);margin-bottom:6px">제조사 메모</div><div style="background:#FFF5E6;padding:12px;border-radius:6px;font-size:13px">${imp.manufacturer_memo}</div></div>`:''}
${imp.manufacturer_memo ? `
<div style="margin-top:12px">
<div style="font-size:12px;font-weight:700;color:var(--orange);margin-bottom:6px">제조사 메모</div>
<div style="background:#FFF5E6;padding:12px;border-radius:6px;font-size:13px;line-height:1.6">${imp.manufacturer_memo}</div>
</div>` : ''}
</div>
<!-- 연결 AS + 첨부 -->
<div>
<div class="card">
<div class="card-title">📎 연결된 AS 신고</div>
${imp.report_ids.length ? imp.report_ids.map(rid=>`
<div onclick="location.href='/pages/admin/report-detail.html?id=${rid}'"
style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;cursor:pointer;font-size:13px">
신고 #${rid}
</div>`).join('') : '<div class="alert alert-info">연결된 신고 없음</div>'}
${(imp.report_links||[]).length
? (imp.report_links||[]).map(r => `
<a class="report-link" href="/pages/admin/report-detail.html?id=${r.id}">
<span class="report-link-num">#${r.seq}</span>
<span style="color:var(--gray4);font-size:12px">신고 상세 보기 →</span>
</a>`).join('')
: '<div class="alert alert-info" style="margin:0">연결된 신고 없음</div>'}
</div>
<div class="card-title" style="margin-top:16px">📁 첨부 파일</div>
${imp.attachments.length ? imp.attachments.map(a=>`
<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-bottom:6px;display:block">
📄 ${a.name||a.path.split('/').pop()}
</a>`).join('') : '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
<div class="card">
<div class="card-title">📁 첨부 파일</div>
${imp.attachments.length
? imp.attachments.map(a => `
<a class="file-link" href="${a.path}" target="_blank">
<span style="flex-shrink:0">📄</span>
<span>${a.name||a.path.split('/').pop()}</span>
</a>`).join('')
: '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
</div>
</div>
</div>
<!-- 상태 변경 -->
<div class="card" style="margin-top:0">
<div class="card">
<div class="card-title">🔄 상태 변경</div>
<div class="form-row">
<div class="form-group">
<label>상태 변경</label>
<div class="status-form">
<div class="form-group" style="margin:0">
<label>상태</label>
<select id="newStatus">
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>SW 실제 배포일 (배포완료 시)</label>
<div class="form-group" style="margin:0">
<label>SW 실제 배포일 <span style="color:var(--gray4);font-weight:400">(배포완료 시)</span></label>
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
</div>
</div>
<div class="form-group"><label>변경 메모</label><input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모"></div>
<button class="btn btn-primary" onclick="changeStatus()">상태 저장</button>
<div class="form-group" style="margin-top:12px">
<label>변경 메모</label>
<input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모">
</div>
<button class="btn btn-primary" onclick="changeStatus()">저장</button>
</div>
<!-- 이력 로그 -->
<div class="card" style="margin-top:0">
<!-- 변경 이력 -->
<div class="card">
<div class="card-title">📜 변경 이력</div>
${imp.logs.length ? `<div class="timeline">${imp.logs.map(l=>`
${imp.logs.length
? `<div class="timeline">${imp.logs.map(l=>`
<div class="tl-item">
<div class="tl-time">${Auth.fmtDt(l.changed_at)}${l.by||'시스템'}</div>
<div class="tl-text">${l.old_status?`${STATUS_LABEL[l.old_status]||l.old_status}`:''}${STATUS_LABEL[l.new_status]||l.new_status}${l.memo?` / ${l.memo}`:''}</div>
</div>`).join('')}</div>` : '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
</div>`).join('')}</div>`
: '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
</div>
`;
}
async function changeStatus() {
const status = document.getElementById('newStatus').value;
const memo = document.getElementById('changeMemo').value;
const date = document.getElementById('deployedAt').value;
const fd = new FormData();
fd.append('status', status); fd.append('memo', memo);
fd.append('status', document.getElementById('newStatus').value);
fd.append('memo', document.getElementById('changeMemo').value);
const date = document.getElementById('deployedAt').value;
if (date) fd.append('sw_deployed_at', date);
await API.patch('/improvements/'+id+'/status', fd);
load();
}
load();
</script></div></div></body></html>
</script>
</body></html>

View File

@@ -6,9 +6,10 @@
#btnDelete { display:none; }
</style></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -20,6 +21,7 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
@@ -62,9 +64,9 @@
<div class="form-group">
<label>분류 <span class="req">*</span></label>
<select id="mCat">
<option value="sw">SW 개선</option><option value="hw">HW 개선</option>
<option value="ui">UI 개선</option><option value="firmware">펌웨어</option>
<option value="other">기타</option>
<option value="hardware">하드웨어</option><option value="software">소프트웨어</option>
<option value="firmware">펌웨어</option><option value="installation">설치환경</option>
<option value="ui">UI 개선</option><option value="other">기타</option>
</select>
</div>
<div class="form-group">
@@ -123,20 +125,25 @@ async function bulkDelete() {
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
const CAT = {hardware:'하드웨어',software:'소프트웨어',firmware:'펌웨어',installation:'설치환경',ui:'UI',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
const selectedReports = new Set();
let allReports = [];
async function load() {
const st = document.getElementById('fStatus').value;
const mfr = document.getElementById('fMfr').value;
let impUrl = '/improvements?';
if (st) impUrl += 'status=' + st + '&';
if (mfr) impUrl += 'manufacturer_id=' + mfr + '&';
const [mfrs, imps] = await Promise.all([
API.get('/accounts?role=manufacturer'),
API.get('/improvements?status='+document.getElementById('fStatus').value+'&manufacturer_id='+document.getElementById('fMfr').value)
API.get('/manufacturers'),
API.get(impUrl)
]);
// 제조사 필터 드롭다운
const mfrSel = document.getElementById('fMfr');
if (mfrSel.options.length <= 1)
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=`${m.company||''} / ${m.name}`; mfrSel.appendChild(o); });
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=m.name; mfrSel.appendChild(o); });
document.getElementById('chkAll').checked = false;
updateDeleteBtn();
@@ -151,7 +158,7 @@ async function load() {
<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">${i.manufacturer_name||'-'}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer"><span class="badge s-pending">${i.report_count}건</span></td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.statusBadge(i.status)}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.fmtDt(i.created_at)}</td>
@@ -161,9 +168,9 @@ async function load() {
async function openModal() {
document.getElementById('modal').classList.remove('hidden');
const mfrs = await API.get('/accounts?role=manufacturer');
document.getElementById('mMfr').innerHTML = '<option value="">제조사 선택</option>' +
mfrs.map(m=>`<option value="${m.id}">${m.company||''} / ${m.name}</option>`).join('');
const mfrs = await API.get('/manufacturers');
document.getElementById('mMfr').innerHTML = '<option value="">업체 선택</option>' +
mfrs.map(m=>`<option value="${m.id}">${m.name}</option>`).join('');
allReports = await API.get('/reports');
renderReportList('');
}
@@ -171,12 +178,12 @@ function closeModal() { document.getElementById('modal').classList.add('hidden')
function searchReports() { renderReportList(document.getElementById('mReportSearch').value.toLowerCase()); }
function renderReportList(q) {
const filtered = allReports.filter(r => !q || String(r.id).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
const filtered = allReports.filter(r => !q || String(r.seq).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
document.getElementById('mReportList').innerHTML = filtered.map(r => `
<label style="display:flex;gap:8px;align-items:center;padding:5px;cursor:pointer;${selectedReports.has(r.id)?'background:#E3EDFF;border-radius:4px':''}">
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}" style="accent-color:var(--accent);flex-shrink:0"
onchange="${selectedReports.has(r.id)?'selectedReports.delete':'selectedReports.add'}(${r.id}); renderReportList('${q}')">
<span><strong>#${r.id}</strong> ${r.charger_id||''}${(r.issue_types||[]).join(', ')}</span>
<span><strong>#${r.seq}</strong> ${r.charger_id||''}${(r.issue_types||[]).join(', ')}</span>
</label>`).join('') || '<div style="color:var(--gray4)">검색 결과 없음</div>';
}

View File

@@ -59,9 +59,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -73,6 +74,7 @@
<a href="/pages/admin/issue-types.html" class="active">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -1,8 +1,9 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>QR 생성</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
@@ -14,6 +15,7 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">

View File

@@ -114,15 +114,29 @@
.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; }
/* 충전기 검색 드롭다운 */
.ec-opt { padding:9px 12px; cursor:pointer; font-size:13px; border-bottom:1px solid #F1F5F9; }
.ec-opt:hover { background:var(--gray1); }
.ec-opt:last-child { border-bottom:none; }
@media(max-width:768px) {
.cost-summary-grid { grid-template-columns:1fr !important; }
.issue-chk-grid { grid-template-columns:1fr !important; }
.no-hover td:first-child { white-space:nowrap; }
.no-hover td { word-break:break-word; }
}
</style>
</head>
<body>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
@@ -134,12 +148,13 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="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 style="margin-bottom:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm" style="flex-shrink:0">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy);min-width:0;word-break:break-word;">신고 상세</h2>
</div>
<div id="content"></div>
</div>
@@ -152,12 +167,19 @@
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
const params = new URLSearchParams(location.search);
const reportId = params.get('id');
const PARTY_LABEL = {
cpo: 'CPO (운영사)',
manufacturer: '제조사',
manufacturer: '업체',
self: '자체 부담',
user: '사용자 과실',
other: '기타',
@@ -175,23 +197,36 @@ const COST_STATUS_ICON = {
settled: '✅',
};
let editOpen = false;
function toggleEdit() {
editOpen = !editOpen;
const wrap = document.getElementById('costEditWrap');
const btn = document.getElementById('editToggleBtn');
if (editOpen) {
wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
wrap.classList.remove('collapsed');
btn.innerHTML = '▲ 수정 접기';
function toggleCostEdit(repairId) {
const wrap = document.getElementById('costEditWrap_' + repairId);
const btn = document.getElementById('editToggleBtn_' + repairId);
if (!wrap) return;
const isOpen = wrap.style.display !== 'none';
if (isOpen) {
wrap.style.display = 'none';
if (btn) btn.innerHTML = '✏️ 수정하기';
} else {
wrap.style.maxHeight = '0';
wrap.classList.add('collapsed');
btn.innerHTML = '✏️ 수정하기';
wrap.style.display = 'block';
if (btn) btn.innerHTML = '▲ 접기';
}
}
function togglePartySelect(repairId) {
const v = document.getElementById('partyType_' + repairId)?.value;
const mfr = document.getElementById('mfrWrap_' + repairId);
const cus = document.getElementById('customWrap_' + repairId);
if (mfr) mfr.style.display = v === 'manufacturer' ? 'block' : 'none';
if (cus) cus.style.display = v === 'other' ? 'block' : 'none';
}
function toggleRecvPartySelect(repairId) {
const v = document.getElementById('recvPartyType_' + repairId)?.value;
const mfr = document.getElementById('recvMfrWrap_' + repairId);
const cus = document.getElementById('recvCustomWrap_' + repairId);
if (mfr) mfr.style.display = v === 'manufacturer' ? 'block' : 'none';
if (cus) cus.style.display = v === 'other' ? 'block' : 'none';
}
const IMP_CAT_LABEL = {
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
installation:'설치환경', other:'기타'
@@ -201,158 +236,143 @@ async function load() {
const [r, issueTypes, manufacturers, improvements] = await Promise.all([
API.get('/reports/' + reportId),
API.get('/settings/issue-types'),
API.get('/accounts?role=manufacturer'),
API.get('/manufacturers/public'),
API.get('/improvements'),
]);
const repair = r.repair;
const cost = repair?.cost;
const prevRepairs = r.prev_repairs || [];
window._reportData = r; // saveReport 경고용
document.getElementById('pageTitle').textContent = `신고 #${r.seq} 상세`;
// ── 출장비 요약 HTML 생성 ──
let costHtml = '';
if (repair) {
const hasCost = cost && cost.cost_party_type;
const costStatus = cost?.cost_status || 'pending';
// ── 조치별 출장비 HTML 생성 함수 ──
function buildCostHtml(rep, mfrs) {
const c = rep.cost;
const rid = rep.id;
const hasCost = c && c.cost_party_type;
const costStatus = c?.cost_status || 'pending';
const statusLabel = COST_STATUS_LABEL[costStatus] || costStatus;
const statusIcon = COST_STATUS_ICON[costStatus] || '🕐';
// 부담 주체 텍스트
let partyText = '-';
if (cost?.cost_party_type) {
partyText = PARTY_LABEL[cost.cost_party_type] || cost.cost_party_type;
if (cost.cost_party_type === 'manufacturer' && cost.manufacturer_name) {
partyText += ` (${cost.manufacturer_name})`;
}
if (cost.cost_party_type === 'other' && cost.cost_party_custom) {
partyText += `${cost.cost_party_custom}`;
}
if (c?.cost_party_type) {
partyText = PARTY_LABEL[c.cost_party_type] || c.cost_party_type;
if (c.cost_party_type === 'manufacturer' && c.cost_manufacturer_name) partyText += ` (${c.cost_manufacturer_name})`;
if (c.cost_party_type === 'other' && c.cost_party_custom) partyText += ` ${c.cost_party_custom}`;
}
let recvText = '-';
if (c?.recv_party_type) {
recvText = PARTY_LABEL[c.recv_party_type] || c.recv_party_type;
if (c.recv_party_type === 'manufacturer' && c.recv_manufacturer_name) recvText += ` (${c.recv_manufacturer_name})`;
if (c.recv_party_type === 'other' && c.recv_party_custom) recvText += `${c.recv_party_custom}`;
}
// 요약 카드 (처리 내역이 있을 때만 표시)
const summaryHtml = hasCost ? `
<div class="cost-summary s-${costStatus}">
<div class="cost-summary s-${costStatus}" style="margin-bottom:10px;">
<div class="cost-summary-header">
<div class="cost-summary-title">
${statusIcon} 출장비 처리 내역
</div>
<div class="cost-summary-title">${statusIcon} 출장비 처리 내역</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="cost-status-badge csb-${costStatus}">${statusLabel}</span>
<button class="edit-toggle-btn" id="editToggleBtn" onclick="toggleEdit()">✏️ 수정하기</button>
<button class="edit-toggle-btn" id="editToggleBtn_${rid}" onclick="toggleCostEdit(${rid})">✏️ 수정하기</button>
</div>
</div>
<div class="cost-summary-grid">
<div class="cost-summary-item">
<label>출장비 부담 주체</label>
<span>${partyText}</span>
</div>
<div class="cost-summary-item">
<label>출장비 금액</label>
<span class="amount">${(cost.cost_amount || 0).toLocaleString()}원</span>
</div>
<div class="cost-summary-item">
<label>처리 담당자</label>
<span>${cost.reviewed_by_name || '-'}</span>
</div>
<div class="cost-summary-item">
<label>처리 일시</label>
<span>${Auth.fmtDt(cost.reviewed_at)}</span>
</div>
<div class="cost-summary-item"><label>부담 주체</label><span>${partyText}</span></div>
<div class="cost-summary-item"><label>수급 주체</label><span>${recvText}</span></div>
<div class="cost-summary-item"><label>금액</label><span class="amount">${(c.cost_amount||0).toLocaleString()}</span></div>
<div class="cost-summary-item"><label>담당자</label><span>${c.reviewed_by_name||'-'}</span></div>
<div class="cost-summary-item"><label>처리일시</label><span>${Auth.fmtDt(c.reviewed_at)}</span></div>
</div>
${(c.root_cause||c.admin_note) ? `<hr class="cost-summary-divider">
${c.root_cause ? `<div style="margin-bottom:6px;"><div style="font-size:10px;color:var(--gray4);margin-bottom:3px;">문제 원인</div><div class="cost-note-box">${c.root_cause}</div></div>` : ''}
${c.admin_note ? `<div><div style="font-size:10px;color:var(--gray4);margin-bottom:3px;">비고</div><div class="cost-note-box">${c.admin_note}</div></div>` : ''}` : ''}
</div>` : `<div style="font-size:12px;color:var(--gray4);margin-bottom:8px;">💰 출장비 미입력</div>`;
${(cost.root_cause || cost.admin_note) ? `
<hr class="cost-summary-divider">
${cost.root_cause ? `
<div style="margin-bottom:8px;">
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">문제 원인</div>
<div class="cost-note-box">${cost.root_cause}</div>
</div>` : ''}
${cost.admin_note ? `
<div>
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">비고</div>
<div class="cost-note-box">${cost.admin_note}</div>
</div>` : ''}
` : ''}
</div>` : '';
// 수정 폼 (항상 존재, 기존 미처리면 바로 펼쳐져 있음)
const formCollapsed = hasCost ? 'collapsed' : '';
if (!hasCost) editOpen = true;
costHtml = `
<div class="card" style="margin-top:0">
<div class="card-title">💰 출장비 처리${hasCost ? '' : ' (관리자)'}</div>
const formDisplay = hasCost ? 'none' : 'block';
return `
<div style="border-top:1px dashed var(--gray3);margin-top:14px;padding-top:14px;">
<div style="font-size:13px;font-weight:700;color:var(--navy);margin-bottom:10px;">💰 출장비 정산</div>
${summaryHtml}
<!-- 입력 / 수정 폼 -->
<div class="cost-edit-wrap ${formCollapsed}" id="costEditWrap">
<div class="cost-edit-inner">
<div id="costEditWrap_${rid}" style="display:${formDisplay}">
<div class="form-row">
<div class="form-group">
<label>문제 원인 파악 <span class="req">*</span></label>
<textarea id="rootCause" rows="3"
placeholder="조치 내용 검토 후 원인을 기재하세요.">${cost?.root_cause || ''}</textarea>
<label>문제 원인 <span class="req">*</span></label>
<textarea id="rootCause_${rid}" rows="3" placeholder="조치 내용 검토 후 원인 기재">${c?.root_cause||''}</textarea>
</div>
<div class="form-group">
<label>비고</label>
<textarea id="adminNote" rows="3"
placeholder="특이사항, 추가 메모 등">${cost?.admin_note || ''}</textarea>
<textarea id="adminNote_${rid}" rows="3" placeholder="특이사항">${c?.admin_note||''}</textarea>
</div>
</div>
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;">부담 주체 (비용 부담)</div>
<div class="form-row-3">
<div class="form-group">
<label>출장비 부담 주체 <span class="req">*</span></label>
<select id="partyType" onchange="toggleParty()">
<label>유형 <span class="req">*</span></label>
<select id="partyType_${rid}" onchange="togglePartySelect(${rid})">
<option value="">선택</option>
<option value="cpo" ${cost?.cost_party_type === 'cpo' ? 'selected' : ''}>CPO (운영사)</option>
<option value="manufacturer" ${cost?.cost_party_type === 'manufacturer' ? 'selected' : ''}>제조사</option>
<option value="self" ${cost?.cost_party_type === 'self' ? 'selected' : ''}>자체 부담</option>
<option value="user" ${cost?.cost_party_type === 'user' ? 'selected' : ''}>사용자 과실</option>
<option value="other" ${cost?.cost_party_type === 'other' ? 'selected' : ''}>기타</option>
<option value="cpo" ${c?.cost_party_type==='cpo' ?'selected':''}>CPO (운영사)</option>
<option value="manufacturer" ${c?.cost_party_type==='manufacturer' ?'selected':''}>업체</option>
<option value="self" ${c?.cost_party_type==='self' ?'selected':''}>자체 부담</option>
<option value="user" ${c?.cost_party_type==='user' ?'selected':''}>사용자 과실</option>
<option value="other" ${c?.cost_party_type==='other' ?'selected':''}>기타</option>
</select>
</div>
<div class="form-group" id="mfrWrap"
style="display:${cost?.cost_party_type === 'manufacturer' ? 'block' : 'none'}">
<label>제조사 선택</label>
<select id="partyMfr">
<div class="form-group" id="mfrWrap_${rid}" style="display:${c?.cost_party_type==='manufacturer'?'block':'none'}">
<label>업체</label>
<select id="partyMfr_${rid}">
<option value="">선택</option>
${manufacturers.map(m =>
`<option value="${m.id}"
${cost?.cost_party_manufacturer_id == m.id ? 'selected' : ''}>
${m.company || ''} / ${m.name}
</option>`).join('')}
${mfrs.map(m=>`<option value="${m.id}" ${c?.cost_party_manufacturer_id==m.id?'selected':''}>${m.name}</option>`).join('')}
</select>
</div>
<div class="form-group" id="customWrap"
style="display:${cost?.cost_party_type === 'other' ? 'block' : 'none'}">
<label>기타 직접 입력</label>
<input type="text" id="partyCustom" value="${cost?.cost_party_custom || ''}">
<div class="form-group" id="customWrap_${rid}" style="display:${c?.cost_party_type==='other'?'block':'none'}">
<label>기타</label>
<input type="text" id="partyCustom_${rid}" value="${c?.cost_party_custom||''}">
</div>
</div>
<div class="form-row">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px;margin-top:10px;">수급 주체 (비용 수령)</div>
<div class="form-row-3">
<div class="form-group">
<label>출장비 금액 (원)</label>
<input type="number" id="costAmount"
value="${cost?.cost_amount || 0}" min="0" step="1000">
<label>유형</label>
<select id="recvPartyType_${rid}" onchange="toggleRecvPartySelect(${rid})">
<option value="">선택</option>
<option value="cpo" ${c?.recv_party_type==='cpo' ?'selected':''}>CPO (운영사)</option>
<option value="manufacturer" ${c?.recv_party_type==='manufacturer' ?'selected':''}>업체</option>
<option value="self" ${c?.recv_party_type==='self' ?'selected':''}>자체 부담</option>
<option value="user" ${c?.recv_party_type==='user' ?'selected':''}>사용자 과실</option>
<option value="other" ${c?.recv_party_type==='other' ?'selected':''}>기타</option>
</select>
</div>
<div class="form-group" id="recvMfrWrap_${rid}" style="display:${c?.recv_party_type==='manufacturer'?'block':'none'}">
<label>업체</label>
<select id="recvPartyMfr_${rid}">
<option value="">선택</option>
${mfrs.map(m=>`<option value="${m.id}" ${c?.recv_party_manufacturer_id==m.id?'selected':''}>${m.name}</option>`).join('')}
</select>
</div>
<div class="form-group" id="recvCustomWrap_${rid}" style="display:${c?.recv_party_type==='other'?'block':'none'}">
<label>기타</label>
<input type="text" id="recvPartyCustom_${rid}" value="${c?.recv_party_custom||''}">
</div>
</div>
<div class="form-row" style="margin-top:10px;">
<div class="form-group">
<label>금액 (원)</label>
<input type="number" id="costAmount_${rid}" value="${c?.cost_amount||0}" min="0" step="1000">
</div>
<div class="form-group">
<label>처리 상태</label>
<select id="costStatus">
<option value="pending" ${(!cost || cost.cost_status === 'pending') ? 'selected' : ''}>미처리</option>
<option value="billed" ${cost?.cost_status === 'billed' ? 'selected' : ''}>청구완료</option>
<option value="waived" ${cost?.cost_status === 'waived' ? 'selected' : ''}>면제</option>
<option value="settled" ${cost?.cost_status === 'settled' ? 'selected' : ''}>정산완료</option>
<select id="costStatus_${rid}">
<option value="pending" ${(!c||c.cost_status==='pending') ?'selected':''}>미처리</option>
<option value="billed" ${c?.cost_status==='billed' ?'selected':''}>청구완료</option>
<option value="waived" ${c?.cost_status==='waived' ?'selected':''}>면제</option>
<option value="settled" ${c?.cost_status==='settled' ?'selected':''}>정산완료</option>
</select>
</div>
</div>
<div id="costErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="saveCost(${repair.id})">
💾 출장비 처리 저장
</button>
</div>
<div id="costErr_${rid}" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="saveCost(${rid})">💾 출장비 저장</button>
</div>
</div>`;
}
@@ -369,8 +389,19 @@ async function load() {
<!-- 보기 모드 -->
<div class="report-view" id="reportView">
${r.report_scope === 'station' ? `
<div style="background:#F5F3FF;border:1px solid #DDD6FE;border-radius:8px;padding:9px 14px;margin-bottom:10px;font-size:13px;color:#5B21B6;font-weight:600;">
🏢 충전소 전체 신고 · <strong>${r.scope_charger_count}대</strong> 대상
</div>` : r.report_scope === 'type' ? `
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:9px 14px;margin-bottom:10px;font-size:13px;color:#1D4ED8;font-weight:600;">
🔧 동일 모델 전체 신고 · <strong>${r.scope_charger_count}대</strong> 대상
</div>` : r.report_scope === 'multi' ? `
<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:8px;padding:9px 14px;margin-bottom:10px;font-size:13px;color:#92400E;font-weight:600;">
📋 충전기 ${r.scope_charger_count}대 선택 신고
${(r.charger_ids||[]).length > 1 ? `<div style="margin-top:6px;font-size:12px;font-weight:400;display:flex;flex-wrap:wrap;gap:4px">${(r.charger_ids||[]).map(cid=>`<span style="background:#FDE68A;padding:2px 8px;border-radius:10px">${cid}</span>`).join('')}</div>` : ''}
</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);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong>${r.report_scope !== 'single' ? ` <span style="font-size:11px;color:var(--gray4)">(대표 충전기)</span>` : ''}</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>
@@ -437,6 +468,60 @@ async function load() {
<!-- 편집 모드 -->
<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)">충전기</label>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--gray1);border-radius:6px;font-size:13px;margin-bottom:6px">
<span id="rEditChargerCurrent"><strong>${r.charger_id}</strong>${r.station_name ? ` · ${r.station_name}` : ''}</span>
<button type="button" onclick="toggleChargerSearch()" class="edit-toggle-btn" style="flex-shrink:0">변경</button>
</div>
<div id="rEditChargerSearch" style="display:none">
<div style="position:relative">
<input type="text" id="rEditChargerInput" autocomplete="off"
placeholder="충전소명 또는 충전기 ID 검색..."
oninput="filterEditChargers(this.value)" onfocus="filterEditChargers(this.value)"
style="width:100%;padding:8px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;box-sizing:border-box;outline:none">
<div id="rEditChargerDropdown"
style="display:none;position:absolute;top:calc(100% + 4px);left:0;right:0;
background:white;border:1px solid var(--gray3);border-radius:7px;
max-height:200px;overflow-y:auto;z-index:20;box-shadow:0 4px 12px rgba(0,0,0,.12)"></div>
</div>
<div id="rEditChargerSelected"
style="display:none;margin-top:6px;padding:6px 10px;background:#EFF6FF;
border:1px solid #BFDBFE;border-radius:6px;font-size:12px;color:var(--navy2);">
<span id="rEditChargerSelectedText"></span>
<button type="button" onclick="clearEditCharger()" style="float:right;background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px;padding:0 2px;margin-left:8px">✕</button>
</div>
</div>
</div>
<!-- 신고 범위 -->
<div class="form-group" style="margin-bottom:10px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 범위</label>
<div style="display:flex;flex-direction:column;gap:7px;margin-top:5px">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="single" style="width:auto;accent-color:var(--accent)"
${!r.report_scope || r.report_scope === 'single' ? 'checked' : ''}>
<span><strong>이 충전기만</strong></span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="station" style="width:auto;accent-color:var(--accent)"
${r.report_scope === 'station' ? 'checked' : ''}>
<span><strong>충전소 전체</strong> <span style="font-size:11px;color:var(--gray4)">— 같은 충전소의 모든 충전기 대상</span></span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="type" style="width:auto;accent-color:var(--accent)"
${r.report_scope === 'type' ? 'checked' : ''}>
<span><strong>동일 모델 전체</strong> <span style="font-size:11px;color:var(--gray4)">— 같은 충전기 종류 전체 대상</span></span>
</label>
${r.report_scope === 'multi' ? `
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="radio" name="rEditScope" value="multi" style="width:auto;accent-color:var(--accent)" checked>
<span><strong>선택 충전기 ${r.scope_charger_count}대</strong> <span style="font-size:11px;color:var(--gray4)">— 접수 시 선택한 충전기들</span></span>
</label>` : ''}
</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>
<div class="issue-chk-grid">
@@ -545,6 +630,7 @@ async function load() {
${(pr.photos_after||[]).length ? `<label style="font-size:12px;font-weight:700;color:var(--navy2);margin-top:8px;display:block">조치 후 사진</label>
<div class="photo-preview">${(pr.photos_after||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
</div>` : ''}
${buildCostHtml(pr, manufacturers)}
</div>`).join('');
})() : ''}
@@ -592,6 +678,7 @@ async function load() {
</div>
</div>
${renderLocationMap(repair)}
${buildCostHtml(repair, manufacturers)}
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
repair.linked_improvements && repair.linked_improvements.length ? `
@@ -678,7 +765,7 @@ async function load() {
<select id="impMfr">
<option value="">미지정 (나중에 설정)</option>
${manufacturers.map(m =>
`<option value="${m.id}">${m.company ? m.company+' / ' : ''}${m.name}</option>`
`<option value="${m.id}">${m.name}</option>`
).join('')}
</select>
</div>
@@ -695,7 +782,6 @@ async function load() {
</div>
${costHtml}
`;
// 신고 편집 폼 사진 압축 설정
@@ -705,11 +791,6 @@ async function load() {
// 지도 초기화 (수리 정보가 있을 때만)
if (repair) initRepairMap(repair);
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
if (!editOpen) return;
const wrap = document.getElementById('costEditWrap');
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
}
/* ── 방문 위치 지도 ── */
@@ -829,6 +910,7 @@ function toggleReportEdit() {
edit.classList.remove('active');
view.classList.remove('hidden');
btn.innerHTML = '✏️ 내용 수정';
clearEditCharger();
} else {
view.classList.add('hidden');
edit.classList.add('active');
@@ -836,6 +918,79 @@ function toggleReportEdit() {
}
}
/* ── 충전기 변경 (수정 폼) ── */
let editChargerList = [];
let editChargerFiltered = [];
let editNewChargerId = null;
function _ecEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function toggleChargerSearch() {
const wrap = document.getElementById('rEditChargerSearch');
if (!wrap) return;
const opening = wrap.style.display === 'none';
wrap.style.display = opening ? 'block' : 'none';
if (opening) {
if (!editChargerList.length) {
API.get('/chargers').then(cs => { editChargerList = cs; filterEditChargers(''); });
} else {
filterEditChargers('');
}
setTimeout(() => document.getElementById('rEditChargerInput')?.focus(), 50);
}
}
function filterEditChargers(q) {
const dd = document.getElementById('rEditChargerDropdown');
if (!dd) return;
q = (q || '').trim().toLowerCase();
editChargerFiltered = (q
? editChargerList.filter(c =>
c.station_name.toLowerCase().includes(q) ||
c.name.toLowerCase().includes(q) ||
c.id.toLowerCase().includes(q))
: editChargerList).slice(0, 50);
dd.style.display = editChargerFiltered.length ? 'block' : 'none';
dd.innerHTML = editChargerFiltered.map((c, i) => `
<div class="ec-opt" onclick="selectEditCharger(${i})">
<div style="font-weight:600;color:var(--navy)">${_ecEsc(c.station_name)} · ${_ecEsc(c.name)}</div>
<div style="font-size:11px;color:var(--gray4);margin-top:2px">${_ecEsc(c.id)}</div>
</div>`).join('');
}
function selectEditCharger(idx) {
const c = editChargerFiltered[idx];
if (!c) return;
editNewChargerId = c.id;
document.getElementById('rEditChargerDropdown').style.display = 'none';
document.getElementById('rEditChargerInput').value = '';
document.getElementById('rEditChargerSelectedText').textContent =
`${c.station_name} · ${c.name} (${c.id})`;
document.getElementById('rEditChargerSelected').style.display = 'block';
}
function clearEditCharger() {
editNewChargerId = null;
const sel = document.getElementById('rEditChargerSelected');
if (sel) sel.style.display = 'none';
const inp = document.getElementById('rEditChargerInput');
if (inp) inp.value = '';
const dd = document.getElementById('rEditChargerDropdown');
if (dd) dd.style.display = 'none';
const wrap = document.getElementById('rEditChargerSearch');
if (wrap) wrap.style.display = 'none';
}
document.addEventListener('click', e => {
const inp = document.getElementById('rEditChargerInput');
const dd = document.getElementById('rEditChargerDropdown');
if (inp && dd && !inp.contains(e.target) && !dd.contains(e.target)) {
dd.style.display = 'none';
}
});
async function saveReport(reportId) {
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
if (!issues.length) {
@@ -844,6 +999,14 @@ async function saveReport(reportId) {
err.style.display = 'block';
return;
}
// 재조치 대기 중인데 상태를 pending 이외로 바꾸면 정비사 목록에서 사라짐 → 경고
const selStatus = document.getElementById('rEditStatus').value;
if (selStatus !== 'pending') {
const latestRepair = (window._reportData && window._reportData.repair);
if (latestRepair && latestRepair.re_dispatch_requested && !latestRepair.approved_at) {
if (!confirm('⚠️ 현재 재조치 대기 중인 건입니다.\n상태를 "접수(pending)" 이외로 변경하면 정비사 AS 목록에서 사라집니다.\n\n계속 저장하시겠습니까?')) return;
}
}
document.getElementById('rEditErr').style.display = 'none';
const fd = new FormData();
fd.append('issue_types', JSON.stringify(issues));
@@ -851,8 +1014,11 @@ async function saveReport(reportId) {
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('status', selStatus);
fd.append('ocpp_log', document.getElementById('rEditOcppLog').value);
if (editNewChargerId) fd.append('charger_id', editNewChargerId);
const newScope = document.querySelector('input[name="rEditScope"]:checked')?.value;
if (newScope) fd.append('scope', newScope);
const newPhotos = document.getElementById('rEditPhoto')?.files || [];
Array.from(newPhotos).forEach(f => fd.append('photos', f));
try {
@@ -865,11 +1031,7 @@ async function saveReport(reportId) {
}
}
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 toggleParty() {} // 레거시 — togglePartySelect(repairId) 사용
function toggleApprovePanel() {
const panel = document.getElementById('approvePanel');
@@ -985,28 +1147,31 @@ async function submitClose(id) {
}
async function saveCost(repairId) {
const partyType = document.getElementById('partyType').value;
if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; }
const partyType = document.getElementById('partyType_' + repairId)?.value;
if (!partyType) {
const err = document.getElementById('costErr_' + repairId);
if (err) { err.textContent = '출장비 부담 주체를 선택해 주세요.'; err.style.display = 'block'; }
return;
}
const fd = new FormData();
fd.append('root_cause', document.getElementById('rootCause').value);
fd.append('admin_note', document.getElementById('adminNote').value);
fd.append('root_cause', document.getElementById('rootCause_' + repairId)?.value || '');
fd.append('admin_note', document.getElementById('adminNote_' + repairId)?.value || '');
fd.append('cost_party_type', partyType);
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr')?.value || '');
fd.append('cost_party_custom', document.getElementById('partyCustom')?.value || '');
fd.append('cost_amount', document.getElementById('costAmount').value || 0);
fd.append('cost_status', document.getElementById('costStatus').value);
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr_' + repairId)?.value || '');
fd.append('cost_party_custom', document.getElementById('partyCustom_' + repairId)?.value || '');
fd.append('recv_party_type', document.getElementById('recvPartyType_' + repairId)?.value || '');
fd.append('recv_party_manufacturer_id', document.getElementById('recvPartyMfr_' + repairId)?.value || '');
fd.append('recv_party_custom', document.getElementById('recvPartyCustom_'+ repairId)?.value || '');
fd.append('cost_amount', document.getElementById('costAmount_' + repairId)?.value || 0);
fd.append('cost_status', document.getElementById('costStatus_' + repairId)?.value || 'pending');
try {
await API.post(`/costs/repair/${repairId}`, fd);
alert('✅ 출장비 처리가 저장되었습니다.');
editOpen = false;
load();
} catch(e) { showCostErr(e.message); }
}
function showCostErr(msg) {
const el = document.getElementById('costErr');
el.textContent = msg;
el.style.display = 'block';
} catch(e) {
const err = document.getElementById('costErr_' + repairId);
if (err) { err.textContent = e.message; err.style.display = 'block'; }
}
}
load();

View File

@@ -40,9 +40,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
@@ -54,6 +55,7 @@
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
@@ -74,6 +76,7 @@
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending_all">접수 대기 (전체)</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
@@ -132,6 +135,7 @@ let mapMarkers = [];
const _p = new URLSearchParams(location.search);
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
let _stationNameFilter = _p.get('station_name') || '';
// ── 뷰 전환 ──
function setView(v) {
@@ -189,8 +193,23 @@ async function load() {
const c = document.getElementById('fCharger').value.trim();
if (s) url += 'status=' + s + '&';
if (c) url += 'charger_id=' + c + '&';
if (_stationNameFilter) url += 'station_name=' + encodeURIComponent(_stationNameFilter) + '&';
allRows = await API.get(url);
// 충전소 필터 배너
const existing = document.getElementById('stationFilterBanner');
if (_stationNameFilter) {
if (!existing) {
const banner = document.createElement('div');
banner.id = 'stationFilterBanner';
banner.style.cssText = 'background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;padding:8px 14px;margin-bottom:12px;font-size:13px;color:var(--navy2);display:flex;justify-content:space-between;align-items:center;';
banner.innerHTML = `<span>🏢 충전소 필터: <strong>${_stationNameFilter}</strong></span><button onclick="_stationNameFilter='';this.closest('#stationFilterBanner').remove();load()" style="background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px;">✕ 해제</button>`;
document.querySelector('.main').insertBefore(banner, document.querySelector('.card'));
}
} else if (existing) {
existing.remove();
}
document.getElementById('resultCount').textContent = allRows.length + '건';
renderTable();
if (curView === 'map') renderReportMap();
@@ -209,7 +228,10 @@ function renderTable() {
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
<span style="font-weight:700">${r.seq}</span>
</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"><strong>${r.charger_id}</strong></td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
<strong>${r.charger_id}</strong>
${r.report_scope === 'station' ? `<div style="font-size:11px;color:#7C3AED;font-weight:600;margin-top:2px">🏢 충전소 전체 · ${r.scope_charger_count}대</div>` : r.report_scope === 'type' ? `<div style="font-size:11px;color:#0369A1;font-weight:600;margin-top:2px">🔧 동일모델 전체 · ${r.scope_charger_count}대</div>` : r.report_scope === 'multi' ? `<div style="font-size:11px;color:#B45309;font-weight:600;margin-top:2px">📋 충전기 ${r.scope_charger_count}대 선택</div>` : ''}
</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.station_name||'-'}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.charger_type||'-'}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer;max-width:200px">${(r.issue_types||[]).join(', ')}</td>

View File

@@ -25,9 +25,10 @@
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 — 관리자</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>

View File

@@ -1,8 +1,9 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>제조사 대시보드</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS — 제조사</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS — 제조사</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/manufacturer/dashboard.html" class="active">📋 개선항목 목록</a>
</div>

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS — 제조사</span><div id="navUser"></div></nav>
<nav class="nav"><div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS — 제조사</span></div><div id="navUser"></div></nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="main" style="max-width:760px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/manufacturer/dashboard.html" class="btn btn-outline btn-sm">← 목록</a>

View File

@@ -47,16 +47,17 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html" class="active">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
@@ -157,7 +158,7 @@ function renderList() {
: `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`;
return `
<tr onclick="location.href='${href}'">
<td>#${r.id}${r.re_dispatch_count > 0 ? ' <span style="font-size:10px;background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:8px;font-weight:700;vertical-align:middle;">🔁조치</span>' : ''}</td>
<td>#${r.id}${r.re_dispatch_count > 0 ? ' <span style="font-size:10px;background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:8px;font-weight:700;vertical-align:middle;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : ''}</td>
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>

View File

@@ -28,16 +28,17 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<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" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
@@ -136,6 +137,7 @@ function render() {
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
</span>
${r.attempt > 1 ? `<span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:2px 8px;border-radius:10px;font-weight:700;">🔁 ${r.attempt}차 조치</span>` : ''}
<span style="font-size:11px;color:var(--gray4)">${RESULT_LABEL[r.result_status] || r.result_status}</span>
</div>
</div>

View File

@@ -10,20 +10,29 @@
.photo-preview{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.photo-preview img{width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);}
.photo-info{font-size:11px;margin-top:4px;min-height:14px;color:var(--gray4);}
@media(max-width:768px){
.upload-area{padding:16px 12px;font-size:13px;}
.photo-preview img{width:88px;height:88px;}
.main > div{max-width:100% !important;padding:0;}
}
</style>
</head>
<body>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;">
<button class="nav-hamburger" onclick="toggleSidebar()"></button>
<span class="nav-brand">⚡ EV AS 관리</span>
</div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
@@ -42,7 +51,8 @@
</div>
<div class="card">
<div class="card-title">🔧 조치 내역 입력</div>
<div class="card-title" id="repairCardTitle">🔧 조치 내역 입력</div>
<div id="attemptBanner"></div>
<div class="form-group">
<label>조치 유형 <span class="req">*</span></label>
@@ -125,6 +135,13 @@
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
const params = new URLSearchParams(location.search);
const repairId = params.get('repair_id'); // 편집 모드
const chargerId = params.get('charger_id'); // 신규 모드
@@ -185,13 +202,24 @@ async function loadCreate() {
' onchange="toggleReport(' + r.id + ',this.checked,this.closest(\'label\'))">' +
'<div>' +
'<div><strong>#' + r.id + '</strong> ' + Auth.statusBadge(r.status) +
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 재조치 ' + r.re_dispatch_count + '회</span>' : '') + '</div>' +
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : '') + '</div>' +
'<div style="font-size:12px;color:var(--text2);margin-top:2px">' + ((r.issue_types || []).join(', ')) + '</div>' +
'<div style="font-size:11px;color:var(--gray4)">' + Auth.fmtDt(r.reported_at) + '</div>' +
photoHtml +
'</div>' +
'</label>';
}).join('');
// 차수 배너: 대상 신고(initReportId 또는 첫 번째)의 re_dispatch_count 기준
var targetReport = reports.find(function(r) { return r.id === parseInt(initReportId); }) || reports[0];
if (targetReport && targetReport.re_dispatch_count > 0) {
var nth = targetReport.re_dispatch_count + 1;
document.getElementById('repairCardTitle').textContent = '🔧 조치 내역 입력 (' + nth + '차 조치)';
document.getElementById('attemptBanner').innerHTML =
'<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">' +
'🔁 이 건은 <strong>' + nth + '차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)' +
'</div>';
}
} catch(e) {
document.getElementById('chargerCard').innerHTML =
'<div class="alert alert-danger">충전기 정보를 불러오지 못했습니다.<br><small style="opacity:.8">' + e.message + '</small></div>';
@@ -214,8 +242,18 @@ async function loadEdit() {
// 헤더 업데이트
var h2el = document.querySelector('.main > div > h2') || document.querySelector('h2');
if (h2el) h2el.parentNode.removeChild(h2el);
const attemptLabel = repair.attempt > 1 ? ` · ${repair.attempt}차 조치` : '';
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}${attemptLabel}</span>`);
// 차수 배너 (2차 이상일 때)
if (repair.attempt > 1) {
document.getElementById('repairCardTitle').textContent = `🔧 조치 내역 입력 (${repair.attempt}차 조치)`;
document.getElementById('attemptBanner').innerHTML =
`<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">` +
`🔁 이 건은 <strong>${repair.attempt}차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)` +
`</div>`;
}
// 충전기 카드
document.getElementById('chargerCard').innerHTML = `

View File

@@ -10,16 +10,17 @@
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리</span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<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" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>

View File

@@ -11,12 +11,13 @@
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html" class="active">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html">📋 신고 목록</a>

View File

@@ -7,12 +7,13 @@
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span>
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html" class="active">📋 신고 목록</a>