1차완료
This commit is contained in:
@@ -542,22 +542,52 @@ def stats_top_chargers(limit: int = 10):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats/charger-error-codes")
|
@app.get("/api/stats/top-stations")
|
||||||
def stats_charger_error_codes(code_limit: int = 10):
|
def stats_top_stations(limit: int = 10):
|
||||||
"""에러코드별 누적 건수 Top N (단순 순위)."""
|
"""충전소별 누적 고장 신고 건수 Top N."""
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
rows = db.execute(text("""
|
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
|
FROM reports
|
||||||
WHERE error_code IS NOT NULL AND TRIM(error_code) != ''
|
GROUP BY COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음')
|
||||||
GROUP BY TRIM(error_code)
|
|
||||||
ORDER BY cnt DESC
|
ORDER BY cnt DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""), {"limit": code_limit}).fetchall()
|
"""), {"limit": code_limit}).fetchall()
|
||||||
# 역순: 차트 Y축에서 1위가 맨 위
|
|
||||||
result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)]
|
result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)]
|
||||||
return {"error_codes": result}
|
return {"error_codes": result}
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ class Report(Base):
|
|||||||
closed_at = Column(TIMESTAMP)
|
closed_at = Column(TIMESTAMP)
|
||||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
re_dispatch_count = Column(Integer, default=0)
|
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")
|
charger = relationship("Charger", back_populates="reports")
|
||||||
photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan")
|
photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan")
|
||||||
repair_links = relationship("RepairReport", back_populates="report")
|
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)
|
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), unique=True)
|
||||||
root_cause = Column(Text)
|
root_cause = Column(Text)
|
||||||
admin_note = Column(Text)
|
admin_note = Column(Text)
|
||||||
cost_party_type = Column(String(20))
|
cost_party_type = Column(String(50))
|
||||||
cost_party_manufacturer_id = Column(Integer, ForeignKey("users.id"))
|
cost_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL"))
|
||||||
cost_party_custom = Column(String(100))
|
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_amount = Column(Integer, default=0)
|
||||||
cost_status = Column(String(20), default="pending")
|
cost_status = Column(String(20), default="pending")
|
||||||
reviewed_by = Column(Integer, ForeignKey("users.id"))
|
reviewed_by = Column(Integer, ForeignKey("users.id"))
|
||||||
reviewed_at = Column(TIMESTAMP)
|
reviewed_at = Column(TIMESTAMP)
|
||||||
repair = relationship("Repair", back_populates="cost")
|
repair = relationship("Repair", back_populates="cost")
|
||||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
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):
|
class Improvement(Base):
|
||||||
__tablename__ = "improvements"
|
__tablename__ = "improvements"
|
||||||
@@ -141,13 +148,13 @@ class Improvement(Base):
|
|||||||
priority = Column(String(10), default="normal")
|
priority = Column(String(10), default="normal")
|
||||||
part_name = Column(String(100))
|
part_name = Column(String(100))
|
||||||
status = Column(String(20), default="registered")
|
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"))
|
created_by = Column(Integer, ForeignKey("users.id"))
|
||||||
sw_deploy_target = Column(Date)
|
sw_deploy_target = Column(Date)
|
||||||
sw_deployed_at = Column(Date)
|
sw_deployed_at = Column(Date)
|
||||||
manufacturer_memo = Column(Text)
|
manufacturer_memo = Column(Text)
|
||||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
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])
|
creator = relationship("User", foreign_keys=[created_by])
|
||||||
report_links = relationship("ImprovementReport", back_populates="improvement", cascade="all, delete-orphan")
|
report_links = relationship("ImprovementReport", back_populates="improvement", cascade="all, delete-orphan")
|
||||||
attachments = relationship("ImprovementAttachment", back_populates="improvement", cascade="all, delete-orphan")
|
attachments = relationship("ImprovementAttachment", back_populates="improvement", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -40,9 +40,14 @@ def list_costs(
|
|||||||
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
|
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
|
||||||
"root_cause": cost.root_cause, "admin_note": cost.admin_note,
|
"root_cause": cost.root_cause, "admin_note": cost.admin_note,
|
||||||
"cost_party_type": cost.cost_party_type,
|
"cost_party_type": cost.cost_party_type,
|
||||||
|
"cost_party_manufacturer_id": cost.cost_party_manufacturer_id,
|
||||||
"cost_party_custom": cost.cost_party_custom,
|
"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,
|
"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_by_name": cost.reviewer.name if cost.reviewer else None,
|
||||||
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at 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_type: str = Form(...),
|
||||||
cost_party_manufacturer_id: Optional[int] = Form(None),
|
cost_party_manufacturer_id: Optional[int] = Form(None),
|
||||||
cost_party_custom: str = Form(""),
|
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_amount: int = Form(0),
|
||||||
cost_status: str = Form("pending"),
|
cost_status: str = Form("pending"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -93,6 +101,9 @@ def upsert_cost(
|
|||||||
cost.cost_party_type = cost_party_type
|
cost.cost_party_type = cost_party_type
|
||||||
cost.cost_party_manufacturer_id = cost_party_manufacturer_id or None
|
cost.cost_party_manufacturer_id = cost_party_manufacturer_id or None
|
||||||
cost.cost_party_custom = cost_party_custom 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.cost_amount = cost_amount; cost.cost_status = cost_status
|
||||||
cost.reviewed_by = current_user.id; cost.reviewed_at = datetime.now()
|
cost.reviewed_by = current_user.id; cost.reviewed_at = datetime.now()
|
||||||
else:
|
else:
|
||||||
@@ -101,6 +112,9 @@ def upsert_cost(
|
|||||||
cost_party_type=cost_party_type,
|
cost_party_type=cost_party_type,
|
||||||
cost_party_manufacturer_id=cost_party_manufacturer_id or None,
|
cost_party_manufacturer_id=cost_party_manufacturer_id or None,
|
||||||
cost_party_custom=cost_party_custom 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,
|
cost_amount=cost_amount, cost_status=cost_status,
|
||||||
reviewed_by=current_user.id, reviewed_at=datetime.now()
|
reviewed_by=current_user.id, reviewed_at=datetime.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from typing import Optional
|
||||||
import openpyxl
|
import openpyxl
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
from database import get_db
|
from database import get_db
|
||||||
@@ -15,17 +16,27 @@ router = APIRouter(prefix="/api/export", tags=["export"])
|
|||||||
|
|
||||||
NAVY = "0B1E3D"
|
NAVY = "0B1E3D"
|
||||||
LIGHT = "D6EAF8"
|
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")
|
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):
|
for col, h in enumerate(headers, 1):
|
||||||
cell = ws.cell(row=row, column=col, value=h)
|
_hdr_cell(ws, row, col, h, color)
|
||||||
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)
|
|
||||||
ws.row_dimensions[row].height = 20
|
ws.row_dimensions[row].height = 20
|
||||||
|
|
||||||
|
|
||||||
def style_row(ws, row_num, num_cols, even=True):
|
def style_row(ws, row_num, num_cols, even=True):
|
||||||
bd = Side(style="thin", color="DDDDDD")
|
bd = Side(style="thin", color="DDDDDD")
|
||||||
for col in range(1, num_cols + 1):
|
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.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
||||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
|
||||||
def fmt_dt(dt):
|
def fmt_dt(dt):
|
||||||
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
|
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
|
||||||
|
|
||||||
|
|
||||||
def fmt_d(d):
|
def fmt_d(d):
|
||||||
return str(d) if d else ""
|
return str(d) if d else ""
|
||||||
|
|
||||||
|
|
||||||
def elapsed(start, end):
|
def elapsed(start, end):
|
||||||
if not start or not end: return ""
|
if not start or not end: return ""
|
||||||
diff = end - start
|
diff = end - start
|
||||||
total = int(diff.total_seconds())
|
total = int(diff.total_seconds())
|
||||||
|
if total < 0: return ""
|
||||||
h, m = divmod(total // 60, 60)
|
h, m = divmod(total // 60, 60)
|
||||||
return f"{h}시간 {m}분"
|
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:
|
def make_response(wb: openpyxl.Workbook, korean_name: str) -> StreamingResponse:
|
||||||
"""엑셀 파일을 StreamingResponse로 반환 — 한글 파일명 URL 인코딩 처리"""
|
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
wb.save(buf)
|
wb.save(buf)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
date_str = datetime.now().strftime("%Y%m%d_%H%M")
|
date_str = datetime.now().strftime("%Y%m%d_%H%M")
|
||||||
filename = f"{korean_name}_{date_str}.xlsx"
|
filename = f"{korean_name}_{date_str}.xlsx"
|
||||||
encoded = quote(filename, safe="") # 한글 URL 인코딩
|
encoded = quote(filename, safe="")
|
||||||
cd_header = f"attachment; filename*=UTF-8''{encoded}"
|
cd = f"attachment; filename*=UTF-8''{encoded}"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
buf,
|
buf,
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
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")
|
@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()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "AS신고목록"
|
ws.title = "AS신고목록"
|
||||||
ws.freeze_panes = "A2"
|
ws.freeze_panes = "A2"
|
||||||
|
|
||||||
CLOSURE_LABEL = {
|
|
||||||
"natural": "증상자연소거",
|
|
||||||
"remote_reset": "원격리셋후증상소거",
|
|
||||||
"false_alarm": "인지오류",
|
|
||||||
"other": "기타",
|
|
||||||
}
|
|
||||||
headers = [
|
headers = [
|
||||||
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
|
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
|
||||||
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
|
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
|
||||||
@@ -92,14 +409,15 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
|||||||
"재조치횟수"
|
"재조치횟수"
|
||||||
]
|
]
|
||||||
style_header(ws, headers)
|
style_header(ws, headers)
|
||||||
|
set_col_widths(ws, [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
|
||||||
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
|
|
||||||
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18,
|
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18,
|
||||||
18,24,16,14,10]
|
18,24,16,14,10])
|
||||||
for i, w in enumerate(col_widths, 1):
|
|
||||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
q = db.query(models.Report)
|
||||||
|
if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
|
||||||
|
if dt_to: q = q.filter(models.Report.reported_at < dt_to)
|
||||||
|
reports = q.order_by(desc(models.Report.reported_at)).all()
|
||||||
|
|
||||||
reports = db.query(models.Report).order_by(desc(models.Report.reported_at)).all()
|
|
||||||
for row_num, r in enumerate(reports, 2):
|
for row_num, r in enumerate(reports, 2):
|
||||||
c = r.charger
|
c = r.charger
|
||||||
repair = r.repair_links[0].repair if r.repair_links else None
|
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
|
ir.improvement_id
|
||||||
for ir in db.query(models.ImprovementReport).filter_by(report_id=r.id).all()
|
for ir in db.query(models.ImprovementReport).filter_by(report_id=r.id).all()
|
||||||
]
|
]
|
||||||
seq_no = row_num - 1 # 순차번호 (1부터 시작)
|
|
||||||
|
|
||||||
row_data = [
|
row_data = [
|
||||||
seq_no,
|
r.id,
|
||||||
r.charger_id,
|
r.charger_id,
|
||||||
c.charger_type.name if c and c.charger_type else "",
|
c.charger_type.name if c and c.charger_type else "",
|
||||||
c.name if c 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 "",
|
fmt_d(c.installed_at) if c else "",
|
||||||
r.gps_lat or "",
|
r.gps_lat or "",
|
||||||
r.gps_lng or "",
|
r.gps_lng or "",
|
||||||
", ".join(r.issue_types) if r.issue_types else "",
|
", ".join(r.issue_types or []),
|
||||||
r.error_code or "",
|
r.error_code or "",
|
||||||
r.issue_detail or "",
|
r.issue_detail or "",
|
||||||
r.contact or "",
|
r.contact or "",
|
||||||
fmt_dt(r.occurred_at),
|
fmt_dt(r.occurred_at),
|
||||||
fmt_dt(r.reported_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.reporter.name if r.reporter else "",
|
||||||
r.status,
|
r.status,
|
||||||
repair.mechanic.name if repair and repair.mechanic else "",
|
repair.mechanic.name if repair and repair.mechanic else "",
|
||||||
repair.mechanic.company 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 "",
|
repair.description if repair else "",
|
||||||
fmt_dt(repair.started_at) if repair else "",
|
fmt_dt(repair.started_at) if repair else "",
|
||||||
fmt_dt(repair.completed_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),
|
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None),
|
||||||
cost.root_cause if cost else "",
|
cost.root_cause if cost else "",
|
||||||
cost.admin_note 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_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 "",
|
cost.reviewer.name if cost and cost.reviewer else "",
|
||||||
fmt_dt(cost.reviewed_at) if cost else "",
|
fmt_dt(cost.reviewed_at) if cost else "",
|
||||||
", ".join(str(i) for i in imp_ids) if imp_ids 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 "",
|
r.closure_note or "",
|
||||||
fmt_dt(r.closed_at),
|
fmt_dt(r.closed_at),
|
||||||
r.closer.name if r.closer else "",
|
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")
|
@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()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "출장비목록"
|
ws.title = "출장비목록"
|
||||||
@@ -172,39 +494,45 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
|||||||
headers = [
|
headers = [
|
||||||
"신고번호","충전기ID","충전기종류","충전소명","조치완료일",
|
"신고번호","충전기ID","충전기종류","충전소명","조치완료일",
|
||||||
"정비사","소속","문제원인","비고",
|
"정비사","소속","문제원인","비고",
|
||||||
"출장비부담주체","제조사명","금액(원)","처리상태",
|
"부담주체유형","부담업체명","부담기타",
|
||||||
"처리담당자","처리일시"
|
"수급주체유형","수급업체명","수급기타",
|
||||||
|
"금액(원)","처리상태","처리담당자","처리일시"
|
||||||
]
|
]
|
||||||
style_header(ws, headers)
|
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):
|
set_col_widths(ws, [14,14,14,18,16,12,14,26,26,12,16,16,12,16,16,12,12,12,16])
|
||||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
|
||||||
|
|
||||||
costs = db.query(models.RepairCost).join(models.Repair).order_by(
|
q = db.query(models.RepairCost).join(models.Repair)
|
||||||
desc(models.RepairCost.reviewed_at)).all()
|
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):
|
for row_num, cost in enumerate(costs, 2):
|
||||||
repair = cost.repair
|
repair = cost.repair
|
||||||
rids = [rr.report_id for rr in repair.report_links]
|
rids = [rr.report_id for rr in repair.report_links]
|
||||||
charger_id = station_name = charger_type = ""
|
charger_id = station = ctype = ""
|
||||||
if rids:
|
if rids:
|
||||||
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
||||||
if r and r.charger:
|
if r and r.charger:
|
||||||
charger_id = r.charger_id
|
charger_id = r.charger_id
|
||||||
station_name = r.charger.station_name
|
station = r.charger.station_name or ""
|
||||||
charger_type = r.charger.charger_type.name if r.charger.charger_type else ""
|
ctype = r.charger.charger_type.name if r.charger.charger_type else ""
|
||||||
|
|
||||||
row_data = [
|
row_data = [
|
||||||
", ".join(str(i) for i in rids),
|
", ".join(str(i) for i in rids),
|
||||||
charger_id, charger_type, station_name,
|
charger_id, ctype, station,
|
||||||
fmt_dt(repair.completed_at),
|
fmt_dt(repair.completed_at),
|
||||||
repair.mechanic.name if repair.mechanic else "",
|
repair.mechanic.name if repair.mechanic else "",
|
||||||
repair.mechanic.company if repair.mechanic else "",
|
repair.mechanic.company if repair.mechanic else "",
|
||||||
cost.root_cause or "",
|
cost.root_cause or "",
|
||||||
cost.admin_note or "",
|
cost.admin_note or "",
|
||||||
cost.cost_party_type or "",
|
PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""),
|
||||||
cost.manufacturer.company if cost.manufacturer else (cost.cost_party_custom 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_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 "",
|
cost.reviewer.name if cost.reviewer else "",
|
||||||
fmt_dt(cost.reviewed_at),
|
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")
|
@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()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "개선항목목록"
|
ws.title = "개선항목목록"
|
||||||
@@ -228,40 +562,29 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
|
|||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
"번호","제목","분류","우선순위","개선내용","관련부품",
|
"번호","제목","분류","우선순위","개선내용","관련부품",
|
||||||
"담당제조사","담당자","연락처","연결AS건수","연결AS번호","연결AS신고자",
|
"담당업체","담당자(대표)","연락처","연결AS건수","연결AS번호",
|
||||||
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
|
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
|
||||||
"등록관리자","등록일시"
|
"등록관리자","등록일시"
|
||||||
]
|
]
|
||||||
style_header(ws, headers)
|
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):
|
set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16])
|
||||||
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
|
||||||
|
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):
|
for row_num, imp in enumerate(imps, 2):
|
||||||
rids = [ir.report_id for ir in imp.report_links]
|
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 = [
|
row_data = [
|
||||||
imp.id, imp.title, imp.category, imp.priority,
|
imp.id, imp.title, imp.category, imp.priority,
|
||||||
imp.description, imp.part_name or "",
|
imp.description, imp.part_name or "",
|
||||||
imp.manufacturer.company if imp.manufacturer else "",
|
|
||||||
imp.manufacturer.name 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 "",
|
imp.manufacturer.phone if imp.manufacturer else "",
|
||||||
len(rids),
|
len(rids),
|
||||||
", ".join(str(i) for i in rids),
|
", ".join(str(i) for i in rids),
|
||||||
"\n".join(reporters),
|
IMP_STATUS_LABEL.get(imp.status, imp.status),
|
||||||
imp.status,
|
|
||||||
fmt_d(imp.sw_deploy_target),
|
fmt_d(imp.sw_deploy_target),
|
||||||
fmt_d(imp.sw_deployed_at),
|
fmt_d(imp.sw_deployed_at),
|
||||||
imp.manufacturer_memo or "",
|
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
|
ws.row_dimensions[row_num].height = 16
|
||||||
|
|
||||||
return make_response(wb, "개선항목목록")
|
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}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import desc, text
|
from sqlalchemy import desc, text, func
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from database import get_db
|
from database import get_db
|
||||||
@@ -18,7 +18,7 @@ def _fmt(imp: models.Improvement):
|
|||||||
"part_name": imp.part_name, "status": imp.status,
|
"part_name": imp.part_name, "status": imp.status,
|
||||||
"manufacturer_id": imp.manufacturer_id,
|
"manufacturer_id": imp.manufacturer_id,
|
||||||
"manufacturer_name": imp.manufacturer.name if imp.manufacturer else None,
|
"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,
|
"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_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,
|
"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_ids": [ir.report_id for ir in imp.report_links],
|
||||||
"report_count": len(imp.report_links),
|
"report_count": len(imp.report_links),
|
||||||
"attachments": [{"path": a.file_path, "name": a.file_name} for a in imp.attachments],
|
"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(),
|
"changed_at": l.changed_at.isoformat(),
|
||||||
"by": l.changer.name if l.changer else None} for l in imp.logs],
|
"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 not imp: raise HTTPException(404)
|
||||||
if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id:
|
if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id:
|
||||||
raise HTTPException(403)
|
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("")
|
@router.post("")
|
||||||
async def create_improvement(
|
async def create_improvement(
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ def _fmt_repair(repair: models.Repair) -> dict:
|
|||||||
"issue_types": r.issue_types,
|
"issue_types": r.issue_types,
|
||||||
"status": r.status,
|
"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 {
|
return {
|
||||||
"id": repair.id,
|
"id": repair.id,
|
||||||
"charger_id": charger_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"],
|
"photos_after": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "after"],
|
||||||
"reports": reports,
|
"reports": reports,
|
||||||
"report_count": len(reports),
|
"report_count": len(reports),
|
||||||
|
"attempt": attempt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ def _fmt_report(r: models.Report, db: Session):
|
|||||||
"closure_note": r.closure_note,
|
"closure_note": r.closure_note,
|
||||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||||
"closed_by_name": r.closer.name if r.closer 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("")
|
@router.post("")
|
||||||
@@ -105,7 +109,8 @@ async def create_report(
|
|||||||
@router.post("/batch")
|
@router.post("/batch")
|
||||||
async def create_batch_report(
|
async def create_batch_report(
|
||||||
charger_id: str = Form(...),
|
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_types: str = Form(...),
|
||||||
issue_detail: str = Form(""),
|
issue_detail: str = Form(""),
|
||||||
error_code: 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()
|
charger = db.query(models.Charger).filter_by(id=charger_id).first()
|
||||||
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
|
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
|
||||||
|
|
||||||
if scope == "station":
|
selected_ids = None
|
||||||
targets = db.query(models.Charger).filter_by(
|
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()
|
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:
|
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()
|
charger_type_id=charger.charger_type_id, is_active=True).all()
|
||||||
|
report_scope = "type"
|
||||||
|
scope_charger_count = len(all_targets)
|
||||||
else:
|
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()
|
setting = db.query(models.SystemSetting).filter_by(key="report_visibility_policy").first()
|
||||||
policy = setting.value if setting else "immediate"
|
policy = setting.value if setting else "immediate"
|
||||||
@@ -143,7 +162,6 @@ async def create_batch_report(
|
|||||||
else:
|
else:
|
||||||
source_value = "qr"
|
source_value = "qr"
|
||||||
|
|
||||||
# Read all photo bytes upfront so they can be written for each target
|
|
||||||
photo_data = []
|
photo_data = []
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
if photo.filename:
|
if photo.filename:
|
||||||
@@ -160,6 +178,9 @@ async def create_batch_report(
|
|||||||
ocpp_log=ocpp_log or None,
|
ocpp_log=ocpp_log or None,
|
||||||
source=source_value,
|
source=source_value,
|
||||||
reported_by=current_user.id if current_user else None,
|
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)
|
db.add(r); db.commit(); db.refresh(r)
|
||||||
|
|
||||||
@@ -181,6 +202,7 @@ async def create_batch_report(
|
|||||||
def list_reports(
|
def list_reports(
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
charger_id: Optional[str] = None,
|
charger_id: Optional[str] = None,
|
||||||
|
station_name: Optional[str] = None,
|
||||||
active_only: bool = False,
|
active_only: bool = False,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user)
|
current_user: models.User = Depends(get_current_user)
|
||||||
@@ -196,12 +218,17 @@ def list_reports(
|
|||||||
q = (db.query(models.Report, seq_subq.c.seq)
|
q = (db.query(models.Report, seq_subq.c.seq)
|
||||||
.join(seq_subq, models.Report.id == seq_subq.c.rid)
|
.join(seq_subq, models.Report.id == seq_subq.c.rid)
|
||||||
.order_by(desc(models.Report.reported_at)))
|
.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)
|
q = q.filter(models.Report.status == status)
|
||||||
elif active_only:
|
elif active_only:
|
||||||
q = q.filter(models.Report.status.in_(
|
q = q.filter(models.Report.status.in_(
|
||||||
["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
|
["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
|
||||||
if charger_id: q = q.filter(models.Report.charger_id == charger_id)
|
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":
|
if current_user.role == "mechanic":
|
||||||
q = q.filter(models.Report.status != "pending_approval")
|
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,
|
"root_cause": cost.root_cause,
|
||||||
"admin_note": cost.admin_note,
|
"admin_note": cost.admin_note,
|
||||||
"cost_party_type": cost.cost_party_type,
|
"cost_party_type": cost.cost_party_type,
|
||||||
|
"cost_party_manufacturer_id": cost.cost_party_manufacturer_id,
|
||||||
"cost_party_custom": cost.cost_party_custom,
|
"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_amount": cost.cost_amount,
|
||||||
"cost_status": cost.cost_status,
|
"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,
|
||||||
} if (cost and include_cost) 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 [],
|
"linked_improvements": _get_linked_improvements(repair, db) if include_cost else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.repair_links:
|
if r.repair_links:
|
||||||
sorted_links = sorted(r.repair_links, key=lambda l: l.repair_id, reverse=True)
|
sorted_links = sorted(r.repair_links, key=lambda l: l.repair_id, reverse=True)
|
||||||
result["repair"] = _fmt_one_repair(sorted_links[0].repair)
|
result["repair"] = _fmt_one_repair(sorted_links[0].repair)
|
||||||
# 재조치로 인한 이전 조치 이력 (최신 제외, re_dispatch_requested=True인 것)
|
|
||||||
result["prev_repairs"] = [
|
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:]
|
for link in sorted_links[1:]
|
||||||
if link.repair
|
if link.repair
|
||||||
]
|
]
|
||||||
@@ -306,6 +339,8 @@ def bulk_delete_reports(
|
|||||||
@router.patch("/{report_id}")
|
@router.patch("/{report_id}")
|
||||||
async def update_report(
|
async def update_report(
|
||||||
report_id: int,
|
report_id: int,
|
||||||
|
charger_id: Optional[str] = Form(None),
|
||||||
|
scope: Optional[str] = Form(None),
|
||||||
issue_types: Optional[str] = Form(None),
|
issue_types: Optional[str] = Form(None),
|
||||||
issue_detail: Optional[str] = Form(None),
|
issue_detail: Optional[str] = Form(None),
|
||||||
error_code: Optional[str] = Form(None),
|
error_code: Optional[str] = Form(None),
|
||||||
@@ -320,6 +355,24 @@ async def update_report(
|
|||||||
import json
|
import json
|
||||||
r = db.query(models.Report).filter_by(id=report_id).first()
|
r = db.query(models.Report).filter_by(id=report_id).first()
|
||||||
if not r: raise HTTPException(404)
|
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:
|
if issue_types is not None:
|
||||||
r.issue_types = json.loads(issue_types)
|
r.issue_types = json.loads(issue_types)
|
||||||
if issue_detail is not None:
|
if issue_detail is not None:
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
#btnDelete { display:none; }
|
#btnDelete { display:none; }
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html" class="active">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html" class="active">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|||||||
@@ -37,9 +37,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@@ -49,7 +51,7 @@
|
|||||||
<select id="fParty" style="width:auto">
|
<select id="fParty" style="width:auto">
|
||||||
<option value="">전체 부담주체</option>
|
<option value="">전체 부담주체</option>
|
||||||
<option value="cpo">CPO</option>
|
<option value="cpo">CPO</option>
|
||||||
<option value="manufacturer">제조사</option>
|
<option value="manufacturer">업체</option>
|
||||||
<option value="self">자체</option>
|
<option value="self">자체</option>
|
||||||
<option value="user">사용자과실</option>
|
<option value="user">사용자과실</option>
|
||||||
<option value="other">기타</option>
|
<option value="other">기타</option>
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
|
<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>
|
</tr></thead>
|
||||||
<tbody id="tbody"></tbody>
|
<tbody id="tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -94,7 +96,7 @@ async function bulkDelete() {
|
|||||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
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() {
|
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)]);
|
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.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.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">${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;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.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>
|
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${Auth.fmtDt(c.reviewed_at)}</td>
|
||||||
|
|||||||
@@ -70,12 +70,21 @@
|
|||||||
.charger-option.selected { background: #EFF6FF; }
|
.charger-option.selected { background: #EFF6FF; }
|
||||||
.charger-option .opt-name { font-weight: 600; color: var(--navy); }
|
.charger-option .opt-name { font-weight: 600; color: var(--navy); }
|
||||||
.charger-option .opt-sub { font-size: 11px; color: var(--gray4); margin-top: 2px; }
|
.charger-option .opt-sub { font-size: 11px; color: var(--gray4); margin-top: 2px; }
|
||||||
.charger-selected-badge {
|
.selected-chargers-list {
|
||||||
display: none; margin-top: 6px; padding: 7px 10px;
|
display: none; flex-wrap: wrap; gap: 5px;
|
||||||
background: #EFF6FF; border: 1px solid #BFDBFE;
|
margin-top: 8px;
|
||||||
border-radius: 6px; font-size: 12px; color: var(--navy2);
|
|
||||||
}
|
}
|
||||||
.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; }
|
.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.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
.detail-tab:hover:not(.active) { color: var(--navy2); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||||
@@ -159,10 +184,11 @@
|
|||||||
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<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>
|
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin:0">대시보드</h2>
|
||||||
<button class="btn-report-new" onclick="openReportModal()">+ 신고 접수</button>
|
<button class="btn-report-new" onclick="openReportModal()">+ 신고 접수</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +197,7 @@
|
|||||||
<!-- 처리 시간 지표 카드 -->
|
<!-- 처리 시간 지표 카드 -->
|
||||||
<div class="card" id="timeMetrics" style="margin-bottom:20px">
|
<div class="card" id="timeMetrics" style="margin-bottom:20px">
|
||||||
<div class="card-title">⏱ 처리 시간 지표</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 드릴다운 뒤로가기 -->
|
<!-- 드릴다운 뒤로가기 -->
|
||||||
@@ -183,7 +209,7 @@
|
|||||||
|
|
||||||
<!-- 월별 처리시간 차트 -->
|
<!-- 월별 처리시간 차트 -->
|
||||||
<div class="card" style="margin-bottom:20px">
|
<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 class="card-title" style="margin:0" id="monthlyChartTitle">📈 월별 평균 처리시간</div>
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
<span id="monthlyChartMode" style="font-size:11px;color:var(--gray4)"></span>
|
<span id="monthlyChartMode" style="font-size:11px;color:var(--gray4)"></span>
|
||||||
@@ -194,28 +220,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="position:relative;height:220px">
|
<div class="dash-chart-wrap">
|
||||||
<canvas id="monthlyChart"></canvas>
|
<canvas id="monthlyChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 월별 신고 접수 건수 차트 -->
|
<!-- 월별 신고 접수 건수 차트 -->
|
||||||
<div class="card" style="margin-bottom:20px">
|
<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 class="card-title" style="margin:0" id="monthlyReportChartTitle">📊 월별 신고 접수 건수</div>
|
||||||
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
|
<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:#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>
|
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#CBD5E1;margin-right:3px"></span>미처리</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="position:relative;height:220px">
|
<div class="dash-chart-wrap">
|
||||||
<canvas id="monthlyReportChart"></canvas>
|
<canvas id="monthlyReportChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 충전기별 누적 고장 Top 10 -->
|
<!-- 충전기별 누적 고장 Top 10 -->
|
||||||
<div class="card" style="margin-bottom:20px">
|
<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 class="card-title" style="margin:0">🏆 충전기별 누적 고장 건수 Top 10</div>
|
||||||
<div style="display:flex;gap:10px;font-size:11px;color:var(--gray4)">
|
<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:#3B82F6;margin-right:3px"></span>처리 완료</span>
|
||||||
@@ -227,18 +253,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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 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>
|
||||||
<div id="errorCodesChartWrap" style="position:relative">
|
<div id="errorCodesChartWrap" style="position:relative">
|
||||||
<canvas id="errorCodesChart"></canvas>
|
<canvas id="errorCodesChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
|
<div class="dash-bottom-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">🔴 접수 대기 현황 <span id="pendingSort" style="font-size:11px;font-weight:400;color:var(--gray4)">(오래된 순)</span></div>
|
<div class="card-title">🔴 접수 대기 현황 <span id="pendingSort" style="font-size:11px;font-weight:400;color:var(--gray4)">(오래된 순)</span></div>
|
||||||
<div id="recentReports"></div>
|
<div id="recentReports"></div>
|
||||||
@@ -284,10 +324,7 @@
|
|||||||
onfocus="openDropdown()" autocomplete="off">
|
onfocus="openDropdown()" autocomplete="off">
|
||||||
<div class="charger-dropdown" id="chargerDropdown"></div>
|
<div class="charger-dropdown" id="chargerDropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="charger-selected-badge" id="selectedBadge">
|
<div class="selected-chargers-list" id="selectedChargersList"></div>
|
||||||
<span id="selectedBadgeText"></span>
|
|
||||||
<button onclick="clearCharger()" style="background:none;border:none;cursor:pointer;color:var(--gray4);font-size:13px">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 발생 일시 -->
|
<!-- 발생 일시 -->
|
||||||
@@ -311,7 +348,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 신고 범위 -->
|
<!-- 신고 범위 -->
|
||||||
<div class="form-row">
|
<div class="form-row" id="scopeRow">
|
||||||
<label class="form-label">신고 범위</label>
|
<label class="form-label">신고 범위</label>
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||||||
@@ -320,11 +357,11 @@
|
|||||||
</label>
|
</label>
|
||||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
<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)">
|
<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>
|
||||||
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
|
<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)">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +433,7 @@ Auth.require(['admin']);
|
|||||||
Auth.renderNav(document.getElementById('navUser'));
|
Auth.renderNav(document.getElementById('navUser'));
|
||||||
|
|
||||||
let allChargers = [];
|
let allChargers = [];
|
||||||
let selectedChargerId = null;
|
let selectedChargers = [];
|
||||||
let cachedIssueTypes = null;
|
let cachedIssueTypes = null;
|
||||||
let selectedChargerErrors = [];
|
let selectedChargerErrors = [];
|
||||||
|
|
||||||
@@ -425,6 +462,7 @@ async function load() {
|
|||||||
]);
|
]);
|
||||||
loadMonthlyChart();
|
loadMonthlyChart();
|
||||||
loadTopChargersChart();
|
loadTopChargersChart();
|
||||||
|
loadTopStationsChart();
|
||||||
loadErrorCodesChart();
|
loadErrorCodesChart();
|
||||||
|
|
||||||
/* ── 통계 카드 ── */
|
/* ── 통계 카드 ── */
|
||||||
@@ -433,7 +471,7 @@ async function load() {
|
|||||||
<div class="stat stat-link" onclick="location.href='/pages/admin/reports.html'">
|
<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 class="stat-num">${stats.total}</div><div class="stat-label">전체 신고</div>
|
||||||
</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 class="stat-num">${stats.pending}</div><div class="stat-label">접수 대기</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat warn stat-link" onclick="location.href='/pages/admin/reports.html?status=in_progress'">
|
<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 warn stat-link" onclick="location.href='/pages/admin/improvements.html'">
|
||||||
<div class="stat-num">${stats.improvement_open}</div><div class="stat-label">개선항목 진행중</div>
|
<div class="stat-num">${stats.improvement_open}</div><div class="stat-label">개선항목 진행중</div>
|
||||||
</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-num" style="font-size:22px">${fmtHours(stats.avg_resolution_hours_30d)}</div>
|
||||||
<div class="stat-label">평균 처리 시간<br><small>(최근 30일)</small></div>
|
<div class="stat-label">평균 처리 시간<br><small>(최근 30일)</small></div>
|
||||||
</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-num">${stats.pending_over_72h}</div>
|
||||||
<div class="stat-label">72h+ 장기 대기</div>
|
<div class="stat-label">72h+ 장기 대기</div>
|
||||||
</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;
|
let _errorCodesChart = null;
|
||||||
async function loadErrorCodesChart() {
|
async function loadErrorCodesChart() {
|
||||||
const data = await API.get('/stats/charger-error-codes');
|
const data = await API.get('/stats/charger-error-codes');
|
||||||
const wrap = document.getElementById('errorCodesChartWrap');
|
const wrap = document.getElementById('errorCodesChartWrap');
|
||||||
if (!data.error_codes || !data.error_codes.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const { error_codes } = data;
|
const { error_codes } = data;
|
||||||
@@ -655,7 +783,7 @@ async function loadErrorCodesChart() {
|
|||||||
labels: error_codes.map(e => e.error_code),
|
labels: error_codes.map(e => e.error_code),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: error_codes.map(e => e.total),
|
data: error_codes.map(e => e.total),
|
||||||
backgroundColor: '#1565C0',
|
backgroundColor: error_codes.map(e => e.error_code === '에러코드 없음' ? '#94A3B8' : '#1565C0'),
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -667,7 +795,9 @@ async function loadErrorCodesChart() {
|
|||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
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}건`,
|
label: c => `누적 ${c.raw}건`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -983,11 +1113,12 @@ function closeReportModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetModal() {
|
function resetModal() {
|
||||||
selectedChargerId = null;
|
selectedChargers = [];
|
||||||
selectedChargerErrors = [];
|
selectedChargerErrors = [];
|
||||||
document.getElementById('chargerSearchInput').value = '';
|
document.getElementById('chargerSearchInput').value = '';
|
||||||
document.getElementById('chargerDropdown').classList.remove('open');
|
document.getElementById('chargerDropdown').classList.remove('open');
|
||||||
document.getElementById('selectedBadge').classList.remove('show');
|
renderSelectedChargers();
|
||||||
|
updateScopeVisibility();
|
||||||
document.getElementById('occurredAt').value = '';
|
document.getElementById('occurredAt').value = '';
|
||||||
document.getElementById('issueDetail').value = '';
|
document.getElementById('issueDetail').value = '';
|
||||||
document.getElementById('contact').value = '';
|
document.getElementById('contact').value = '';
|
||||||
@@ -1016,7 +1147,7 @@ function filterChargers(q) {
|
|||||||
: allChargers.slice(0, 50);
|
: allChargers.slice(0, 50);
|
||||||
|
|
||||||
dd.innerHTML = filtered.map(c => `
|
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||'')}')">
|
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-name">${escHtml(c.station_name)} · ${escHtml(c.name)}</div>
|
||||||
<div class="opt-sub">${c.id}${c.location_detail ? ' · ' + escHtml(c.location_detail) : ''}</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) {
|
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('chargerSearchInput').value = '';
|
||||||
document.getElementById('chargerDropdown').classList.remove('open');
|
document.getElementById('chargerDropdown').classList.remove('open');
|
||||||
const badge = document.getElementById('selectedBadge');
|
renderSelectedChargers();
|
||||||
document.getElementById('selectedBadgeText').textContent =
|
updateScopeVisibility();
|
||||||
`${station} · ${name}${region ? ' (' + region + ')' : ''}`;
|
if (selectedChargers.length === 1) {
|
||||||
badge.classList.add('show');
|
|
||||||
// Load error codes for this charger type
|
|
||||||
try {
|
try {
|
||||||
selectedChargerErrors = await API.get('/chargers/' + id + '/errors');
|
selectedChargerErrors = await API.get('/chargers/' + id + '/errors');
|
||||||
} catch { selectedChargerErrors = []; }
|
} catch { selectedChargerErrors = []; }
|
||||||
renderErrorCodeUI();
|
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() {
|
function renderErrorCodeUI() {
|
||||||
@@ -1075,10 +1247,11 @@ function getModalErrorCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearCharger() {
|
function clearCharger() {
|
||||||
selectedChargerId = null;
|
selectedChargers = [];
|
||||||
selectedChargerErrors = [];
|
selectedChargerErrors = [];
|
||||||
document.getElementById('chargerSearchInput').value = '';
|
document.getElementById('chargerSearchInput').value = '';
|
||||||
document.getElementById('selectedBadge').classList.remove('show');
|
renderSelectedChargers();
|
||||||
|
updateScopeVisibility();
|
||||||
renderErrorCodeUI();
|
renderErrorCodeUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,44 +1269,56 @@ function escHtml(s) {
|
|||||||
|
|
||||||
/* ── 신고 제출 ── */
|
/* ── 신고 제출 ── */
|
||||||
async function submitReport() {
|
async function submitReport() {
|
||||||
if (!selectedChargerId) { alert('충전기를 선택해주세요.'); return; }
|
if (!selectedChargers.length) { alert('충전기를 선택해주세요.'); return; }
|
||||||
const issues = [...document.querySelectorAll('.issue-chk:checked')].map(c => c.value);
|
const issues = [...document.querySelectorAll('.issue-chk:checked')].map(c => c.value);
|
||||||
if (!issues.length) { alert('증상을 하나 이상 선택해주세요.'); return; }
|
if (!issues.length) { alert('증상을 하나 이상 선택해주세요.'); return; }
|
||||||
|
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
btn.disabled = true; btn.textContent = '접수 중...';
|
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();
|
const fd = new FormData();
|
||||||
fd.append('charger_id', selectedChargerId);
|
fd.append('charger_id', selectedChargers[0].id);
|
||||||
fd.append('scope', scope);
|
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('source', 'dashboard');
|
||||||
fd.append('issue_types', JSON.stringify(issues));
|
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');
|
fd.append('consent', 'false');
|
||||||
const occ = document.getElementById('occurredAt').value;
|
if (issueDetail) fd.append('issue_detail', issueDetail);
|
||||||
if (occ) fd.append('occurred_at', occ);
|
if (errorCode) fd.append('error_code', errorCode);
|
||||||
const ocppLogText = document.getElementById('ocppLog').value.trim();
|
if (contact) fd.append('contact', contact);
|
||||||
if (ocppLogText) fd.append('ocpp_log', ocppLogText);
|
if (occurredAt) fd.append('occurred_at', occurredAt);
|
||||||
Array.from(document.getElementById('modalPhoto').files).forEach(f => fd.append('photos', f));
|
if (ocppLog) fd.append('ocpp_log', ocppLog);
|
||||||
|
photos.forEach(f => fd.append('photos', f));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/reports/batch', { method: 'POST', body: fd,
|
const res = await fetch('/api/reports/batch', {
|
||||||
headers: { 'Authorization': 'Bearer ' + Auth.token() } });
|
method: 'POST', body: fd,
|
||||||
|
headers: { 'Authorization': 'Bearer ' + Auth.token() },
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
closeReportModal();
|
closeReportModal();
|
||||||
load();
|
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})`);
|
alert(`신고가 ${data.count}건 접수되었습니다. (첫 번째 신고 #${data.primary_id})`);
|
||||||
location.href = `/pages/admin/report-detail.html?id=${data.primary_id}`;
|
|
||||||
} else {
|
} else {
|
||||||
alert(`신고가 접수되었습니다. (신고 #${data.primary_id})`);
|
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) {
|
} catch(e) {
|
||||||
alert('오류: ' + e.message);
|
alert('오류: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
224
frontend/static/pages/admin/export.html
Normal file
224
frontend/static/pages/admin/export.html
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>데이터 내보내기</title><link rel="stylesheet" href="/css/style.css">
|
||||||
|
<style>
|
||||||
|
.export-card { background:white; border-radius:10px; padding:24px; box-shadow:0 2px 8px rgba(0,0,0,.06); margin-bottom:20px; }
|
||||||
|
.sheet-badge { display:inline-flex; align-items:center; gap:6px; padding:5px 14px; border-radius:20px; font-size:12px; font-weight:700; margin:3px; }
|
||||||
|
.date-row { display:flex; gap:14px; align-items:flex-end; flex-wrap:wrap; margin-top:18px; }
|
||||||
|
.date-row .form-group { margin:0; min-width:140px; }
|
||||||
|
.quick-btns { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
|
||||||
|
.quick-btn { padding:4px 12px; border:1px solid var(--gray3); border-radius:6px; background:white; font-size:12px; color:var(--navy); cursor:pointer; }
|
||||||
|
.quick-btn:hover { background:var(--gray1); border-color:var(--accent); }
|
||||||
|
.download-btn { padding:12px 28px; font-size:15px; font-weight:700; border-radius:8px; border:none;
|
||||||
|
background:var(--blue); color:white; cursor:pointer; display:flex; align-items:center; gap:8px;
|
||||||
|
transition:background .15s; min-width:220px; justify-content:center; }
|
||||||
|
.download-btn:hover { background:#1251A3; }
|
||||||
|
.download-btn:disabled { background:var(--gray3); cursor:not-allowed; }
|
||||||
|
.ind-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; margin-top:16px; }
|
||||||
|
.ind-item { border:1px solid var(--gray2); border-radius:8px; padding:14px 16px; }
|
||||||
|
.ind-item .ind-title { font-size:13px; font-weight:700; color:var(--navy); margin-bottom:8px; }
|
||||||
|
.ind-item .ind-desc { font-size:11px; color:var(--gray4); margin-bottom:10px; line-height:1.5; }
|
||||||
|
.status-msg { padding:10px 14px; border-radius:6px; font-size:13px; margin-top:12px; display:none; }
|
||||||
|
@media(max-width:768px){
|
||||||
|
.date-row { flex-direction:column; align-items:stretch; }
|
||||||
|
.download-btn { width:100%; }
|
||||||
|
.ind-grid { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<div style="display:flex;align-items:center;gap:2px;">
|
||||||
|
<button class="nav-hamburger" onclick="toggleSidebar()">☰</button>
|
||||||
|
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
||||||
|
</div>
|
||||||
|
<div id="navUser"></div>
|
||||||
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-section">AS 관리</div>
|
||||||
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
|
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||||
|
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
|
||||||
|
<div class="sidebar-section">시스템</div>
|
||||||
|
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
|
||||||
|
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
|
||||||
|
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
|
||||||
|
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
|
||||||
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html" class="active">📥 데이터 내보내기</a>
|
||||||
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px;flex-wrap:wrap;">
|
||||||
|
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">📥 데이터 내보내기</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 통합 다운로드 -->
|
||||||
|
<div class="export-card">
|
||||||
|
<div class="card-title">📊 기간별 통합 다운로드</div>
|
||||||
|
<p style="font-size:13px;color:var(--gray4);margin-bottom:8px;line-height:1.7;">
|
||||||
|
설정한 기간의 모든 데이터를 <strong>하나의 엑셀 파일(4개 시트)</strong>로 다운로드합니다.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:4px;">
|
||||||
|
<span class="sheet-badge" style="background:#0B1E3D;color:white;">① AS 신고이력</span>
|
||||||
|
<span class="sheet-badge" style="background:#1B5E20;color:white;">② 조치이력</span>
|
||||||
|
<span class="sheet-badge" style="background:#4A148C;color:white;">③ 개선항목</span>
|
||||||
|
<span class="sheet-badge" style="background:#E65100;color:white;">④ 출장비정산</span>
|
||||||
|
<span class="sheet-badge" style="background:#37474F;color:white;">+ 요약</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>시작일</label>
|
||||||
|
<input type="date" id="dateFrom">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>종료일</label>
|
||||||
|
<input type="date" id="dateTo">
|
||||||
|
</div>
|
||||||
|
<button class="download-btn" id="fullBtn" onclick="downloadFull()">
|
||||||
|
📥 통합 엑셀 다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-btns">
|
||||||
|
<span style="font-size:11px;color:var(--gray4);align-self:center;">빠른 선택:</span>
|
||||||
|
<button class="quick-btn" onclick="setRange(7)">최근 7일</button>
|
||||||
|
<button class="quick-btn" onclick="setRange(30)">최근 30일</button>
|
||||||
|
<button class="quick-btn" onclick="setRange(90)">최근 3개월</button>
|
||||||
|
<button class="quick-btn" onclick="setThisMonth()">이번 달</button>
|
||||||
|
<button class="quick-btn" onclick="setLastMonth()">지난 달</button>
|
||||||
|
<button class="quick-btn" onclick="setThisYear()">올해 전체</button>
|
||||||
|
<button class="quick-btn" onclick="clearRange()">전체 기간</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="statusMsg" class="status-msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 개별 다운로드 -->
|
||||||
|
<div class="export-card">
|
||||||
|
<div class="card-title">📄 항목별 개별 다운로드</div>
|
||||||
|
<p style="font-size:13px;color:var(--gray4);margin-bottom:4px;">위 기간 설정이 동일하게 적용됩니다.</p>
|
||||||
|
<div class="ind-grid">
|
||||||
|
<div class="ind-item">
|
||||||
|
<div class="ind-title" style="color:#0B1E3D;">📋 AS 신고이력</div>
|
||||||
|
<div class="ind-desc">신고 접수일 기준 · 충전기/신고자/상태/<br>정비사/조치내용/출장비 포함</div>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('reports')">📥 다운로드</button>
|
||||||
|
</div>
|
||||||
|
<div class="ind-item">
|
||||||
|
<div class="ind-title" style="color:#1B5E20;">🔧 조치이력</div>
|
||||||
|
<div class="ind-desc">조치 완료일 기준 · 정비사/조치유형/<br>소요시간/승인 여부 포함</div>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('repairs')">📥 다운로드</button>
|
||||||
|
</div>
|
||||||
|
<div class="ind-item">
|
||||||
|
<div class="ind-title" style="color:#4A148C;">🔧 개선항목</div>
|
||||||
|
<div class="ind-desc">등록일 기준 · 분류/우선순위/<br>담당업체/진행상태 포함</div>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('improvements')">📥 다운로드</button>
|
||||||
|
</div>
|
||||||
|
<div class="ind-item">
|
||||||
|
<div class="ind-title" style="color:#E65100;">💰 출장비 정산</div>
|
||||||
|
<div class="ind-desc">조치 완료일 기준 · 부담/수급 주체/<br>금액/정산상태 포함</div>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="downloadIndividual('costs')">📥 다운로드</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||||
|
<script>
|
||||||
|
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const s = document.getElementById('sidebar');
|
||||||
|
const o = document.getElementById('navOverlay');
|
||||||
|
if (s) s.classList.toggle('mobile-open');
|
||||||
|
if (o) o.classList.toggle('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n) { return String(n).padStart(2,'0'); }
|
||||||
|
function fmtDate(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
|
||||||
|
|
||||||
|
function setRange(days) {
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(); from.setDate(from.getDate() - days + 1);
|
||||||
|
document.getElementById('dateFrom').value = fmtDate(from);
|
||||||
|
document.getElementById('dateTo').value = fmtDate(to);
|
||||||
|
}
|
||||||
|
function setThisMonth() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('dateFrom').value = `${now.getFullYear()}-${pad(now.getMonth()+1)}-01`;
|
||||||
|
document.getElementById('dateTo').value = fmtDate(now);
|
||||||
|
}
|
||||||
|
function setLastMonth() {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getMonth() === 0 ? now.getFullYear()-1 : now.getFullYear();
|
||||||
|
const m = now.getMonth() === 0 ? 12 : now.getMonth();
|
||||||
|
const last = new Date(y, m, 0);
|
||||||
|
document.getElementById('dateFrom').value = `${y}-${pad(m)}-01`;
|
||||||
|
document.getElementById('dateTo').value = fmtDate(last);
|
||||||
|
}
|
||||||
|
function setThisYear() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('dateFrom').value = `${now.getFullYear()}-01-01`;
|
||||||
|
document.getElementById('dateTo').value = fmtDate(now);
|
||||||
|
}
|
||||||
|
function clearRange() {
|
||||||
|
document.getElementById('dateFrom').value = '';
|
||||||
|
document.getElementById('dateTo').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery() {
|
||||||
|
const from = document.getElementById('dateFrom').value;
|
||||||
|
const to = document.getElementById('dateTo').value;
|
||||||
|
const p = [];
|
||||||
|
if (from) p.push('date_from=' + from);
|
||||||
|
if (to) p.push('date_to=' + to);
|
||||||
|
return p.length ? '?' + p.join('&') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(msg, type='info') {
|
||||||
|
const el = document.getElementById('statusMsg');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.className = 'status-msg alert alert-' + type;
|
||||||
|
}
|
||||||
|
function hideStatus() { document.getElementById('statusMsg').style.display = 'none'; }
|
||||||
|
|
||||||
|
async function downloadFull() {
|
||||||
|
const btn = document.getElementById('fullBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ 생성 중...';
|
||||||
|
showStatus('엑셀 파일을 생성 중입니다. 데이터량에 따라 수 초가 걸릴 수 있습니다.', 'info');
|
||||||
|
try {
|
||||||
|
const from = document.getElementById('dateFrom').value;
|
||||||
|
const to = document.getElementById('dateTo').value;
|
||||||
|
const period = (from || to) ? `${from||'전체'}~${to||'전체'}` : '전체기간';
|
||||||
|
await API.download('/export/full' + buildQuery(), `EV_AS_통합이력_${period}.xlsx`);
|
||||||
|
showStatus('✅ 다운로드가 완료되었습니다.', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showStatus('❌ 오류: ' + e.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '📥 통합 엑셀 다운로드';
|
||||||
|
setTimeout(hideStatus, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadIndividual(type) {
|
||||||
|
const names = {
|
||||||
|
reports: 'AS신고이력',
|
||||||
|
repairs: '조치이력',
|
||||||
|
improvements: '개선항목',
|
||||||
|
costs: '출장비정산',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await API.download('/export/' + type + buildQuery(), `${names[type]}.xlsx`);
|
||||||
|
} catch(e) { alert('다운로드 오류: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값: 이번 달
|
||||||
|
setThisMonth();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
@@ -1,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>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
<div class="page-header">
|
||||||
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm">← 목록</a>
|
<a href="/pages/admin/improvements.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)">개선항목 상세</h2>
|
<h2 id="pageTitle">개선항목 상세</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="content"></div>
|
<div id="content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
|
||||||
<script>
|
<script>
|
||||||
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
|
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 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_OPTIONS = ['registered','reviewing','developing','deployed','done'];
|
||||||
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
|
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const imp = await API.get('/improvements/'+id);
|
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 = `
|
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">
|
||||||
<div class="card-title">📋 기본 정보</div>
|
<div class="card-title">📋 기본 정보</div>
|
||||||
<table class="no-hover" style="font-size:13px">
|
<dl class="info-dl">
|
||||||
<tr><td style="color:var(--gray4);width:90px">제목</td><td><strong>${imp.title}</strong></td></tr>
|
<dt>제목</dt> <dd><strong>${imp.title}</strong></dd>
|
||||||
<tr><td style="color:var(--gray4)">분류</td><td>${CAT[imp.category]||imp.category}</td></tr>
|
<dt>분류</dt> <dd>${CAT[imp.category]||imp.category}</dd>
|
||||||
<tr><td style="color:var(--gray4)">우선순위</td><td>${imp.priority}</td></tr>
|
<dt>우선순위</dt> <dd>${PRI[imp.priority]||imp.priority}</dd>
|
||||||
<tr><td style="color:var(--gray4)">관련 부품</td><td>${imp.part_name||'-'}</td></tr>
|
<dt>관련 부품</dt> <dd>${imp.part_name||'-'}</dd>
|
||||||
<tr><td style="color:var(--gray4)">담당 제조사</td><td><strong>${imp.manufacturer_company||'-'}</strong><br>${imp.manufacturer_name||''}</td></tr>
|
<dt>담당 업체</dt> <dd><strong>${imp.manufacturer_name||'-'}</strong></dd>
|
||||||
<tr><td style="color:var(--gray4)">등록자</td><td>${imp.created_by_name||'-'}</td></tr>
|
<dt>등록자</dt> <dd>${imp.created_by_name||'-'}</dd>
|
||||||
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
|
<dt>등록일시</dt> <dd>${Auth.fmtDt(imp.created_at)}</dd>
|
||||||
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
|
<dt>배포 목표일</dt><dd>${imp.sw_deploy_target||'-'}</dd>
|
||||||
<tr><td style="color:var(--gray4)">실제 배포일</td><td>${imp.sw_deployed_at||'-'}</td></tr>
|
<dt>실제 배포일</dt><dd>${imp.sw_deployed_at||'-'}</dd>
|
||||||
<tr><td style="color:var(--gray4)">현재 상태</td><td>${Auth.statusBadge(imp.status)}</td></tr>
|
<dt>상태</dt> <dd>${Auth.statusBadge(imp.status)}</dd>
|
||||||
</table>
|
</dl>
|
||||||
<div style="margin-top:12px">
|
<div style="margin-top:14px">
|
||||||
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">개선 내용</div>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<!-- 연결 AS + 첨부 -->
|
||||||
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">📎 연결된 AS 신고</div>
|
<div class="card-title">📎 연결된 AS 신고</div>
|
||||||
${imp.report_ids.length ? imp.report_ids.map(rid=>`
|
${(imp.report_links||[]).length
|
||||||
<div onclick="location.href='/pages/admin/report-detail.html?id=${rid}'"
|
? (imp.report_links||[]).map(r => `
|
||||||
style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;cursor:pointer;font-size:13px">
|
<a class="report-link" href="/pages/admin/report-detail.html?id=${r.id}">
|
||||||
신고 #${rid}
|
<span class="report-link-num">#${r.seq}</span>
|
||||||
</div>`).join('') : '<div class="alert alert-info">연결된 신고 없음</div>'}
|
<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>
|
<div class="card">
|
||||||
${imp.attachments.length ? imp.attachments.map(a=>`
|
<div class="card-title">📁 첨부 파일</div>
|
||||||
<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-bottom:6px;display:block">
|
${imp.attachments.length
|
||||||
📄 ${a.name||a.path.split('/').pop()}
|
? imp.attachments.map(a => `
|
||||||
</a>`).join('') : '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 상태 변경 -->
|
<!-- 상태 변경 -->
|
||||||
<div class="card" style="margin-top:0">
|
<div class="card">
|
||||||
<div class="card-title">🔄 상태 변경</div>
|
<div class="card-title">🔄 상태 변경</div>
|
||||||
<div class="form-row">
|
<div class="status-form">
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin:0">
|
||||||
<label>상태 변경</label>
|
<label>상태</label>
|
||||||
<select id="newStatus">
|
<select id="newStatus">
|
||||||
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
|
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin:0">
|
||||||
<label>SW 실제 배포일 (배포완료 시)</label>
|
<label>SW 실제 배포일 <span style="color:var(--gray4);font-weight:400">(배포완료 시)</span></label>
|
||||||
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
|
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label>변경 메모</label><input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모"></div>
|
<div class="form-group" style="margin-top:12px">
|
||||||
<button class="btn btn-primary" onclick="changeStatus()">상태 저장</button>
|
<label>변경 메모</label>
|
||||||
|
<input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="changeStatus()">저장</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 이력 로그 -->
|
<!-- 변경 이력 -->
|
||||||
<div class="card" style="margin-top:0">
|
<div class="card">
|
||||||
<div class="card-title">📜 변경 이력</div>
|
<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-item">
|
||||||
<div class="tl-time">${Auth.fmtDt(l.changed_at)} — ${l.by||'시스템'}</div>
|
<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 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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeStatus() {
|
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();
|
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);
|
if (date) fd.append('sw_deployed_at', date);
|
||||||
await API.patch('/improvements/'+id+'/status', fd);
|
await API.patch('/improvements/'+id+'/status', fd);
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
</script></div></div></body></html>
|
</script>
|
||||||
|
</body></html>
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
#btnDelete { display:none; }
|
#btnDelete { display:none; }
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@@ -62,9 +64,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>분류 <span class="req">*</span></label>
|
<label>분류 <span class="req">*</span></label>
|
||||||
<select id="mCat">
|
<select id="mCat">
|
||||||
<option value="sw">SW 개선</option><option value="hw">HW 개선</option>
|
<option value="hardware">하드웨어</option><option value="software">소프트웨어</option>
|
||||||
<option value="ui">UI 개선</option><option value="firmware">펌웨어</option>
|
<option value="firmware">펌웨어</option><option value="installation">설치환경</option>
|
||||||
<option value="other">기타</option>
|
<option value="ui">UI 개선</option><option value="other">기타</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -123,20 +125,25 @@ async function bulkDelete() {
|
|||||||
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
|
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 PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
|
||||||
const selectedReports = new Set();
|
const selectedReports = new Set();
|
||||||
let allReports = [];
|
let allReports = [];
|
||||||
|
|
||||||
async function load() {
|
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([
|
const [mfrs, imps] = await Promise.all([
|
||||||
API.get('/accounts?role=manufacturer'),
|
API.get('/manufacturers'),
|
||||||
API.get('/improvements?status='+document.getElementById('fStatus').value+'&manufacturer_id='+document.getElementById('fMfr').value)
|
API.get(impUrl)
|
||||||
]);
|
]);
|
||||||
// 제조사 필터 드롭다운
|
// 제조사 필터 드롭다운
|
||||||
const mfrSel = document.getElementById('fMfr');
|
const mfrSel = document.getElementById('fMfr');
|
||||||
if (mfrSel.options.length <= 1)
|
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;
|
document.getElementById('chkAll').checked = false;
|
||||||
updateDeleteBtn();
|
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;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">${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">${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"><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.statusBadge(i.status)}</td>
|
||||||
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.fmtDt(i.created_at)}</td>
|
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.fmtDt(i.created_at)}</td>
|
||||||
@@ -161,9 +168,9 @@ async function load() {
|
|||||||
|
|
||||||
async function openModal() {
|
async function openModal() {
|
||||||
document.getElementById('modal').classList.remove('hidden');
|
document.getElementById('modal').classList.remove('hidden');
|
||||||
const mfrs = await API.get('/accounts?role=manufacturer');
|
const mfrs = await API.get('/manufacturers');
|
||||||
document.getElementById('mMfr').innerHTML = '<option value="">제조사 선택</option>' +
|
document.getElementById('mMfr').innerHTML = '<option value="">업체 선택</option>' +
|
||||||
mfrs.map(m=>`<option value="${m.id}">${m.company||''} / ${m.name}</option>`).join('');
|
mfrs.map(m=>`<option value="${m.id}">${m.name}</option>`).join('');
|
||||||
allReports = await API.get('/reports');
|
allReports = await API.get('/reports');
|
||||||
renderReportList('');
|
renderReportList('');
|
||||||
}
|
}
|
||||||
@@ -171,12 +178,12 @@ function closeModal() { document.getElementById('modal').classList.add('hidden')
|
|||||||
|
|
||||||
function searchReports() { renderReportList(document.getElementById('mReportSearch').value.toLowerCase()); }
|
function searchReports() { renderReportList(document.getElementById('mReportSearch').value.toLowerCase()); }
|
||||||
function renderReportList(q) {
|
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 => `
|
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':''}">
|
<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"
|
<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}')">
|
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>';
|
</label>`).join('') || '<div style="color:var(--gray4)">검색 결과 없음</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,9 +59,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html" class="active">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|||||||
@@ -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>
|
<!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>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|||||||
@@ -114,15 +114,29 @@
|
|||||||
.issue-chk-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:6px; }
|
.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 { display:flex; align-items:center; gap:6px; font-size:13px; }
|
||||||
.issue-chk-item input { width:15px; height:15px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="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>
|
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
|
||||||
|
</div>
|
||||||
<div id="navUser"></div>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
<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">← 목록</a>
|
<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)">신고 상세</h2>
|
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy);min-width:0;word-break:break-word;">신고 상세</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="content"></div>
|
<div id="content"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,12 +167,19 @@
|
|||||||
Auth.require(['admin']);
|
Auth.require(['admin']);
|
||||||
Auth.renderNav(document.getElementById('navUser'));
|
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 params = new URLSearchParams(location.search);
|
||||||
const reportId = params.get('id');
|
const reportId = params.get('id');
|
||||||
|
|
||||||
const PARTY_LABEL = {
|
const PARTY_LABEL = {
|
||||||
cpo: 'CPO (운영사)',
|
cpo: 'CPO (운영사)',
|
||||||
manufacturer: '제조사',
|
manufacturer: '업체',
|
||||||
self: '자체 부담',
|
self: '자체 부담',
|
||||||
user: '사용자 과실',
|
user: '사용자 과실',
|
||||||
other: '기타',
|
other: '기타',
|
||||||
@@ -175,23 +197,36 @@ const COST_STATUS_ICON = {
|
|||||||
settled: '✅',
|
settled: '✅',
|
||||||
};
|
};
|
||||||
|
|
||||||
let editOpen = false;
|
function toggleCostEdit(repairId) {
|
||||||
|
const wrap = document.getElementById('costEditWrap_' + repairId);
|
||||||
function toggleEdit() {
|
const btn = document.getElementById('editToggleBtn_' + repairId);
|
||||||
editOpen = !editOpen;
|
if (!wrap) return;
|
||||||
const wrap = document.getElementById('costEditWrap');
|
const isOpen = wrap.style.display !== 'none';
|
||||||
const btn = document.getElementById('editToggleBtn');
|
if (isOpen) {
|
||||||
if (editOpen) {
|
wrap.style.display = 'none';
|
||||||
wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
|
if (btn) btn.innerHTML = '✏️ 수정하기';
|
||||||
wrap.classList.remove('collapsed');
|
|
||||||
btn.innerHTML = '▲ 수정 접기';
|
|
||||||
} else {
|
} else {
|
||||||
wrap.style.maxHeight = '0';
|
wrap.style.display = 'block';
|
||||||
wrap.classList.add('collapsed');
|
if (btn) btn.innerHTML = '▲ 접기';
|
||||||
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 = {
|
const IMP_CAT_LABEL = {
|
||||||
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
|
hardware:'하드웨어', software:'소프트웨어', firmware:'펌웨어',
|
||||||
installation:'설치환경', other:'기타'
|
installation:'설치환경', other:'기타'
|
||||||
@@ -201,158 +236,143 @@ async function load() {
|
|||||||
const [r, issueTypes, manufacturers, improvements] = await Promise.all([
|
const [r, issueTypes, manufacturers, improvements] = await Promise.all([
|
||||||
API.get('/reports/' + reportId),
|
API.get('/reports/' + reportId),
|
||||||
API.get('/settings/issue-types'),
|
API.get('/settings/issue-types'),
|
||||||
API.get('/accounts?role=manufacturer'),
|
API.get('/manufacturers/public'),
|
||||||
API.get('/improvements'),
|
API.get('/improvements'),
|
||||||
]);
|
]);
|
||||||
const repair = r.repair;
|
const repair = r.repair;
|
||||||
const cost = repair?.cost;
|
const cost = repair?.cost;
|
||||||
const prevRepairs = r.prev_repairs || [];
|
const prevRepairs = r.prev_repairs || [];
|
||||||
|
window._reportData = r; // saveReport 경고용
|
||||||
|
|
||||||
document.getElementById('pageTitle').textContent = `신고 #${r.seq} 상세`;
|
document.getElementById('pageTitle').textContent = `신고 #${r.seq} 상세`;
|
||||||
|
|
||||||
// ── 출장비 요약 HTML 생성 ──
|
// ── 조치별 출장비 HTML 생성 함수 ──
|
||||||
let costHtml = '';
|
function buildCostHtml(rep, mfrs) {
|
||||||
if (repair) {
|
const c = rep.cost;
|
||||||
const hasCost = cost && cost.cost_party_type;
|
const rid = rep.id;
|
||||||
const costStatus = cost?.cost_status || 'pending';
|
const hasCost = c && c.cost_party_type;
|
||||||
|
const costStatus = c?.cost_status || 'pending';
|
||||||
const statusLabel = COST_STATUS_LABEL[costStatus] || costStatus;
|
const statusLabel = COST_STATUS_LABEL[costStatus] || costStatus;
|
||||||
const statusIcon = COST_STATUS_ICON[costStatus] || '🕐';
|
const statusIcon = COST_STATUS_ICON[costStatus] || '🕐';
|
||||||
|
|
||||||
// 부담 주체 텍스트
|
|
||||||
let partyText = '-';
|
let partyText = '-';
|
||||||
if (cost?.cost_party_type) {
|
if (c?.cost_party_type) {
|
||||||
partyText = PARTY_LABEL[cost.cost_party_type] || cost.cost_party_type;
|
partyText = PARTY_LABEL[c.cost_party_type] || c.cost_party_type;
|
||||||
if (cost.cost_party_type === 'manufacturer' && cost.manufacturer_name) {
|
if (c.cost_party_type === 'manufacturer' && c.cost_manufacturer_name) partyText += ` (${c.cost_manufacturer_name})`;
|
||||||
partyText += ` (${cost.manufacturer_name})`;
|
if (c.cost_party_type === 'other' && c.cost_party_custom) partyText += ` — ${c.cost_party_custom}`;
|
||||||
}
|
}
|
||||||
if (cost.cost_party_type === 'other' && cost.cost_party_custom) {
|
|
||||||
partyText += ` — ${cost.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 ? `
|
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-header">
|
||||||
<div class="cost-summary-title">
|
<div class="cost-summary-title">${statusIcon} 출장비 처리 내역</div>
|
||||||
${statusIcon} 출장비 처리 내역
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
<span class="cost-status-badge csb-${costStatus}">${statusLabel}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="cost-summary-grid">
|
<div class="cost-summary-grid">
|
||||||
<div class="cost-summary-item">
|
<div class="cost-summary-item"><label>부담 주체</label><span>${partyText}</span></div>
|
||||||
<label>출장비 부담 주체</label>
|
<div class="cost-summary-item"><label>수급 주체</label><span>${recvText}</span></div>
|
||||||
<span>${partyText}</span>
|
<div class="cost-summary-item"><label>금액</label><span class="amount">${(c.cost_amount||0).toLocaleString()}원</span></div>
|
||||||
</div>
|
<div class="cost-summary-item"><label>담당자</label><span>${c.reviewed_by_name||'-'}</span></div>
|
||||||
<div class="cost-summary-item">
|
<div class="cost-summary-item"><label>처리일시</label><span>${Auth.fmtDt(c.reviewed_at)}</span></div>
|
||||||
<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>
|
</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) ? `
|
const formDisplay = hasCost ? 'none' : 'block';
|
||||||
<hr class="cost-summary-divider">
|
return `
|
||||||
${cost.root_cause ? `
|
<div style="border-top:1px dashed var(--gray3);margin-top:14px;padding-top:14px;">
|
||||||
<div style="margin-bottom:8px;">
|
<div style="font-size:13px;font-weight:700;color:var(--navy);margin-bottom:10px;">💰 출장비 정산</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.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>
|
|
||||||
|
|
||||||
${summaryHtml}
|
${summaryHtml}
|
||||||
|
<div id="costEditWrap_${rid}" style="display:${formDisplay}">
|
||||||
<!-- 입력 / 수정 폼 -->
|
|
||||||
<div class="cost-edit-wrap ${formCollapsed}" id="costEditWrap">
|
|
||||||
<div class="cost-edit-inner">
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>문제 원인 파악 <span class="req">*</span></label>
|
<label>문제 원인 <span class="req">*</span></label>
|
||||||
<textarea id="rootCause" rows="3"
|
<textarea id="rootCause_${rid}" rows="3" placeholder="조치 내용 검토 후 원인 기재">${c?.root_cause||''}</textarea>
|
||||||
placeholder="조치 내용 검토 후 원인을 기재하세요.">${cost?.root_cause || ''}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>비고</label>
|
<label>비고</label>
|
||||||
<textarea id="adminNote" rows="3"
|
<textarea id="adminNote_${rid}" rows="3" placeholder="특이사항">${c?.admin_note||''}</textarea>
|
||||||
placeholder="특이사항, 추가 메모 등">${cost?.admin_note || ''}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
</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-row-3">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>출장비 부담 주체 <span class="req">*</span></label>
|
<label>유형 <span class="req">*</span></label>
|
||||||
<select id="partyType" onchange="toggleParty()">
|
<select id="partyType_${rid}" onchange="togglePartySelect(${rid})">
|
||||||
<option value="">선택</option>
|
<option value="">선택</option>
|
||||||
<option value="cpo" ${cost?.cost_party_type === 'cpo' ? 'selected' : ''}>CPO (운영사)</option>
|
<option value="cpo" ${c?.cost_party_type==='cpo' ?'selected':''}>CPO (운영사)</option>
|
||||||
<option value="manufacturer" ${cost?.cost_party_type === 'manufacturer' ? 'selected' : ''}>제조사</option>
|
<option value="manufacturer" ${c?.cost_party_type==='manufacturer' ?'selected':''}>업체</option>
|
||||||
<option value="self" ${cost?.cost_party_type === 'self' ? 'selected' : ''}>자체 부담</option>
|
<option value="self" ${c?.cost_party_type==='self' ?'selected':''}>자체 부담</option>
|
||||||
<option value="user" ${cost?.cost_party_type === 'user' ? 'selected' : ''}>사용자 과실</option>
|
<option value="user" ${c?.cost_party_type==='user' ?'selected':''}>사용자 과실</option>
|
||||||
<option value="other" ${cost?.cost_party_type === 'other' ? 'selected' : ''}>기타</option>
|
<option value="other" ${c?.cost_party_type==='other' ?'selected':''}>기타</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="mfrWrap"
|
<div class="form-group" id="mfrWrap_${rid}" style="display:${c?.cost_party_type==='manufacturer'?'block':'none'}">
|
||||||
style="display:${cost?.cost_party_type === 'manufacturer' ? 'block' : 'none'}">
|
<label>업체</label>
|
||||||
<label>제조사 선택</label>
|
<select id="partyMfr_${rid}">
|
||||||
<select id="partyMfr">
|
|
||||||
<option value="">선택</option>
|
<option value="">선택</option>
|
||||||
${manufacturers.map(m =>
|
${mfrs.map(m=>`<option value="${m.id}" ${c?.cost_party_manufacturer_id==m.id?'selected':''}>${m.name}</option>`).join('')}
|
||||||
`<option value="${m.id}"
|
|
||||||
${cost?.cost_party_manufacturer_id == m.id ? 'selected' : ''}>
|
|
||||||
${m.company || ''} / ${m.name}
|
|
||||||
</option>`).join('')}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="customWrap"
|
<div class="form-group" id="customWrap_${rid}" style="display:${c?.cost_party_type==='other'?'block':'none'}">
|
||||||
style="display:${cost?.cost_party_type === 'other' ? 'block' : 'none'}">
|
<label>기타</label>
|
||||||
<label>기타 직접 입력</label>
|
<input type="text" id="partyCustom_${rid}" value="${c?.cost_party_custom||''}">
|
||||||
<input type="text" id="partyCustom" value="${cost?.cost_party_custom || ''}">
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label>출장비 금액 (원)</label>
|
<label>유형</label>
|
||||||
<input type="number" id="costAmount"
|
<select id="recvPartyType_${rid}" onchange="toggleRecvPartySelect(${rid})">
|
||||||
value="${cost?.cost_amount || 0}" min="0" step="1000">
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>처리 상태</label>
|
<label>처리 상태</label>
|
||||||
<select id="costStatus">
|
<select id="costStatus_${rid}">
|
||||||
<option value="pending" ${(!cost || cost.cost_status === 'pending') ? 'selected' : ''}>미처리</option>
|
<option value="pending" ${(!c||c.cost_status==='pending') ?'selected':''}>미처리</option>
|
||||||
<option value="billed" ${cost?.cost_status === 'billed' ? 'selected' : ''}>청구완료</option>
|
<option value="billed" ${c?.cost_status==='billed' ?'selected':''}>청구완료</option>
|
||||||
<option value="waived" ${cost?.cost_status === 'waived' ? 'selected' : ''}>면제</option>
|
<option value="waived" ${c?.cost_status==='waived' ?'selected':''}>면제</option>
|
||||||
<option value="settled" ${cost?.cost_status === 'settled' ? 'selected' : ''}>정산완료</option>
|
<option value="settled" ${c?.cost_status==='settled' ?'selected':''}>정산완료</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="costErr" class="alert alert-danger" style="display:none"></div>
|
<div id="costErr_${rid}" class="alert alert-danger" style="display:none"></div>
|
||||||
<button class="btn btn-primary" onclick="saveCost(${repair.id})">
|
<button class="btn btn-primary" onclick="saveCost(${rid})">💾 출장비 저장</button>
|
||||||
💾 출장비 처리 저장
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -369,8 +389,19 @@ async function load() {
|
|||||||
|
|
||||||
<!-- 보기 모드 -->
|
<!-- 보기 모드 -->
|
||||||
<div class="report-view" id="reportView">
|
<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;">
|
<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.charger_name || '-'}</td></tr>
|
||||||
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
|
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
|
||||||
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
|
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
|
||||||
@@ -437,6 +468,60 @@ async function load() {
|
|||||||
|
|
||||||
<!-- 편집 모드 -->
|
<!-- 편집 모드 -->
|
||||||
<div class="report-edit" id="reportEdit">
|
<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">
|
<div class="form-group" style="margin-bottom:10px">
|
||||||
<label style="font-size:12px;font-weight:700;color:var(--navy2)">문제 유형 <span class="req">*</span></label>
|
<label style="font-size:12px;font-weight:700;color:var(--navy2)">문제 유형 <span class="req">*</span></label>
|
||||||
<div class="issue-chk-grid">
|
<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>
|
${(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 class="photo-preview">${(pr.photos_after||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
${buildCostHtml(pr, manufacturers)}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
})() : ''}
|
})() : ''}
|
||||||
|
|
||||||
@@ -592,6 +678,7 @@ async function load() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${renderLocationMap(repair)}
|
${renderLocationMap(repair)}
|
||||||
|
${buildCostHtml(repair, manufacturers)}
|
||||||
|
|
||||||
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
|
${/* ── 연결된 개선항목 표시 (승인 완료 후) ── */
|
||||||
repair.linked_improvements && repair.linked_improvements.length ? `
|
repair.linked_improvements && repair.linked_improvements.length ? `
|
||||||
@@ -678,7 +765,7 @@ async function load() {
|
|||||||
<select id="impMfr">
|
<select id="impMfr">
|
||||||
<option value="">미지정 (나중에 설정)</option>
|
<option value="">미지정 (나중에 설정)</option>
|
||||||
${manufacturers.map(m =>
|
${manufacturers.map(m =>
|
||||||
`<option value="${m.id}">${m.company ? m.company+' / ' : ''}${m.name}</option>`
|
`<option value="${m.id}">${m.name}</option>`
|
||||||
).join('')}
|
).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -695,7 +782,6 @@ async function load() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
${costHtml}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 신고 편집 폼 사진 압축 설정
|
// 신고 편집 폼 사진 압축 설정
|
||||||
@@ -705,11 +791,6 @@ async function load() {
|
|||||||
|
|
||||||
// 지도 초기화 (수리 정보가 있을 때만)
|
// 지도 초기화 (수리 정보가 있을 때만)
|
||||||
if (repair) initRepairMap(repair);
|
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');
|
edit.classList.remove('active');
|
||||||
view.classList.remove('hidden');
|
view.classList.remove('hidden');
|
||||||
btn.innerHTML = '✏️ 내용 수정';
|
btn.innerHTML = '✏️ 내용 수정';
|
||||||
|
clearEditCharger();
|
||||||
} else {
|
} else {
|
||||||
view.classList.add('hidden');
|
view.classList.add('hidden');
|
||||||
edit.classList.add('active');
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function saveReport(reportId) {
|
||||||
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
|
const issues = [...document.querySelectorAll('.r-issue-chk:checked')].map(c => c.value);
|
||||||
if (!issues.length) {
|
if (!issues.length) {
|
||||||
@@ -844,6 +999,14 @@ async function saveReport(reportId) {
|
|||||||
err.style.display = 'block';
|
err.style.display = 'block';
|
||||||
return;
|
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';
|
document.getElementById('rEditErr').style.display = 'none';
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('issue_types', JSON.stringify(issues));
|
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('error_code', document.getElementById('rEditErrorCode').value);
|
||||||
fd.append('contact', document.getElementById('rEditContact').value);
|
fd.append('contact', document.getElementById('rEditContact').value);
|
||||||
fd.append('occurred_at', document.getElementById('rEditOccurred').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);
|
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 || [];
|
const newPhotos = document.getElementById('rEditPhoto')?.files || [];
|
||||||
Array.from(newPhotos).forEach(f => fd.append('photos', f));
|
Array.from(newPhotos).forEach(f => fd.append('photos', f));
|
||||||
try {
|
try {
|
||||||
@@ -865,11 +1031,7 @@ async function saveReport(reportId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleParty() {
|
function toggleParty() {} // 레거시 — togglePartySelect(repairId) 사용
|
||||||
const v = document.getElementById('partyType').value;
|
|
||||||
document.getElementById('mfrWrap').style.display = v === 'manufacturer' ? 'block' : 'none';
|
|
||||||
document.getElementById('customWrap').style.display = v === 'other' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleApprovePanel() {
|
function toggleApprovePanel() {
|
||||||
const panel = document.getElementById('approvePanel');
|
const panel = document.getElementById('approvePanel');
|
||||||
@@ -985,28 +1147,31 @@ async function submitClose(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCost(repairId) {
|
async function saveCost(repairId) {
|
||||||
const partyType = document.getElementById('partyType').value;
|
const partyType = document.getElementById('partyType_' + repairId)?.value;
|
||||||
if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; }
|
if (!partyType) {
|
||||||
|
const err = document.getElementById('costErr_' + repairId);
|
||||||
|
if (err) { err.textContent = '출장비 부담 주체를 선택해 주세요.'; err.style.display = 'block'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('root_cause', document.getElementById('rootCause').value);
|
fd.append('root_cause', document.getElementById('rootCause_' + repairId)?.value || '');
|
||||||
fd.append('admin_note', document.getElementById('adminNote').value);
|
fd.append('admin_note', document.getElementById('adminNote_' + repairId)?.value || '');
|
||||||
fd.append('cost_party_type', partyType);
|
fd.append('cost_party_type', partyType);
|
||||||
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr')?.value || '');
|
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr_' + repairId)?.value || '');
|
||||||
fd.append('cost_party_custom', document.getElementById('partyCustom')?.value || '');
|
fd.append('cost_party_custom', document.getElementById('partyCustom_' + repairId)?.value || '');
|
||||||
fd.append('cost_amount', document.getElementById('costAmount').value || 0);
|
fd.append('recv_party_type', document.getElementById('recvPartyType_' + repairId)?.value || '');
|
||||||
fd.append('cost_status', document.getElementById('costStatus').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 {
|
try {
|
||||||
await API.post(`/costs/repair/${repairId}`, fd);
|
await API.post(`/costs/repair/${repairId}`, fd);
|
||||||
alert('✅ 출장비 처리가 저장되었습니다.');
|
alert('✅ 출장비 처리가 저장되었습니다.');
|
||||||
editOpen = false;
|
|
||||||
load();
|
load();
|
||||||
} catch(e) { showCostErr(e.message); }
|
} catch(e) {
|
||||||
}
|
const err = document.getElementById('costErr_' + repairId);
|
||||||
|
if (err) { err.textContent = e.message; err.style.display = 'block'; }
|
||||||
function showCostErr(msg) {
|
}
|
||||||
const el = document.getElementById('costErr');
|
|
||||||
el.textContent = msg;
|
|
||||||
el.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -40,9 +40,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</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/issue-types.html">📝 유형관리</a>
|
||||||
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
<a href="/pages/admin/qr.html">📷 QR 생성</a>
|
||||||
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
|
||||||
|
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
|
||||||
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
<a href="/pages/admin/settings.html">⚙️ 설정</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||||
<select id="fStatus" style="width:auto">
|
<select id="fStatus" style="width:auto">
|
||||||
<option value="">전체 상태</option>
|
<option value="">전체 상태</option>
|
||||||
|
<option value="pending_all">접수 대기 (전체)</option>
|
||||||
<option value="pending_approval">승인대기</option>
|
<option value="pending_approval">승인대기</option>
|
||||||
<option value="pending">접수</option>
|
<option value="pending">접수</option>
|
||||||
<option value="in_progress">처리중</option>
|
<option value="in_progress">처리중</option>
|
||||||
@@ -132,6 +135,7 @@ let mapMarkers = [];
|
|||||||
const _p = new URLSearchParams(location.search);
|
const _p = new URLSearchParams(location.search);
|
||||||
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
|
if (_p.get('status')) document.getElementById('fStatus').value = _p.get('status');
|
||||||
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
|
if (_p.get('charger_id')) document.getElementById('fCharger').value = _p.get('charger_id');
|
||||||
|
let _stationNameFilter = _p.get('station_name') || '';
|
||||||
|
|
||||||
// ── 뷰 전환 ──
|
// ── 뷰 전환 ──
|
||||||
function setView(v) {
|
function setView(v) {
|
||||||
@@ -189,8 +193,23 @@ async function load() {
|
|||||||
const c = document.getElementById('fCharger').value.trim();
|
const c = document.getElementById('fCharger').value.trim();
|
||||||
if (s) url += 'status=' + s + '&';
|
if (s) url += 'status=' + s + '&';
|
||||||
if (c) url += 'charger_id=' + c + '&';
|
if (c) url += 'charger_id=' + c + '&';
|
||||||
|
if (_stationNameFilter) url += 'station_name=' + encodeURIComponent(_stationNameFilter) + '&';
|
||||||
allRows = await API.get(url);
|
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 + '건';
|
document.getElementById('resultCount').textContent = allRows.length + '건';
|
||||||
renderTable();
|
renderTable();
|
||||||
if (curView === 'map') renderReportMap();
|
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">
|
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
|
||||||
<span style="font-weight:700">${r.seq}</span>
|
<span style="font-weight:700">${r.seq}</span>
|
||||||
</td>
|
</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.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">${r.charger_type||'-'}</td>
|
||||||
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer;max-width:200px">${(r.issue_types||[]).join(', ')}</td>
|
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer;max-width:200px">${(r.issue_types||[]).join(', ')}</td>
|
||||||
|
|||||||
@@ -25,9 +25,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">AS 관리</div>
|
<div class="sidebar-section">AS 관리</div>
|
||||||
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
|
||||||
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
<a href="/pages/admin/reports.html">📋 신고 목록</a>
|
||||||
|
|||||||
@@ -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>
|
<!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>
|
<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="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">메뉴</div>
|
<div class="sidebar-section">메뉴</div>
|
||||||
<a href="/pages/manufacturer/dashboard.html" class="active">📋 개선항목 목록</a>
|
<a href="/pages/manufacturer/dashboard.html" class="active">📋 개선항목 목록</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head>
|
<!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>
|
<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 class="main" style="max-width:760px;margin:0 auto;">
|
||||||
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
|
<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>
|
<a href="/pages/manufacturer/dashboard.html" class="btn btn-outline btn-sm">← 목록</a>
|
||||||
|
|||||||
@@ -47,16 +47,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="mech-tab-bar">
|
<div class="mech-tab-bar">
|
||||||
<a href="/pages/mechanic/dashboard.html" class="active">📋<span>AS 목록</span></a>
|
<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/scan.html">📷<span>QR 스캔</span></a>
|
||||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">메뉴</div>
|
<div class="sidebar-section">메뉴</div>
|
||||||
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
|
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
|
||||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</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}`;
|
: `/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}`;
|
||||||
return `
|
return `
|
||||||
<tr onclick="location.href='${href}'">
|
<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><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
|
||||||
<td>${r.station_name||'-'}</td>
|
<td>${r.station_name||'-'}</td>
|
||||||
<td>${r.charger_type||'-'}</td>
|
<td>${r.charger_type||'-'}</td>
|
||||||
|
|||||||
@@ -28,16 +28,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="mech-tab-bar">
|
<div class="mech-tab-bar">
|
||||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||||
<a href="/pages/mechanic/history.html" class="active">🗂<span>처리 이력</span></a>
|
<a href="/pages/mechanic/history.html" class="active">🗂<span>처리 이력</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">메뉴</div>
|
<div class="sidebar-section">메뉴</div>
|
||||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
||||||
@@ -136,6 +137,7 @@ function render() {
|
|||||||
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
|
<span class="${isApproved ? 'badge-approved' : 'badge-pending'}">
|
||||||
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
|
${isApproved ? '✅ 승인완료' : '⏳ 승인대기'}
|
||||||
</span>
|
</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>
|
<span style="font-size:11px;color:var(--gray4)">${RESULT_LABEL[r.result_status] || r.result_status}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,20 +10,29 @@
|
|||||||
.photo-preview{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
|
.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-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);}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="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>
|
<span class="nav-brand">⚡ EV AS 관리</span>
|
||||||
|
</div>
|
||||||
<div id="navUser"></div>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="mech-tab-bar">
|
<div class="mech-tab-bar">
|
||||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
||||||
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
|
||||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">메뉴</div>
|
<div class="sidebar-section">메뉴</div>
|
||||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||||
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
|
||||||
@@ -42,7 +51,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">🔧 조치 내역 입력</div>
|
<div class="card-title" id="repairCardTitle">🔧 조치 내역 입력</div>
|
||||||
|
<div id="attemptBanner"></div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>조치 유형 <span class="req">*</span></label>
|
<label>조치 유형 <span class="req">*</span></label>
|
||||||
@@ -125,6 +135,13 @@
|
|||||||
Auth.require(['mechanic','admin']);
|
Auth.require(['mechanic','admin']);
|
||||||
Auth.renderNav(document.getElementById('navUser'));
|
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 params = new URLSearchParams(location.search);
|
||||||
const repairId = params.get('repair_id'); // 편집 모드
|
const repairId = params.get('repair_id'); // 편집 모드
|
||||||
const chargerId = params.get('charger_id'); // 신규 모드
|
const chargerId = params.get('charger_id'); // 신규 모드
|
||||||
@@ -185,13 +202,24 @@ async function loadCreate() {
|
|||||||
' onchange="toggleReport(' + r.id + ',this.checked,this.closest(\'label\'))">' +
|
' onchange="toggleReport(' + r.id + ',this.checked,this.closest(\'label\'))">' +
|
||||||
'<div>' +
|
'<div>' +
|
||||||
'<div><strong>#' + r.id + '</strong> ' + Auth.statusBadge(r.status) +
|
'<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: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>' +
|
'<div style="font-size:11px;color:var(--gray4)">' + Auth.fmtDt(r.reported_at) + '</div>' +
|
||||||
photoHtml +
|
photoHtml +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</label>';
|
'</label>';
|
||||||
}).join('');
|
}).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) {
|
} catch(e) {
|
||||||
document.getElementById('chargerCard').innerHTML =
|
document.getElementById('chargerCard').innerHTML =
|
||||||
'<div class="alert alert-danger">충전기 정보를 불러오지 못했습니다.<br><small style="opacity:.8">' + e.message + '</small></div>';
|
'<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');
|
var h2el = document.querySelector('.main > div > h2') || document.querySelector('h2');
|
||||||
if (h2el) h2el.parentNode.removeChild(h2el);
|
if (h2el) h2el.parentNode.removeChild(h2el);
|
||||||
|
const attemptLabel = repair.attempt > 1 ? ` · ${repair.attempt}차 조치` : '';
|
||||||
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
|
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 = `
|
document.getElementById('chargerCard').innerHTML = `
|
||||||
|
|||||||
@@ -10,16 +10,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="mech-tab-bar">
|
<div class="mech-tab-bar">
|
||||||
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
|
<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/scan.html" class="active">📷<span>QR 스캔</span></a>
|
||||||
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">메뉴</div>
|
<div class="sidebar-section">메뉴</div>
|
||||||
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
|
||||||
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>
|
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>
|
||||||
|
|||||||
@@ -11,12 +11,13 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="nav">
|
<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>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">메뉴</div>
|
<div class="sidebar-section">메뉴</div>
|
||||||
<a href="/pages/observer/dashboard.html" class="active">📊 현황 대시보드</a>
|
<a href="/pages/observer/dashboard.html" class="active">📊 현황 대시보드</a>
|
||||||
<a href="/pages/observer/reports.html">📋 신고 목록</a>
|
<a href="/pages/observer/reports.html">📋 신고 목록</a>
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="nav">
|
<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>
|
<div id="navUser"></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-section">메뉴</div>
|
<div class="sidebar-section">메뉴</div>
|
||||||
<a href="/pages/observer/dashboard.html">📊 현황 대시보드</a>
|
<a href="/pages/observer/dashboard.html">📊 현황 대시보드</a>
|
||||||
<a href="/pages/observer/reports.html" class="active">📋 신고 목록</a>
|
<a href="/pages/observer/reports.html" class="active">📋 신고 목록</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user