Compare commits

...

16 Commits

Author SHA1 Message Date
byun
b6863cd260 1차완료 2026-06-02 19:34:36 +09:00
byun
9f0f4326fe 기능 개선 — 사진 업로드, HEIC 지원, 재조치 흐름, 신고 순번, 모바일 UI
- 이미지 압축: 삼성/네이버 브라우저 호환, URL.createObjectURL 방식으로 메모리 절감,
  대용량 PNG/HEIC 처리, blob 유효성 검증, 순차 압축으로 모바일 OOM 방지
- HEIC/HEIF 지원: pillow-heif 서버사이드 변환, Pillow 12.2.0 업그레이드
- 조치 페이지: '조치 완료 저장' 단일 버튼으로 단순화
- 재조치 흐름: 관리자 재조치 요청 시 이전 조치 이력을 번호 카드로 순차 표시
- 신고 순번: 전체 기준 ROW_NUMBER(oldest=1) 순번 표시, 삭제 gap 제거
- 모바일 탭바: position:fixed 적용으로 nav 하단 흰 여백 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 05:38:33 +09:00
byun
5ebd0a6ae7 UX 개선 — 처리이력 페이지 admin 접근 시 안내 메시지 추가
admin 계정으로 정비사 처리이력 접근 시 '직접 등록한
조치만 표시된다'는 안내와 관리자 신고 목록 링크 제공

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:56:18 +09:00
byun
012f87d75e 버그 수정 — 완료 조치 편집 시 결과상태 '완료' 미표시 문제
처리이력에서 완료된 조치를 편집할 때 resultStatus 셀렉트에
'done' 옵션이 없어 '계속 진행 중'으로 표시되던 문제 수정.
편집 모드에서 result_status가 'done'이면 셀렉트에 ' 완료' 옵션을
동적으로 추가하고 자동 선택함.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:40:19 +09:00
byun
e52e916dc8 UI 개선 — 모바일 사진 업로드 카메라/갤러리 버튼 분리, 에러코드 차트 단순화
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:25:48 +09:00
byun
d75bd5f358 UI 개선 — 모바일 사이드바 하단에 로그아웃 버튼 추가
모바일에서 nav-user가 숨겨져 로그아웃 불가한 문제 수정.
renderNav에서 사이드바 하단에 사용자명·역할 표시와 로그아웃 링크를 자동 주입.
모든 역할(관리자·정비사·제조사·옵저버) 공통 적용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:01:51 +09:00
byun
2908c06b93 UI 개선 — 신고 목록 표에 발생일시 컬럼 추가
신고일시 앞에 발생일시(occurred_at) 컬럼 추가.
미입력 시 '-' 표시. 관리자·옵저버 신고 목록·대시보드 모두 반영.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:51:38 +09:00
byun
d429ed627d 기능 개선 — 조치 시작/완료 시각 직접 입력
정비사가 조치 입력 시 시작·완료 시각을 직접 수정할 수 있도록 변경.
현장 처리 후 나중에 입력하는 경우 실제 조치 시간을 정확히 기록 가능.

- repair.html: 자동기록 안내문구 → datetime-local 입력 필드 2개로 교체
  (페이지 로드 시 현재 시각 기본 설정, 편집 모드에서 기존 값 복원)
- repairs.py: POST/PUT 엔드포인트에 started_at_input / completed_at_input
  Form 파라미터 추가, 미입력 시 datetime.now() 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:30:33 +09:00
byun
124ad0d165 기능 추가 — 옵저버 계정 및 현황 조회 포털
읽기 전용 옵저버 역할 추가. 신고 현황 확인만 가능하며 모든 쓰기 동작 차단.

- auth.py: require_viewer(admin+observer) 의존성 추가
- auth_router.py: register 엔드포인트에 role 파라미터 추가 (mechanic/observer)
- login.html: 회원가입 시 정비사/옵저버 역할 카드 선택 UI, 역할별 안내문구
- 로그인 후 observer → /pages/observer/dashboard.html 라우팅
- observer/dashboard.html: 통계 카드(상태별 건수) + 신고 현황 테이블(읽기전용)
- observer/reports.html: 상태·충전기ID·충전소명 필터 신고 목록
- accounts.html: 옵저버 필터·생성·승인 대기 역할 표시 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:25:47 +09:00
byun
81c3428aa1 기능 추가 — 재조치 요청 (조치 기록 유지 재출동)
정비사 조치 완료 후 동일 문제 재발 시 관리자가 기존 기록을 보존한 채
재조치를 요청할 수 있는 기능 추가.

- DB: repairs.re_dispatch_requested/at, reports.re_dispatch_count 컬럼 추가
- 재조치 요청 엔드포인트 (POST /repairs/{id}/re-dispatch): 기존 repair에 플래그,
  연결 신고를 pending으로 복원, re_dispatch_count 증가
- pending 상태 신고는 새 조치 생성으로 분기 (in_progress만 기존 수정 모드)
- report-detail: 조치승인·취소 사이에 "🔁 재조치 요청" 버튼, 이전 조치 이력 카드
- 정비사 대시보드: 재조치 건에 🔁 뱃지 및 강조 버튼색 표시
- 엑셀 export: 재조치횟수 컬럼 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:58:50 +09:00
byun
af7e47529c 기능 추가 — 신고 상황종료 처리
- DB: reports 테이블에 closure_type, closure_note, closed_at, closed_by 컬럼 추가
- 백엔드: PATCH /reports/{id}/close 엔드포인트 (사유 4종: natural/remote_reset/false_alarm/other)
- 신고상세: 승인대기 상태에서 [상황종료] 버튼 추가, 인라인 패널에서 사유 선택
- 상황종료 후 상세 화면에 사유·메모·처리자·일시 표시
- 엑셀 AS신고목록에 상황종료 4개 컬럼 추가
- 신고목록 필터·지도 상태 목록에 closed 추가, CSS 뱃지 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:39:50 +09:00
byun
b59569ca11 에러코드 누적 순위 차트 축 수정 — 에러코드 Y축, 충전기별 스택
- 기존: 충전기 Y축 + 에러코드별 스택 (충전기 중심)
- 변경: 에러코드 Y축 + 충전기별 스택 (에러코드 순위 중심)
- 어떤 에러코드가 가장 많이 발생했는지 + 어떤 충전기에서 발생했는지 한눈에 확인

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:28:18 +09:00
byun
585cacfa13 대시보드 — 충전기별 에러코드 누적 순위 차트 추가
- /api/stats/charger-error-codes 엔드포인트 추가
  (Top 10 충전기 × Top 6 에러코드 stacked bar, 나머지 기타로 합산)
- dashboard.html: 에러코드 누적 순위 가로 스택 바 차트 카드 추가
  (클릭 시 해당 충전기 신고 목록으로 이동)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:23:59 +09:00
byun
7ab8a5065e 계정 관리 삭제 기능 수정
- 삭제(비활성화) 후 목록에서 즉시 숨겨지도록 수정 (기본값: 활성 계정만 표시)
- "비활성 계정 포함" 체크박스 추가 — 필요 시 비활성 계정도 확인 가능
- delUser 에러 처리 추가 (try/catch + alert)
- 삭제 확인 메시지 개선

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:05:49 +09:00
byun
16f7ee651b 버그 수정 — 출장비 관리 JS 문법 오류 및 모바일 nav 수정
- costs.html: 템플릿 리터럴 내 `||}`  문법 오류 수정 (스크립트 전체 실행 불가 → 햄버거 버튼 미표시 원인)
- style.css: 모바일에서 가로 오버플로우 시 fixed nav 버튼 밀림 방지 (html,body overflow-x:hidden)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:58:03 +09:00
byun
2e8751ea6c 기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, UI 개선
## 처리시간 지표
- 업무시간 기준(09-18 평일) / 공휴일 제외 24h / 달력 기준 3가지 모드 선택
- 공휴일 DB 관리 (holidays 테이블, 수동 등록·삭제·일괄 추가)
- 2026년 공휴일 등록 지원
- 설정 페이지에서 라디오 버튼으로 모드 선택

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 06:52:56 +09:00
44 changed files with 8042 additions and 819 deletions

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ class ChargerType(Base):
description = Column(Text) description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now()) created_at = Column(TIMESTAMP, server_default=func.now())
chargers = relationship("Charger", back_populates="charger_type") chargers = relationship("Charger", back_populates="charger_type")
errors = relationship("ChargerTypeError", back_populates="charger_type",
cascade="all, delete-orphan", order_by="ChargerTypeError.display_order")
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -21,6 +23,7 @@ class User(Base):
phone = Column(String(20)) phone = Column(String(20))
email = Column(String(100)) email = Column(String(100))
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_pending = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, server_default=func.now()) created_at = Column(TIMESTAMP, server_default=func.now())
class Charger(Base): class Charger(Base):
@@ -52,10 +55,23 @@ class Report(Base):
gps_lat = Column(Float) gps_lat = Column(Float)
gps_lng = Column(Float) gps_lng = Column(Float)
status = Column(String(30), default="pending") status = Column(String(30), default="pending")
ocpp_log = Column(Text)
source = Column(String(20), default="qr") # qr | admin
reported_by = Column(Integer, ForeignKey("users.id"), nullable=True)
reported_at = Column(TIMESTAMP, server_default=func.now()) reported_at = Column(TIMESTAMP, server_default=func.now())
closure_type = Column(String(30)) # natural|remote_reset|false_alarm|other
closure_note = Column(Text)
closed_at = Column(TIMESTAMP)
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
re_dispatch_count = Column(Integer, default=0)
report_scope = Column(String(20), default="single") # single | station | type | multi
scope_charger_count = Column(Integer, default=1)
charger_ids = Column(ARRAY(Text), nullable=True) # multi 범위일 때 선택된 충전기 ID 목록
charger = relationship("Charger", back_populates="reports") 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")
reporter = relationship("User", foreign_keys=[reported_by])
closer = relationship("User", foreign_keys=[closed_by])
class ReportPhoto(Base): class ReportPhoto(Base):
__tablename__ = "report_photos" __tablename__ = "report_photos"
@@ -74,7 +90,14 @@ class Repair(Base):
started_at = Column(TIMESTAMP, nullable=False) started_at = Column(TIMESTAMP, nullable=False)
completed_at = Column(TIMESTAMP) completed_at = Column(TIMESTAMP)
result_status = Column(String(20), default="done") result_status = Column(String(20), default="done")
mechanic_lat = Column(Float)
mechanic_lng = Column(Float)
approved_at = Column(TIMESTAMP)
approved_by = Column(Integer, ForeignKey("users.id"))
re_dispatch_requested = Column(Boolean, default=False)
re_dispatch_requested_at = Column(TIMESTAMP)
mechanic = relationship("User", foreign_keys=[mechanic_id]) mechanic = relationship("User", foreign_keys=[mechanic_id])
approver = relationship("User", foreign_keys=[approved_by])
report_links = relationship("RepairReport", back_populates="repair", cascade="all, delete-orphan") report_links = relationship("RepairReport", back_populates="repair", cascade="all, delete-orphan")
photos = relationship("RepairPhoto", back_populates="repair", cascade="all, delete-orphan") photos = relationship("RepairPhoto", back_populates="repair", cascade="all, delete-orphan")
cost = relationship("RepairCost", back_populates="repair", uselist=False) cost = relationship("RepairCost", back_populates="repair", uselist=False)
@@ -101,16 +124,20 @@ class RepairCost(Base):
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), unique=True) 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"
@@ -121,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")
@@ -161,8 +188,36 @@ class ImprovementLog(Base):
improvement = relationship("Improvement", back_populates="logs") improvement = relationship("Improvement", back_populates="logs")
changer = relationship("User") changer = relationship("User")
class ChargerTypeError(Base):
__tablename__ = "charger_type_errors"
id = Column(Integer, primary_key=True)
charger_type_id = Column(Integer, ForeignKey("charger_types.id", ondelete="CASCADE"), nullable=False)
error_code = Column(String(20), nullable=False)
error_name = Column(String(100), nullable=False)
range_condition = Column(String(200))
description = Column(Text)
auto_recovery = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
charger_type = relationship("ChargerType", back_populates="errors")
class Manufacturer(Base):
__tablename__ = "manufacturers"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
representative_name = Column(String(100))
business_number = Column(String(50))
phone = Column(String(30))
address = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
class SystemSetting(Base): class SystemSetting(Base):
__tablename__ = "system_settings" __tablename__ = "system_settings"
key = Column(String(100), primary_key=True) key = Column(String(100), primary_key=True)
value = Column(Text, nullable=False) value = Column(Text, nullable=False)
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class Holiday(Base):
__tablename__ = "holidays"
holiday_date = Column(Date, primary_key=True)
name = Column(String(100), nullable=False)

View File

@@ -7,7 +7,8 @@ bcrypt==4.0.1
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
python-multipart==0.0.9 python-multipart==0.0.9
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
Pillow==10.3.0 Pillow==12.2.0
pillow-heif==1.3.0
openpyxl==3.1.2 openpyxl==3.1.2
python-dotenv==1.0.1 python-dotenv==1.0.1
pydantic[email]==2.7.1 pydantic[email]==2.7.1

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Form from fastapi import APIRouter, Depends, HTTPException, Form, Body
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import List, Optional
from database import get_db from database import get_db
import models import models
from auth import require_admin, hash_password, get_current_user from auth import require_admin, hash_password, get_current_user
@@ -15,6 +15,7 @@ def list_users(role: Optional[str] = None, db: Session = Depends(get_db), _=Depe
"id": u.id, "username": u.username, "role": u.role, "id": u.id, "username": u.username, "role": u.role,
"company": u.company, "name": u.name, "phone": u.phone, "company": u.company, "name": u.name, "phone": u.phone,
"email": u.email, "is_active": u.is_active, "email": u.email, "is_active": u.is_active,
"is_pending": getattr(u, 'is_pending', False),
"created_at": u.created_at.isoformat(), "created_at": u.created_at.isoformat(),
} for u in q.order_by(models.User.id).all()] } for u in q.order_by(models.User.id).all()]
@@ -53,6 +54,29 @@ def update_user(
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@router.patch("/{user_id}/approve")
def approve_user(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
u = db.query(models.User).filter_by(id=user_id).first()
if not u: raise HTTPException(404)
u.is_active = True
u.is_pending = False
db.commit()
return {"ok": True}
@router.delete("/bulk")
def bulk_delete_accounts(
ids: List[int] = Body(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)
):
if not ids:
raise HTTPException(400, "삭제할 항목을 선택하세요.")
safe_ids = [i for i in ids if i != current_user.id]
count = db.query(models.User).filter(models.User.id.in_(safe_ids)).update(
{"is_active": False}, synchronize_session=False)
db.commit()
return {"deactivated": count}
@router.delete("/{user_id}") @router.delete("/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db), def delete_user(user_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)): current_user: models.User = Depends(require_admin)):

View File

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

View File

@@ -1,8 +1,8 @@
import os import os
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import List, Optional
from database import get_db from database import get_db
import models import models
from auth import require_admin, get_current_user from auth import require_admin, get_current_user
@@ -41,7 +41,80 @@ def delete_type(type_id: int, db: Session = Depends(get_db), _=Depends(require_a
db.delete(t); db.commit() db.delete(t); db.commit()
return {"ok": True} return {"ok": True}
# ── 충전기 종류별 에러 코드 ──────────────────────────
@router.get("/types/{type_id}/errors")
def list_type_errors(type_id: int, db: Session = Depends(get_db)):
errors = (db.query(models.ChargerTypeError)
.filter_by(charger_type_id=type_id)
.order_by(models.ChargerTypeError.display_order)
.all())
return [{"id": e.id, "error_code": e.error_code, "error_name": e.error_name,
"range_condition": e.range_condition, "description": e.description,
"auto_recovery": e.auto_recovery, "display_order": e.display_order}
for e in errors]
@router.post("/types/{type_id}/errors")
def create_type_error(
type_id: int,
error_code: str = Form(...), error_name: str = Form(...),
range_condition: str = Form(""), description: str = Form(""),
auto_recovery: bool = Form(True), display_order: int = Form(0),
db: Session = Depends(get_db), _=Depends(require_admin)
):
if not db.query(models.ChargerType).filter_by(id=type_id).first():
raise HTTPException(404)
e = models.ChargerTypeError(
charger_type_id=type_id, error_code=error_code, error_name=error_name,
range_condition=range_condition or None, description=description or None,
auto_recovery=auto_recovery, display_order=display_order
)
db.add(e); db.commit(); db.refresh(e)
return {"id": e.id}
@router.put("/types/{type_id}/errors/{error_id}")
def update_type_error(
type_id: int, error_id: int,
error_code: str = Form(...), error_name: str = Form(...),
range_condition: str = Form(""), description: str = Form(""),
auto_recovery: bool = Form(True), display_order: int = Form(0),
db: Session = Depends(get_db), _=Depends(require_admin)
):
e = db.query(models.ChargerTypeError).filter_by(id=error_id, charger_type_id=type_id).first()
if not e: raise HTTPException(404)
e.error_code = error_code; e.error_name = error_name
e.range_condition = range_condition or None; e.description = description or None
e.auto_recovery = auto_recovery; e.display_order = display_order
db.commit()
return {"ok": True}
@router.delete("/types/{type_id}/errors/{error_id}")
def delete_type_error(
type_id: int, error_id: int,
db: Session = Depends(get_db), _=Depends(require_admin)
):
e = db.query(models.ChargerTypeError).filter_by(id=error_id, charger_type_id=type_id).first()
if not e: raise HTTPException(404)
db.delete(e); db.commit()
return {"ok": True}
# ── 충전기 ────────────────────────────────────────── # ── 충전기 ──────────────────────────────────────────
@router.delete("/bulk")
def bulk_delete_chargers(
ids: List[str] = Body(...),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
if not ids:
raise HTTPException(400, "삭제할 항목을 선택하세요.")
has_reports = db.query(models.Report.charger_id).filter(
models.Report.charger_id.in_(ids)).distinct().all()
if has_reports:
blocked = [r[0] for r in has_reports]
raise HTTPException(400, f"신고 내역이 있는 충전기는 삭제할 수 없습니다: {', '.join(blocked)}")
result = db.query(models.Charger).filter(models.Charger.id.in_(ids)).delete(synchronize_session=False)
db.commit()
return {"deleted": result}
@router.get("") @router.get("")
def list_chargers(db: Session = Depends(get_db)): def list_chargers(db: Session = Depends(get_db)):
chargers = db.query(models.Charger).order_by(models.Charger.id).all() chargers = db.query(models.Charger).order_by(models.Charger.id).all()
@@ -62,6 +135,19 @@ def list_chargers(db: Session = Depends(get_db)):
}) })
return result return result
@router.get("/{charger_id}/errors")
def get_charger_errors(charger_id: str, db: Session = Depends(get_db)):
c = db.query(models.Charger).filter_by(id=charger_id).first()
if not c: raise HTTPException(404)
if not c.charger_type_id: return []
errors = (db.query(models.ChargerTypeError)
.filter_by(charger_type_id=c.charger_type_id)
.order_by(models.ChargerTypeError.display_order)
.all())
return [{"id": e.id, "error_code": e.error_code, "error_name": e.error_name,
"range_condition": e.range_condition, "auto_recovery": e.auto_recovery}
for e in errors]
@router.get("/{charger_id}") @router.get("/{charger_id}")
def get_charger(charger_id: str, db: Session = Depends(get_db)): def get_charger(charger_id: str, db: Session = Depends(get_db)):
c = db.query(models.Charger).filter_by(id=charger_id).first() c = db.query(models.Charger).filter_by(id=charger_id).first()

View File

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

View File

@@ -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
@@ -13,19 +14,29 @@ from auth import require_admin
router = APIRouter(prefix="/api/export", tags=["export"]) 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,40 +46,352 @@ def style_row(ws, row_num, num_cols, even=True):
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd) cell.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신고목록"
@@ -77,20 +400,24 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
headers = [ headers = [
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일", "접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명", "신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
"신고자연락처","문제발생시각","신고일시","처리상태", "신고자연락처","문제발생시각","신고일시","신고출처","신고자","처리상태",
"담당정비사","정비사소속","조치유형","조치내용", "담당정비사","정비사소속","조치유형","조치내용",
"조치시작","조치완료","작업소요시간","신고→완료소요시간", "조치시작","조치완료","작업소요시간","신고→완료소요시간",
"문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태", "문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태",
"처리담당자","처리일시","연결개선항목번호" "처리담당자","처리일시","연결개선항목번호",
"상황종료사유","상황종료메모","상황종료일시","상황종료처리자",
"재조치횟수"
] ]
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,
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18,
18,24,16,14,10])
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,12, q = db.query(models.Report)
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18] if dt_from: q = q.filter(models.Report.reported_at >= dt_from)
for i, w in enumerate(col_widths, 1): if dt_to: q = q.filter(models.Report.reported_at < dt_to)
ws.column_dimensions[ws.cell(1, i).column_letter].width = w 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
@@ -99,7 +426,6 @@ 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()
] ]
row_data = [ row_data = [
r.id, r.id,
r.charger_id, r.charger_id,
@@ -110,16 +436,18 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
fmt_d(c.installed_at) if c else "", 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),
SOURCE_LABEL.get(r.source or "qr", r.source or ""),
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 "",
@@ -127,12 +455,17 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None), 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 or "", ""),
r.closure_note or "",
fmt_dt(r.closed_at),
r.closer.name if r.closer else "",
r.re_dispatch_count or 0,
] ]
for col, val in enumerate(row_data, 1): for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val) ws.cell(row=row_num, column=col, value=val)
@@ -143,10 +476,16 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 2. 출장비 목록 # 2. 출장비 목록 (개별)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@router.get("/costs") @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 = "출장비목록"
@@ -155,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),
] ]
@@ -200,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 = "개선항목목록"
@@ -211,26 +562,29 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin))
headers = [ headers = [
"번호","제목","분류","우선순위","개선내용","관련부품", "번호","제목","분류","우선순위","개선내용","관련부품",
"담당제조사","담당자","연락처","연결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,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]
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),
imp.status, IMP_STATUS_LABEL.get(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 "",
@@ -243,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}")

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, Form, Request
from sqlalchemy.orm import Session
from sqlalchemy import extract
from database import get_db
from auth import require_admin
import models
from datetime import date as date_type
router = APIRouter(prefix="/api/holidays", tags=["holidays"])
@router.get("")
def list_holidays(year: int = None, db: Session = Depends(get_db), _=Depends(require_admin)):
q = db.query(models.Holiday)
if year:
q = q.filter(extract('year', models.Holiday.holiday_date) == year)
rows = q.order_by(models.Holiday.holiday_date).all()
return [{"date": str(r.holiday_date), "name": r.name} for r in rows]
@router.post("")
def add_holiday(
holiday_date: str = Form(...),
name: str = Form(...),
db: Session = Depends(get_db),
_=Depends(require_admin),
):
try:
d = date_type.fromisoformat(holiday_date)
except ValueError:
raise HTTPException(400, "날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).")
if db.query(models.Holiday).filter_by(holiday_date=d).first():
raise HTTPException(400, "이미 등록된 날짜입니다.")
db.add(models.Holiday(holiday_date=d, name=name))
db.commit()
return {"ok": True}
@router.post("/bulk")
async def bulk_add_holidays(request: Request, db: Session = Depends(get_db), _=Depends(require_admin)):
"""JSON body: [{"date": "YYYY-MM-DD", "name": "공휴일명"}, ...]"""
items = await request.json()
if not isinstance(items, list):
raise HTTPException(400, "배열 형식이어야 합니다.")
added = 0
for item in items:
try:
d = date_type.fromisoformat(item["date"])
except (KeyError, ValueError):
continue
if not db.query(models.Holiday).filter_by(holiday_date=d).first():
db.add(models.Holiday(holiday_date=d, name=item.get("name", "공휴일")))
added += 1
db.commit()
return {"ok": True, "added": added}
@router.delete("/{holiday_date}")
def delete_holiday(
holiday_date: str,
db: Session = Depends(get_db),
_=Depends(require_admin),
):
try:
d = date_type.fromisoformat(holiday_date)
except ValueError:
raise HTTPException(400, "날짜 형식이 올바르지 않습니다.")
h = db.query(models.Holiday).filter_by(holiday_date=d).first()
if not h:
raise HTTPException(404, "등록된 공휴일이 아닙니다.")
db.delete(h); db.commit()
return {"ok": True}

View File

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

View File

@@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException, Form
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
import models
from auth import require_admin
router = APIRouter(prefix="/api/manufacturers", tags=["manufacturers"])
def _fmt(m: models.Manufacturer) -> dict:
return {
"id": m.id, "name": m.name,
"representative_name": m.representative_name,
"business_number": m.business_number,
"phone": m.phone, "address": m.address,
"is_active": m.is_active,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
@router.get("/public")
def list_public(db: Session = Depends(get_db)):
"""인증 없이 활성 제조사 목록 반환 (회원가입 화면용)"""
rows = db.query(models.Manufacturer).filter_by(is_active=True).order_by(models.Manufacturer.name).all()
return [{"id": m.id, "name": m.name} for m in rows]
@router.get("")
def list_manufacturers(db: Session = Depends(get_db), _=Depends(require_admin)):
rows = db.query(models.Manufacturer).order_by(models.Manufacturer.name).all()
return [_fmt(m) for m in rows]
@router.post("")
def create_manufacturer(
name: str = Form(...),
representative_name: str = Form(""),
business_number: str = Form(""),
phone: str = Form(""),
address: str = Form(""),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
if db.query(models.Manufacturer).filter_by(name=name).first():
raise HTTPException(400, "이미 등록된 회사명입니다.")
m = models.Manufacturer(
name=name,
representative_name=representative_name or None,
business_number=business_number or None,
phone=phone or None,
address=address or None,
)
db.add(m); db.commit(); db.refresh(m)
return _fmt(m)
@router.put("/{mfr_id}")
def update_manufacturer(
mfr_id: int,
name: str = Form(...),
representative_name: str = Form(""),
business_number: str = Form(""),
phone: str = Form(""),
address: str = Form(""),
is_active: str = Form("true"),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
m = db.query(models.Manufacturer).filter_by(id=mfr_id).first()
if not m: raise HTTPException(404)
dup = db.query(models.Manufacturer).filter(
models.Manufacturer.name == name, models.Manufacturer.id != mfr_id
).first()
if dup: raise HTTPException(400, "이미 사용 중인 회사명입니다.")
m.name = name
m.representative_name = representative_name or None
m.business_number = business_number or None
m.phone = phone or None
m.address = address or None
m.is_active = is_active == "true"
db.commit()
return _fmt(m)
@router.delete("/{mfr_id}")
def delete_manufacturer(mfr_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
m = db.query(models.Manufacturer).filter_by(id=mfr_id).first()
if not m: raise HTTPException(404)
db.delete(m); db.commit()
return {"ok": True}

View File

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

View File

@@ -1,20 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import desc from sqlalchemy import desc, text, func
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
import os, uuid
from database import get_db from database import get_db
import models import models
from auth import require_admin, get_current_user from auth import require_admin, get_current_user, get_optional_user
from utils import save_upload from utils import save_upload, UPLOAD_DIR
router = APIRouter(prefix="/api/reports", tags=["reports"]) router = APIRouter(prefix="/api/reports", tags=["reports"])
def _fmt_report(r: models.Report, db: Session): def _fmt_report(r: models.Report, db: Session):
c = r.charger c = r.charger
repair_id = None repair_id = None
mechanic_name = None
mechanic_company = None
if r.repair_links: if r.repair_links:
repair_id = r.repair_links[0].repair_id repair_id = r.repair_links[0].repair_id
rep = r.repair_links[0].repair
if rep and rep.mechanic:
mechanic_name = rep.mechanic.name
mechanic_company = rep.mechanic.company
return { return {
"id": r.id, "charger_id": r.charger_id, "id": r.id, "charger_id": r.charger_id,
"charger_name": c.name if c else None, "charger_name": c.name if c else None,
@@ -27,9 +34,25 @@ def _fmt_report(r: models.Report, db: Session):
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None, "occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
"reported_at": r.reported_at.isoformat() if r.reported_at else None, "reported_at": r.reported_at.isoformat() if r.reported_at else None,
"gps_lat": r.gps_lat, "gps_lng": r.gps_lng, "gps_lat": r.gps_lat, "gps_lng": r.gps_lng,
"charger_lat": c.gps_lat if c else None,
"charger_lng": c.gps_lng if c else None,
"location_detail": c.location_detail if c else None,
"status": r.status, "status": r.status,
"photos": [p.file_path for p in r.photos], "ocpp_log": r.ocpp_log,
"source": r.source or "qr",
"reported_by_name": r.reporter.name if r.reporter else None,
"photos": [{"id": p.id, "path": p.file_path} for p in r.photos],
"repair_id": repair_id, "repair_id": repair_id,
"mechanic_name": mechanic_name,
"mechanic_company": mechanic_company,
"closure_type": r.closure_type,
"closure_note": r.closure_note,
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
"closed_by_name": r.closer.name if r.closer else None,
"re_dispatch_count": r.re_dispatch_count or 0,
"report_scope": r.report_scope or "single",
"scope_charger_count": r.scope_charger_count or 1,
"charger_ids": r.charger_ids or [],
} }
@router.post("") @router.post("")
@@ -43,8 +66,11 @@ async def create_report(
consent: bool = Form(False), consent: bool = Form(False),
gps_lat: Optional[float] = Form(None), gps_lat: Optional[float] = Form(None),
gps_lng: Optional[float] = Form(None), gps_lng: Optional[float] = Form(None),
ocpp_log: Optional[str] = Form(None),
source: Optional[str] = Form(None),
photos: List[UploadFile] = File(default=[]), photos: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db) db: Session = Depends(get_db),
current_user: Optional[models.User] = Depends(get_optional_user)
): ):
import json import json
charger = db.query(models.Charger).filter_by(id=charger_id).first() charger = db.query(models.Charger).filter_by(id=charger_id).first()
@@ -55,13 +81,21 @@ async def create_report(
policy = setting.value if setting else "immediate" policy = setting.value if setting else "immediate"
initial_status = "pending_approval" if policy == "admin_approval" else "pending" initial_status = "pending_approval" if policy == "admin_approval" else "pending"
if current_user:
source_value = source if source in ("admin", "dashboard") else "admin"
else:
source_value = "qr"
issue_list = json.loads(issue_types) if isinstance(issue_types, str) else issue_types issue_list = json.loads(issue_types) if isinstance(issue_types, str) else issue_types
r = models.Report( r = models.Report(
charger_id=charger_id, issue_types=issue_list, charger_id=charger_id, issue_types=issue_list,
issue_detail=issue_detail or None, error_code=error_code or None, issue_detail=issue_detail or None, error_code=error_code or None,
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None, occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
contact=contact or None, consent=consent, contact=contact or None, consent=consent,
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status,
ocpp_log=ocpp_log or None,
source=source_value,
reported_by=current_user.id if current_user else None,
) )
db.add(r); db.commit(); db.refresh(r) db.add(r); db.commit(); db.refresh(r)
@@ -72,20 +106,138 @@ async def create_report(
db.commit() db.commit()
return {"id": r.id, "status": r.status} return {"id": r.id, "status": r.status}
@router.post("/batch")
async def create_batch_report(
charger_id: str = Form(...),
scope: str = Form("single"), # single | station | type | multi
charger_ids: Optional[str] = Form(None), # JSON: ["id1","id2",...] for multi scope
issue_types: str = Form(...),
issue_detail: str = Form(""),
error_code: str = Form(""),
occurred_at: Optional[str] = Form(None),
contact: str = Form(""),
consent: bool = Form(False),
gps_lat: Optional[float] = Form(None),
gps_lng: Optional[float] = Form(None),
ocpp_log: Optional[str] = Form(None),
source: Optional[str] = Form(None),
photos: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: Optional[models.User] = Depends(get_optional_user)
):
import json
charger = db.query(models.Charger).filter_by(id=charger_id).first()
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
selected_ids = None
if scope == "multi" and charger_ids:
selected_ids = json.loads(charger_ids)
all_targets = [charger]
report_scope = "multi"
scope_charger_count = len(selected_ids)
elif scope == "station":
all_targets = db.query(models.Charger).filter_by(
station_name=charger.station_name, is_active=True).all()
report_scope = "station"
scope_charger_count = len(all_targets)
elif scope == "type" and charger.charger_type_id:
all_targets = db.query(models.Charger).filter_by(
charger_type_id=charger.charger_type_id, is_active=True).all()
report_scope = "type"
scope_charger_count = len(all_targets)
else:
all_targets = [charger]
report_scope = "single"
scope_charger_count = 1
targets = [charger] # 항상 단일 신고 생성
setting = db.query(models.SystemSetting).filter_by(key="report_visibility_policy").first()
policy = setting.value if setting else "immediate"
initial_status = "pending_approval" if policy == "admin_approval" else "pending"
issue_list = json.loads(issue_types) if isinstance(issue_types, str) else issue_types
if current_user:
source_value = source if source in ("admin", "dashboard") else "admin"
else:
source_value = "qr"
photo_data = []
for photo in photos:
if photo.filename:
photo_data.append((photo.filename, photo.file.read()))
created_ids = []
for target in targets:
r = models.Report(
charger_id=target.id, issue_types=issue_list,
issue_detail=issue_detail or None, error_code=error_code or None,
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
contact=contact or None, consent=consent,
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status,
ocpp_log=ocpp_log or None,
source=source_value,
reported_by=current_user.id if current_user else None,
report_scope=report_scope,
scope_charger_count=scope_charger_count,
charger_ids=selected_ids,
)
db.add(r); db.commit(); db.refresh(r)
for fname, content in photo_data:
ext = os.path.splitext(fname)[1].lower() or ".jpg"
sub = f"reports/{r.id}"
folder = os.path.join(UPLOAD_DIR, sub)
os.makedirs(folder, exist_ok=True)
dest = os.path.join(folder, f"{uuid.uuid4().hex}{ext}")
with open(dest, "wb") as f:
f.write(content)
db.add(models.ReportPhoto(report_id=r.id, file_path=f"/uploads/{sub}/{os.path.basename(dest)}"))
db.commit()
created_ids.append(r.id)
return {"ids": created_ids, "count": len(created_ids), "primary_id": created_ids[0] if created_ids else None}
@router.get("") @router.get("")
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,
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)
): ):
q = db.query(models.Report).order_by(desc(models.Report.reported_at)) # 전체 신고 기준 순번 (삭제 gap 없이, 오래된 것=1)
if status: q = q.filter(models.Report.status == status) seq_subq = db.query(
models.Report.id.label("rid"),
func.row_number().over(
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
).label("seq")
).subquery()
q = (db.query(models.Report, seq_subq.c.seq)
.join(seq_subq, models.Report.id == seq_subq.c.rid)
.order_by(desc(models.Report.reported_at)))
if status == "pending_all":
q = q.filter(models.Report.status.in_(["pending", "pending_approval"]))
elif status:
q = q.filter(models.Report.status == status)
elif active_only:
q = q.filter(models.Report.status.in_(
["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
if charger_id: q = q.filter(models.Report.charger_id == charger_id) if 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")
return [_fmt_report(r, db) for r in q.all()]
result = []
for r, seq in q.all():
fmt = _fmt_report(r, db)
fmt["seq"] = seq
result.append(fmt)
return result
@router.get("/{report_id}") @router.get("/{report_id}")
def get_report(report_id: int, db: Session = Depends(get_db), def get_report(report_id: int, db: Session = Depends(get_db),
@@ -93,11 +245,20 @@ def get_report(report_id: int, db: Session = Depends(get_db),
r = db.query(models.Report).filter_by(id=report_id).first() r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404) if not r: raise HTTPException(404)
result = _fmt_report(r, db) result = _fmt_report(r, db)
# 수리 정보 포함 result["re_dispatch_count"] = r.re_dispatch_count or 0
if r.repair_links: # 전체 기준 순번 계산
repair = r.repair_links[0].repair seq_subq = db.query(
models.Report.id.label("rid"),
func.row_number().over(
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
).label("seq")
).subquery()
row = db.query(seq_subq.c.seq).filter(seq_subq.c.rid == report_id).scalar()
result["seq"] = row
# 수리 정보 포함 — repair_links를 id 내림차순(최신 우선)으로 정렬
def _fmt_one_repair(repair, include_cost=True):
cost = repair.cost cost = repair.cost
result["repair"] = { return {
"id": repair.id, "id": repair.id,
"mechanic_name": repair.mechanic.name if repair.mechanic else None, "mechanic_name": repair.mechanic.name if repair.mechanic else None,
"mechanic_company": repair.mechanic.company if repair.mechanic else None, "mechanic_company": repair.mechanic.company if repair.mechanic else None,
@@ -106,20 +267,146 @@ def get_report(report_id: int, db: Session = Depends(get_db),
"started_at": repair.started_at.isoformat(), "started_at": repair.started_at.isoformat(),
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None, "completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"result_status": repair.result_status, "result_status": repair.result_status,
"mechanic_lat": repair.mechanic_lat,
"mechanic_lng": repair.mechanic_lng,
"charger_lat": r.charger.gps_lat if r.charger else None,
"charger_lng": r.charger.gps_lng if r.charger else None,
"approved_at": repair.approved_at.isoformat() if repair.approved_at else None,
"approved_by_name": repair.approver.name if repair.approved_by and repair.approver else None,
"re_dispatch_requested": repair.re_dispatch_requested or False,
"re_dispatch_requested_at": repair.re_dispatch_requested_at.isoformat() if repair.re_dispatch_requested_at else None,
"photos_before": [p.file_path for p in repair.photos if p.photo_type == "before"], "photos_before": [p.file_path for p in repair.photos if p.photo_type == "before"],
"photos_after": [p.file_path for p in repair.photos if p.photo_type == "after"], "photos_after": [p.file_path for p in repair.photos if p.photo_type == "after"],
"cost": { "cost": {
"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 else None "reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
} if cost else None,
"linked_improvements": _get_linked_improvements(repair, db) if include_cost else [],
} }
if r.repair_links:
sorted_links = sorted(r.repair_links, key=lambda l: l.repair_id, reverse=True)
result["repair"] = _fmt_one_repair(sorted_links[0].repair)
result["prev_repairs"] = [
_fmt_one_repair(link.repair, include_cost=True)
for link in sorted_links[1:]
if link.repair
]
return result return result
def _get_linked_improvements(repair, db):
rids = [lk.report_id for lk in repair.report_links]
if not rids:
return []
imp_links = db.query(models.ImprovementReport).filter(
models.ImprovementReport.report_id.in_(rids)
).all()
seen, result = set(), []
for il in imp_links:
if il.improvement_id not in seen:
seen.add(il.improvement_id)
imp = db.query(models.Improvement).filter_by(id=il.improvement_id).first()
if imp:
result.append({"id": imp.id, "title": imp.title,
"category": imp.category, "status": imp.status})
return result
@router.delete("/bulk")
def bulk_delete_reports(
ids: List[int] = Body(...),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
if not ids:
raise HTTPException(400, "삭제할 항목을 선택하세요.")
db.execute(text("DELETE FROM repair_reports WHERE report_id = ANY(:ids)"), {"ids": ids})
result = db.execute(text("DELETE FROM reports WHERE id = ANY(:ids)"), {"ids": ids})
db.commit()
return {"deleted": result.rowcount}
@router.patch("/{report_id}")
async def update_report(
report_id: int,
charger_id: Optional[str] = Form(None),
scope: Optional[str] = Form(None),
issue_types: Optional[str] = Form(None),
issue_detail: Optional[str] = Form(None),
error_code: Optional[str] = Form(None),
contact: Optional[str] = Form(None),
occurred_at: Optional[str] = Form(None),
status: Optional[str] = Form(None),
ocpp_log: Optional[str] = Form(None),
photos: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
import json
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
if charger_id is not None and charger_id.strip():
ch = db.query(models.Charger).filter_by(id=charger_id.strip()).first()
if not ch: raise HTTPException(400, "충전기를 찾을 수 없습니다")
r.charger_id = charger_id.strip()
if scope is not None and scope in ("single", "station", "type"):
ref = db.query(models.Charger).filter_by(id=r.charger_id).first()
if scope == "station" and ref:
count = db.query(models.Charger).filter_by(
station_name=ref.station_name, is_active=True).count()
elif scope == "type" and ref and ref.charger_type_id:
count = db.query(models.Charger).filter_by(
charger_type_id=ref.charger_type_id, is_active=True).count()
else:
count = 1
r.report_scope = scope
r.scope_charger_count = count
if scope != "multi":
r.charger_ids = None
if issue_types is not None:
r.issue_types = json.loads(issue_types)
if issue_detail is not None:
r.issue_detail = issue_detail or None
if error_code is not None:
r.error_code = error_code or None
if contact is not None:
r.contact = contact or None
if occurred_at is not None:
r.occurred_at = datetime.fromisoformat(occurred_at) if occurred_at else None
if status is not None:
r.status = status
if ocpp_log is not None:
r.ocpp_log = ocpp_log or None
db.commit()
for photo in photos:
if photo.filename:
path = save_upload(photo, f"reports/{report_id}")
db.add(models.ReportPhoto(report_id=report_id, file_path=path))
db.commit()
return {"ok": True}
@router.delete("/{report_id}/photos/{photo_id}")
def delete_report_photo(
report_id: int, photo_id: int,
db: Session = Depends(get_db),
_=Depends(require_admin)
):
photo = db.query(models.ReportPhoto).filter_by(id=photo_id, report_id=report_id).first()
if not photo: raise HTTPException(404)
db.delete(photo)
db.commit()
return {"ok": True}
@router.patch("/{report_id}/approve") @router.patch("/{report_id}/approve")
def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(require_admin)): def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
r = db.query(models.Report).filter_by(id=report_id).first() r = db.query(models.Report).filter_by(id=report_id).first()
@@ -127,6 +414,29 @@ def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(requ
r.status = "pending"; db.commit() r.status = "pending"; db.commit()
return {"ok": True} return {"ok": True}
CLOSURE_TYPES = {"natural", "remote_reset", "false_alarm", "other"}
@router.patch("/{report_id}/close")
def close_report(
report_id: int,
closure_type: str = Form(...),
closure_note: str = Form(""),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)
):
if closure_type not in CLOSURE_TYPES:
raise HTTPException(400, "유효하지 않은 상황종료 사유입니다.")
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
from datetime import datetime
r.status = "closed"
r.closure_type = closure_type
r.closure_note = closure_note.strip() or None
r.closed_at = datetime.now()
r.closed_by = current_user.id
db.commit()
return {"ok": True}
@router.patch("/{report_id}/status") @router.patch("/{report_id}/status")
def update_status(report_id: int, status: str = Form(...), def update_status(report_id: int, status: str = Form(...),
db: Session = Depends(get_db), _=Depends(require_admin)): db: Session = Depends(get_db), _=Depends(require_admin)):

View File

@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, Form from fastapi import APIRouter, Depends, Form, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from database import get_db from database import get_db
import models import models, json
from auth import require_admin from auth import require_admin
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -47,13 +47,69 @@ def get_settings(db: Session = Depends(get_db), _=Depends(require_admin)):
result[r.key] = r.value result[r.key] = r.value
return result return result
# ── 관리자 설정 저장 (신고공개정책 + 이미지설정 통합) DEFAULT_ISSUE_TYPES = [
{"key": "충전불가", "label": "⚡ 충전 불가"},
{"key": "화면오류", "label": "🖥 화면 오류"},
{"key": "케이블불량", "label": "🔌 케이블 불량"},
{"key": "결제오류", "label": "💳 결제 오류"},
{"key": "외관손상", "label": "🔨 외관 손상"},
{"key": "에러발생", "label": "⚠️ 에러 발생"},
{"key": "기타", "label": "📋 기타"},
]
@router.get("/issue-types")
def get_issue_types(db: Session = Depends(get_db)):
row = db.query(models.SystemSetting).filter_by(key="issue_types").first()
if row:
return json.loads(row.value)
return DEFAULT_ISSUE_TYPES
@router.put("/issue-types")
async def update_issue_types(request: Request, db: Session = Depends(get_db), _=Depends(require_admin)):
items = await request.json()
if not isinstance(items, list):
from fastapi import HTTPException
raise HTTPException(400, "배열 형식이어야 합니다.")
upsert(db, "issue_types", json.dumps(items, ensure_ascii=False))
db.commit()
return {"ok": True}
DEFAULT_REPAIR_TYPES = [
{"key": "부품교체", "label": "🔩 부품 교체"},
{"key": "재시작", "label": "🔄 재시작"},
{"key": "설정변경", "label": "⚙️ 설정 변경"},
{"key": "청소", "label": "🧹 청소"},
{"key": "배선정리", "label": "🔌 배선 정리"},
{"key": "펌웨어", "label": "💾 펌웨어 업데이트"},
{"key": "기타", "label": "📋 기타"},
]
@router.get("/repair-types")
def get_repair_types(db: Session = Depends(get_db)):
row = db.query(models.SystemSetting).filter_by(key="repair_types").first()
if row:
return json.loads(row.value)
return DEFAULT_REPAIR_TYPES
@router.put("/repair-types")
async def update_repair_types(request: Request, db: Session = Depends(get_db), _=Depends(require_admin)):
items = await request.json()
if not isinstance(items, list):
from fastapi import HTTPException
raise HTTPException(400, "배열 형식이어야 합니다.")
upsert(db, "repair_types", json.dumps(items, ensure_ascii=False))
db.commit()
return {"ok": True}
# ── 관리자 설정 저장 (신고공개정책 + 이미지설정 + 처리시간기준 통합)
@router.put("") @router.put("")
def update_settings( def update_settings(
report_visibility_policy: str = Form(...), report_visibility_policy: str = Form(...),
image_compress_enabled: str = Form("true"), image_compress_enabled: str = Form("true"),
image_max_px: str = Form("1024"), image_max_px: str = Form("1024"),
image_quality: str = Form("85"), image_quality: str = Form("85"),
time_metric_base: str = Form("occurred"),
time_metric_worktime: str = Form("false"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin) _ = Depends(require_admin)
): ):
@@ -62,6 +118,8 @@ def update_settings(
("image_compress_enabled", image_compress_enabled), ("image_compress_enabled", image_compress_enabled),
("image_max_px", image_max_px), ("image_max_px", image_max_px),
("image_quality", image_quality), ("image_quality", image_quality),
("time_metric_base", time_metric_base),
("time_metric_worktime", time_metric_worktime),
] ]
for key, val in pairs: for key, val in pairs:
upsert(db, key, val) upsert(db, key, val)

View File

@@ -1,18 +1,47 @@
import os, uuid, qrcode import os, uuid, io, qrcode
from PIL import Image from PIL import Image
from fastapi import UploadFile from fastapi import UploadFile
# HEIC/HEIF 지원 등록 (pillow-heif)
try:
from pillow_heif import register_heif_opener
register_heif_opener()
_HEIF_SUPPORTED = True
except ImportError:
_HEIF_SUPPORTED = False
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/uploads") UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/uploads")
# 브라우저에서 표시 불가능한 포맷 → 서버에서 JPEG 변환
_CONVERT_EXTS = {".heic", ".heif", ".avif"}
def save_upload(file: UploadFile, sub_dir: str = "general") -> str: def save_upload(file: UploadFile, sub_dir: str = "general") -> str:
"""파일을 저장하고 /uploads 기준 상대 경로 반환""" """파일을 저장하고 /uploads 기준 상대 경로 반환.
HEIC/HEIF 등 브라우저 비호환 포맷은 JPEG로 변환 후 저장."""
raw = file.file.read()
ext = os.path.splitext(file.filename or "file")[1].lower() or ".jpg" ext = os.path.splitext(file.filename or "file")[1].lower() or ".jpg"
folder = os.path.join(UPLOAD_DIR, sub_dir) folder = os.path.join(UPLOAD_DIR, sub_dir)
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
if ext in _CONVERT_EXTS:
# HEIC/HEIF → JPEG 변환
try:
img = Image.open(io.BytesIO(raw))
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
elif img.mode != "RGB":
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=88, optimize=True)
raw = buf.getvalue()
ext = ".jpg"
except Exception:
pass # 변환 실패 시 원본 그대로 저장
filename = f"{uuid.uuid4().hex}{ext}" filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(folder, filename) filepath = os.path.join(folder, filename)
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
f.write(file.file.read()) f.write(raw)
return f"/uploads/{sub_dir}/{filename}" return f"/uploads/{sub_dir}/{filename}"
def generate_qr(charger_id: str, domain: str) -> str: def generate_qr(charger_id: str, domain: str) -> str:

View File

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

View File

@@ -16,7 +16,12 @@ const API = (() => {
} }
} }
const res = await fetch(BASE + path, { method, headers, body: fetchBody }); const res = await fetch(BASE + path, { method, headers, body: fetchBody });
if (res.status === 401) { Auth.logout(); return; } if (res.status === 401) {
if (location.pathname !== '/pages/login.html') {
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
}
Auth.logout(); return;
}
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' })); const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
throw new Error(err.detail || '오류'); throw new Error(err.detail || '오류');
@@ -36,7 +41,12 @@ const API = (() => {
const res = await fetch(BASE + path, { method: 'GET', headers }); const res = await fetch(BASE + path, { method: 'GET', headers });
if (res.status === 401) { Auth.logout(); return; } if (res.status === 401) {
if (location.pathname !== '/pages/login.html') {
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
}
Auth.logout(); return;
}
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '다운로드 오류' })); const err = await res.json().catch(() => ({ detail: '다운로드 오류' }));
throw new Error(err.detail || '다운로드 오류'); throw new Error(err.detail || '다운로드 오류');
@@ -66,7 +76,7 @@ const API = (() => {
post: (path, body) => req('POST', path, body, body instanceof FormData), post: (path, body) => req('POST', path, body, body instanceof FormData),
put: (path, body) => req('PUT', path, body, body instanceof FormData), put: (path, body) => req('PUT', path, body, body instanceof FormData),
patch: (path, body) => req('PATCH', path, body, body instanceof FormData), patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
delete: (path) => req('DELETE', path), delete: (path, body) => req('DELETE', path, body !== undefined ? body : null),
download, download,
}; };
})(); })();

View File

@@ -21,10 +21,16 @@ const Auth = (() => {
} }
function require(allowedRoles) { function require(allowedRoles) {
if (!token()) { logout(); return false; } if (!token()) {
// 로그인 후 원래 페이지로 돌아올 수 있도록 현재 URL 저장
if (location.pathname !== '/pages/login.html') {
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
}
logout(); return false;
}
if (allowedRoles && !allowedRoles.includes(role())) { if (allowedRoles && !allowedRoles.includes(role())) {
alert('접근 권한이 없습니다.'); alert('접근 권한이 없습니다. (현재 역할: ' + (role() || '없음') + ')');
history.back(); logout();
return false; return false;
} }
return true; return true;
@@ -33,18 +39,61 @@ const Auth = (() => {
function renderNav(el) { function renderNav(el) {
if (!el) return; if (!el) return;
el.innerHTML = ` el.innerHTML = `
<button class="nav-hamburger" onclick="Auth.toggleMobileNav()" aria-label="메뉴">☰</button>
<span class="nav-user"> <span class="nav-user">
<span>${name()} <small style="color:var(--accent)">[${role()}]</small></span> <span>${name()} <small style="color:var(--accent)">[${role()}]</small></span>
<a onclick="Auth.logout()">로그아웃</a> <a onclick="Auth.logout()">로그아웃</a>
</span>`; </span>`;
// 모바일 오버레이 삽입 (중복 방지)
if (!document.getElementById('mobileNavOverlay')) {
const ov = document.createElement('div');
ov.id = 'mobileNavOverlay';
ov.className = 'mobile-nav-overlay';
ov.addEventListener('click', closeMobileNav);
document.body.appendChild(ov);
}
// 사이드바 링크 클릭 시 드로어 닫기 + 하단 로그아웃 주입 (모바일용)
setTimeout(() => {
document.querySelectorAll('.sidebar a').forEach(a => {
a.addEventListener('click', closeMobileNav);
});
const sidebar = document.querySelector('.sidebar');
if (sidebar && !sidebar.querySelector('.sidebar-user-footer')) {
const footer = document.createElement('div');
footer.className = 'sidebar-user-footer';
footer.innerHTML = `
<div style="border-top:1px solid rgba(255,255,255,.12);margin:12px 0 4px;"></div>
<div style="padding:6px 20px;font-size:11px;color:rgba(255,255,255,.4);">${name()} <span style="color:var(--accent)">[${role()}]</span></div>
<a onclick="Auth.logout()" style="cursor:pointer;color:rgba(255,255,255,.6);">🚪 로그아웃</a>`;
sidebar.appendChild(footer);
}
}, 0);
}
function toggleMobileNav() {
const sidebar = document.querySelector('.sidebar');
const overlay = document.getElementById('mobileNavOverlay');
if (!sidebar) return;
const opening = !sidebar.classList.contains('mobile-open');
sidebar.classList.toggle('mobile-open', opening);
if (overlay) overlay.classList.toggle('show', opening);
}
function closeMobileNav() {
var sb = document.querySelector('.sidebar');
if (sb) sb.classList.remove('mobile-open');
var ov = document.getElementById('mobileNavOverlay');
if (ov) ov.classList.remove('show');
} }
function statusBadge(status) { function statusBadge(status) {
const map = { const map = {
pending_approval: '승인대기', pending: '접수', in_progress: '처리중', pending_approval: '승인대기', pending: '접수', in_progress: '처리중',
done: '완료', waiting: '부품대기', revisit: '재방문', done: '완료', waiting: '부품대기', revisit: '재방문', closed: '상황종료',
registered: '등록', reviewing: '검토중', developing: '개발중', registered: '등록', reviewing: '검토중', developing: '개발중',
deployed: '배포완료', deployed: '배포완료', observer: '옵저버',
}; };
return `<span class="badge s-${status}">${map[status] || status}</span>`; return `<span class="badge s-${status}">${map[status] || status}</span>`;
} }
@@ -59,5 +108,6 @@ const Auth = (() => {
return new Date(dt).toLocaleString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); return new Date(dt).toLocaleString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
} }
return { save, token, role, name, uid, logout, require, renderNav, statusBadge, costStatusBadge, fmtDt }; return { save, token, role, name, uid, logout, require, renderNav,
toggleMobileNav, closeMobileNav, statusBadge, costStatusBadge, fmtDt };
})(); })();

View File

@@ -6,16 +6,21 @@
const ImageCompressor = (() => { const ImageCompressor = (() => {
// 서버에서 가져온 설정 캐시 // 서버에서 가져온 설정 캐시
let _cfg = null; var _cfg = null;
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */ var DEFAULT_CFG = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출, 5초 타임아웃) */
async function loadConfig() { async function loadConfig() {
if (_cfg) return _cfg; if (_cfg) return _cfg;
try { try {
const res = await fetch('/api/settings/public'); var controller = new AbortController();
var tid = setTimeout(function() { controller.abort(); }, 5000);
var res = await fetch('/api/settings/public', { signal: controller.signal });
clearTimeout(tid);
_cfg = await res.json(); _cfg = await res.json();
} catch { } catch (e) {
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 }; _cfg = DEFAULT_CFG;
} }
return _cfg; return _cfg;
} }
@@ -27,62 +32,101 @@ const ImageCompressor = (() => {
* @returns {Promise<File>} * @returns {Promise<File>}
*/ */
function compressOne(file, cfg) { function compressOne(file, cfg) {
return new Promise((resolve) => { return new Promise(function(resolve) {
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환 // 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) { if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
return resolve(file); return resolve(file);
} }
const reader = new FileReader(); // createObjectURL 사용: readAsDataURL 대비 메모리 1/4 이하
reader.onload = (e) => { // (base64 변환 없이 브라우저가 파일을 직접 디코딩)
const img = new Image(); var objUrl = URL.createObjectURL(file);
img.onload = () => { var img = new Image();
const maxPx = cfg.image_max_px;
let { width, height } = img;
// 긴 변이 maxPx 초과하면 비율 유지하며 축소 img.onerror = function() {
if (width > maxPx || height > maxPx) { URL.revokeObjectURL(objUrl);
if (width >= height) { resolve(file); // 디코딩 실패(HEIF 등) → 원본 사용
height = Math.round((height / width) * maxPx); };
width = maxPx;
} else { img.onload = function() {
width = Math.round((width / height) * maxPx); URL.revokeObjectURL(objUrl); // 디코딩 완료 즉시 해제
height = maxPx;
} if (!img.naturalWidth || !img.naturalHeight) { resolve(file); return; }
var maxPx = cfg.image_max_px;
var width = img.naturalWidth;
var height = img.naturalHeight;
// 긴 변이 maxPx 초과하면 비율 유지하며 축소
if (width > maxPx || height > maxPx) {
if (width >= height) {
height = Math.round((height / width) * maxPx);
width = maxPx;
} else {
width = Math.round((width / height) * maxPx);
height = maxPx;
} }
}
const canvas = document.createElement('canvas'); var canvas = document.createElement('canvas');
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
canvas.toBlob( var ctx = canvas.getContext('2d');
(blob) => { if (!ctx) { resolve(file); return; }
const compressed = new File( img.onerror = null; // 이후 src 변경에 의한 onerror 재실행 방지
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
function(blob) {
canvas.width = 0; canvas.height = 0; // canvas 메모리 해제
if (!blob || blob.size < 500) { resolve(file); return; }
// blob이 실제로 렌더 가능한 이미지인지 검증
// (OOM으로 drawImage가 빈 캔버스를 만들었을 경우를 잡기 위해)
var blobUrl = URL.createObjectURL(blob);
var check = new Image();
check.onerror = function() {
URL.revokeObjectURL(blobUrl);
resolve(file); // 유효하지 않은 JPEG → 원본 사용
};
check.onload = function() {
URL.revokeObjectURL(blobUrl);
if (!check.naturalWidth || !check.naturalHeight) {
resolve(file); // 빈 이미지 → 원본 사용
return;
}
resolve(new File(
[blob], [blob],
file.name.replace(/\.[^.]+$/, '') + '.jpg', file.name.replace(/\.[^.]+$/, '') + '.jpg',
{ type: 'image/jpeg', lastModified: Date.now() } { type: 'image/jpeg', lastModified: Date.now() }
); ));
resolve(compressed); };
}, check.src = blobUrl;
'image/jpeg', },
cfg.image_quality / 100 // 0~1 범위 'image/jpeg',
); cfg.image_quality / 100 // 0~1 범위
}; );
img.src = e.target.result;
}; };
reader.readAsDataURL(file);
img.src = objUrl;
}); });
} }
/** /**
* FileList / File[] 전체를 압축 * FileList / File[] 전체를 순차 압축 (병렬 처리 시 모바일 메모리 부족 방지)
* @param {FileList|File[]} files * @param {FileList|File[]} files
* @returns {Promise<File[]>} * @returns {Promise<File[]>}
*/ */
async function compressAll(files) { async function compressAll(files) {
const cfg = await loadConfig(); var cfg = await loadConfig();
return Promise.all(Array.from(files).map(f => compressOne(f, cfg))); var arr = Array.from(files);
var result = [];
for (var i = 0; i < arr.length; i++) {
result.push(await compressOne(arr[i], cfg));
}
return result;
} }
/** /**
@@ -92,52 +136,59 @@ const ImageCompressor = (() => {
* @param {string} infoId - 용량 정보 표시 span id (선택) * @param {string} infoId - 용량 정보 표시 span id (선택)
*/ */
function setupPreview(inputId, previewId, infoId) { function setupPreview(inputId, previewId, infoId) {
const input = document.getElementById(inputId); var input = document.getElementById(inputId);
const preview = document.getElementById(previewId); var preview = document.getElementById(previewId);
const info = infoId ? document.getElementById(infoId) : null; var info = infoId ? document.getElementById(infoId) : null;
if (!input || !preview) return; if (!input || !preview) return;
input.addEventListener('change', async function () { input.addEventListener('change', async function() {
preview.innerHTML = ''; preview.innerHTML = '';
if (info) info.textContent = '압축 중...'; if (info) info.textContent = '압축 중...';
const cfg = await loadConfig(); var cfg = await loadConfig();
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0); var origBytes = Array.from(this.files).reduce(function(s, f) { return s + f.size; }, 0);
const compressed = await compressAll(this.files); var compressed = await compressAll(this.files);
const compBytes = compressed.reduce((s, f) => s + f.size, 0); var compBytes = compressed.reduce(function(s, f) { return s + f.size; }, 0);
// DataTransfer 로 input.files 교체 (압축된 파일로) // DataTransfer 로 input.files 교체 (압축된 파일로)
const dt = new DataTransfer(); var dt = new DataTransfer();
compressed.forEach(f => dt.items.add(f)); compressed.forEach(function(f) { dt.items.add(f); });
this.files = dt.files; this.files = dt.files;
// 미리보기 렌더링 // 미리보기 렌더링
compressed.forEach((f, i) => { compressed.forEach(function(f, i) {
const url = URL.createObjectURL(f); var url = URL.createObjectURL(f);
const wrap = document.createElement('div'); var wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;display:inline-block;'; wrap.style.cssText = 'position:relative;display:inline-block;';
const img = document.createElement('img'); var img = document.createElement('img');
img.src = url; img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;background:#f0f0f0;';
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;'; img.onload = function() { URL.revokeObjectURL(url); };
img.onload = () => URL.revokeObjectURL(url); img.onerror = function() {
// 이미지 렌더 실패 시 플레이스홀더 표시
URL.revokeObjectURL(url);
img.style.cssText = 'width:80px;height:80px;border-radius:6px;border:1px solid #C5CFE0;background:#f5f5f5;display:flex;align-items:center;justify-content:center;font-size:10px;color:#999;';
img.removeAttribute('src');
img.alt = '미리보기\n불가';
};
img.src = url;
// 삭제 버튼 // 삭제 버튼 — 클로저로 이 파일(f)을 직접 참조
const del = document.createElement('button'); var del = document.createElement('button');
del.textContent = '×'; del.textContent = '×';
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;'; del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;';
del.onclick = () => { (function(targetFile) {
// 해당 파일 제거 del.onclick = function() {
const cur = Array.from(input.files); var remaining = Array.from(input.files).filter(function(f2) { return f2.name !== targetFile.name || f2.size !== targetFile.size; });
cur.splice(i, 1); var dt2 = new DataTransfer();
const dt2 = new DataTransfer(); remaining.forEach(function(f2) { dt2.items.add(f2); });
cur.forEach(f2 => dt2.items.add(f2)); input.files = dt2.files;
input.files = dt2.files; wrap.remove();
wrap.remove(); updateInfo(input, info);
updateInfo(input, info); };
}; })(f);
wrap.appendChild(img); wrap.appendChild(img);
wrap.appendChild(del); wrap.appendChild(del);
@@ -146,12 +197,12 @@ const ImageCompressor = (() => {
// 용량 정보 // 용량 정보
if (info) { if (info) {
const pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0; var pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
if (cfg.image_compress_enabled && pct > 0) { if (cfg.image_compress_enabled && pct > 0) {
info.textContent = `${compressed.length}장 | ${fmt(origBytes)}${fmt(compBytes)} (${pct}% 절약) | 최대 ${cfg.image_max_px}px / 품질 ${cfg.image_quality}%`; info.textContent = compressed.length + '장 | ' + fmt(origBytes) + ' → ' + fmt(compBytes) + ' (' + pct + '% 절약) | 최대 ' + cfg.image_max_px + 'px / 품질 ' + cfg.image_quality + '%';
info.style.color = '#00875A'; info.style.color = '#00875A';
} else { } else {
info.textContent = `${compressed.length}장 | ${fmt(compBytes)} (압축 비활성)`; info.textContent = compressed.length + '장 | ' + fmt(compBytes) + ' (압축 비활성)';
info.style.color = '#8899BB'; info.style.color = '#8899BB';
} }
} }
@@ -160,15 +211,36 @@ const ImageCompressor = (() => {
function updateInfo(input, info) { function updateInfo(input, info) {
if (!info) return; if (!info) return;
const bytes = Array.from(input.files).reduce((s, f) => s + f.size, 0); var bytes = Array.from(input.files).reduce(function(s, f) { return s + f.size; }, 0);
info.textContent = `${input.files.length}장 | ${fmt(bytes)}`; info.textContent = input.files.length + '장 | ' + fmt(bytes);
} }
function fmt(bytes) { function fmt(bytes) {
return bytes < 1024 * 1024 return bytes < 1024 * 1024
? (bytes / 1024).toFixed(0) + ' KB' ? Math.round(bytes / 1024) + ' KB'
: (bytes / (1024 * 1024)).toFixed(1) + ' MB'; : (bytes / (1024 * 1024)).toFixed(1) + ' MB';
} }
return { compressAll, setupPreview, loadConfig }; /**
* 카메라 input(capture)이 찍은 사진을 갤러리 input에 병합 후 change 이벤트 발생
* → setupPreview는 갤러리 input 하나만 바라보면 됨
* @param {string} cameraId - capture="environment" input id
* @param {string} galleryId - 기존 multiple input id (setupPreview 대상)
*/
function setupCameraAppend(cameraId, galleryId) {
var cam = document.getElementById(cameraId);
var main = document.getElementById(galleryId);
if (!cam || !main) return;
cam.addEventListener('change', function() {
if (!this.files.length) return;
var dt = new DataTransfer();
Array.from(main.files).forEach(function(f) { dt.items.add(f); }); // 기존 파일 유지
Array.from(this.files).forEach(function(f) { dt.items.add(f); }); // 새 사진 추가
main.files = dt.files;
main.dispatchEvent(new Event('change')); // setupPreview 재실행
this.value = '';
});
}
return { compressAll, setupPreview, loadConfig, setupCameraAppend };
})(); })();

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,18 @@
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>출장비 관리</title> <title>출장비 관리</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<style>
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
tr.selected td { background:var(--gray2) !important; }
#btnDelete { display:none; }
</style>
</head> </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>
@@ -17,14 +24,19 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a> <a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a> <a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.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/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;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">출장비 관리</h2> <h2 style="font-size:18px;font-weight:700;color:var(--navy)">출장비 관리</h2>
<button class="btn btn-success btn-sm" onclick="API.download('/export/costs','출장비목록.xlsx')">📥 엑셀 다운로드</button> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
<button class="btn btn-success btn-sm" onclick="API.download('/export/costs','출장비목록.xlsx')">📥 엑셀 다운로드</button>
</div>
</div> </div>
<div class="stats" id="stats"></div> <div class="stats" id="stats"></div>
<div class="card"> <div class="card">
@@ -39,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>
@@ -48,7 +60,10 @@
</div> </div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr><th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th></tr></thead> <thead><tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>수급주체</th><th>금액</th><th>상태</th><th>처리일시</th>
</tr></thead>
<tbody id="tbody"></tbody> <tbody id="tbody"></tbody>
</table> </table>
</div> </div>
@@ -59,7 +74,29 @@
<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'));
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
function updateDeleteBtn() {
const checked = document.querySelectorAll('.row-chk:checked');
document.getElementById('selCount').textContent = checked.length;
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
}
function toggleAll(chkAll) {
document.querySelectorAll('.row-chk').forEach(c => {
c.checked = chkAll.checked;
c.closest('tr').classList.toggle('selected', chkAll.checked);
});
updateDeleteBtn();
}
async function bulkDelete() {
const checked = [...document.querySelectorAll('.row-chk:checked')];
if (!checked.length) return;
if (!confirm(`선택한 출장비 내역 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
const ids = checked.map(c => parseInt(c.dataset.id));
try { await API.delete('/costs/bulk', ids); load(); }
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const PARTY_LABEL = {cpo:'CPO',manufacturer:'업체',self:'자체',user:'사용자과실',other:'기타'};
async function load() { 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)]);
@@ -67,17 +104,24 @@ async function load() {
<div class="stat"><div class="stat-num">${statsData.monthly_total.toLocaleString()}</div><div class="stat-label">이달 출장비 합계(원)</div></div> <div class="stat"><div class="stat-num">${statsData.monthly_total.toLocaleString()}</div><div class="stat-label">이달 출장비 합계(원)</div></div>
<div class="stat danger"><div class="stat-num">${statsData.pending_count}</div><div class="stat-label">미처리 건수</div></div>`; <div class="stat danger"><div class="stat-num">${statsData.pending_count}</div><div class="stat-label">미처리 건수</div></div>`;
const tbody = document.getElementById('tbody'); const tbody = document.getElementById('tbody');
document.getElementById('chkAll').checked = false;
updateDeleteBtn();
document.getElementById('empty').style.display = costs.length ? 'none' : 'block'; document.getElementById('empty').style.display = costs.length ? 'none' : 'block';
tbody.innerHTML = costs.map(c => ` tbody.innerHTML = costs.map(c => `
<tr onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'"> <tr>
<td>${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td> <td class="cb-cell" onclick="event.stopPropagation()">
<td>${c.charger_id||'-'}</td> <input type="checkbox" class="row-chk" data-id="${c.id}"
<td>${c.station_name||'-'}</td> onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
<td>${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td> </td>
<td>${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td> <td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
<td style="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">${c.charger_id||'-'}</td>
<td>${Auth.costStatusBadge(c.cost_status)}</td> <td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${c.station_name||'-'}</td>
<td>${Auth.fmtDt(c.reviewed_at)}</td> <td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'&&c.cost_manufacturer_name?`<br><small>${c.cost_manufacturer_name}</small>`:''}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${c.recv_party_type?(PARTY_LABEL[c.recv_party_type]||c.recv_party_type):'-'}${c.recv_party_type==='manufacturer'&&c.recv_manufacturer_name?`<br><small>${c.recv_manufacturer_name}</small>`:''}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer;font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${Auth.costStatusBadge(c.cost_status)}</td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'" style="cursor:pointer">${Auth.fmtDt(c.reviewed_at)}</td>
</tr>`).join(''); </tr>`).join('');
} }
load(); load();

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,102 +1,184 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head> <!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css">
<style>
.imp-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
.info-dl { display:grid; grid-template-columns:80px 1fr; gap:8px 14px; font-size:13px; align-items:start; }
.info-dl dt { color:var(--gray4); font-weight:600; padding-top:1px; }
.info-dl dd { word-break:break-word; }
.report-link { display:flex; align-items:center; gap:10px; padding:10px 12px;
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
cursor:pointer; font-size:13px; color:var(--navy); text-decoration:none; transition:background .15s; }
.report-link:hover { background:var(--gray1); }
.report-link-num { font-weight:700; color:var(--blue); flex-shrink:0; }
.file-link { display:flex; align-items:center; gap:8px; padding:9px 12px;
border:1px solid var(--gray2); border-radius:8px; margin-bottom:6px;
color:var(--navy); text-decoration:none; font-size:13px; transition:background .15s; overflow:hidden; }
.file-link:hover { background:var(--gray1); }
.file-link span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.page-header { display:flex; align-items:center; gap:10px; margin-bottom:18px; }
.page-header h2 { font-size:17px; font-weight:700; color:var(--navy); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.status-form { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
@media(max-width:768px) {
.imp-grid { grid-template-columns:1fr; }
.status-form { grid-template-columns:1fr; }
.info-dl { grid-template-columns:72px 1fr; gap:7px 10px; }
}
</style>
</head>
<body> <body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav> <nav class="nav">
<div class="main" style="max-width:860px;margin:0 auto;"> <div style="display:flex;align-items:center;gap:2px;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;"> <button class="nav-hamburger" onclick="toggleSidebar()"></button>
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm">← 목록</a> <span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">개선항목 상세</h2> </div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/export.html">📥 데이터 내보내기</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div class="page-header">
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm" style="flex-shrink:0">← 목록</a>
<h2 id="pageTitle">개선항목 상세</h2>
</div>
<div id="content"></div>
</div> </div>
<div id="content"></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>
<div class="card">
<div class="card-title">📎 연결된 AS 신고</div>
${imp.report_ids.length ? imp.report_ids.map(rid=>`
<div onclick="location.href='/pages/admin/report-detail.html?id=${rid}'"
style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;cursor:pointer;font-size:13px">
신고 #${rid}
</div>`).join('') : '<div class="alert alert-info">연결된 신고 없음</div>'}
<div class="card-title" style="margin-top:16px">📁 첨부 파일</div> <!-- 연결 AS + 첨부 -->
${imp.attachments.length ? imp.attachments.map(a=>` <div>
<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-bottom:6px;display:block"> <div class="card">
📄 ${a.name||a.path.split('/').pop()} <div class="card-title">📎 연결된 AS 신고</div>
</a>`).join('') : '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'} ${(imp.report_links||[]).length
? (imp.report_links||[]).map(r => `
<a class="report-link" href="/pages/admin/report-detail.html?id=${r.id}">
<span class="report-link-num">#${r.seq}</span>
<span style="color:var(--gray4);font-size:12px">신고 상세 보기 →</span>
</a>`).join('')
: '<div class="alert alert-info" style="margin:0">연결된 신고 없음</div>'}
</div>
<div class="card">
<div class="card-title">📁 첨부 파일</div>
${imp.attachments.length
? imp.attachments.map(a => `
<a class="file-link" href="${a.path}" target="_blank">
<span style="flex-shrink:0">📄</span>
<span>${a.name||a.path.split('/').pop()}</span>
</a>`).join('')
: '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
</div>
</div> </div>
</div> </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="tl-item"> ? `<div class="timeline">${imp.logs.map(l=>`
<div class="tl-time">${Auth.fmtDt(l.changed_at)}${l.by||'시스템'}</div> <div class="tl-item">
<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-time">${Auth.fmtDt(l.changed_at)} ${l.by||'시스템'}</div>
</div>`).join('')}</div>` : '<div style="color:var(--gray4);font-size:13px">이력 없음</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> </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></body></html> </script>
</body></html>

View File

@@ -1,8 +1,15 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 관리</title><link rel="stylesheet" href="/css/style.css"></head> <!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 관리</title><link rel="stylesheet" href="/css/style.css">
<style>
.cb-cell { width:36px; text-align:center; padding:8px 4px; }
input[type=checkbox] { width:16px; height:16px; cursor:pointer; }
tr.selected td { background:var(--gray2) !important; }
#btnDelete { display:none; }
</style></head>
<body> <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>
@@ -11,14 +18,17 @@
<a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a> <a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a> <a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.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/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;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">개선항목 관리</h2> <h2 style="font-size:18px;font-weight:700;color:var(--navy)">개선항목 관리</h2>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button id="btnDelete" class="btn btn-sm" style="background:#e53e3e;color:#fff;border:none;" onclick="bulkDelete()">🗑 선택 삭제 (<span id="selCount">0</span>개)</button>
<button class="btn btn-success btn-sm" onclick="API.download('/export/improvements','개선항목목록.xlsx')">📥 엑셀</button> <button class="btn btn-success btn-sm" onclick="API.download('/export/improvements','개선항목목록.xlsx')">📥 엑셀</button>
<button class="btn btn-primary" onclick="openModal()">+ 개선항목 등록</button> <button class="btn btn-primary" onclick="openModal()">+ 개선항목 등록</button>
</div> </div>
@@ -35,7 +45,10 @@
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button> <button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</div> </div>
<div class="tbl-wrap"><table> <div class="tbl-wrap"><table>
<thead><tr><th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th></tr></thead> <thead><tr>
<th class="cb-cell"><input type="checkbox" id="chkAll" onchange="toggleAll(this)"></th>
<th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th>
</tr></thead>
<tbody id="tbody"></tbody> <tbody id="tbody"></tbody>
</table></div> </table></div>
<div id="empty" class="alert alert-info" style="display:none">등록된 개선항목이 없습니다.</div> <div id="empty" class="alert alert-info" style="display:none">등록된 개선항목이 없습니다.</div>
@@ -51,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">
@@ -90,41 +103,74 @@
<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'));
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
function updateDeleteBtn() {
const checked = document.querySelectorAll('.row-chk:checked');
document.getElementById('selCount').textContent = checked.length;
document.getElementById('btnDelete').style.display = checked.length > 0 ? 'inline-flex' : 'none';
}
function toggleAll(chkAll) {
document.querySelectorAll('.row-chk').forEach(c => {
c.checked = chkAll.checked;
c.closest('tr').classList.toggle('selected', chkAll.checked);
});
updateDeleteBtn();
}
async function bulkDelete() {
const checked = [...document.querySelectorAll('.row-chk:checked')];
if (!checked.length) return;
if (!confirm(`선택한 개선항목 ${checked.length}건을 삭제합니다. 되돌릴 수 없습니다. 계속하시겠습니까?`)) return;
const ids = checked.map(c => parseInt(c.dataset.id));
try { await API.delete('/improvements/bulk', ids); load(); }
catch(e) { alert('삭제 중 오류가 발생했습니다: ' + e.message); }
}
const CAT = {hardware:'하드웨어',software:'소프트웨어',firmware:'펌웨어',installation:'설치환경',ui:'UI',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'}; const 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;
updateDeleteBtn();
document.getElementById('empty').style.display = imps.length ? 'none' : 'block'; document.getElementById('empty').style.display = imps.length ? 'none' : 'block';
document.getElementById('tbody').innerHTML = imps.map(i => ` document.getElementById('tbody').innerHTML = imps.map(i => `
<tr onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'"> <tr>
<td>#${i.id}</td> <td class="cb-cell" onclick="event.stopPropagation()">
<td style="max-width:200px"><strong>${i.title}</strong></td> <input type="checkbox" class="row-chk" data-id="${i.id}"
<td>${CAT[i.category]||i.category}</td> onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
<td>${PRI[i.priority]||i.priority}</td> </td>
<td>${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.id}</td>
<td><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;max-width:200px"><strong>${i.title}</strong></td>
<td>${Auth.statusBadge(i.status)}</td> <td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${CAT[i.category]||i.category}</td>
<td>${Auth.fmtDt(i.created_at)}</td> <td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${PRI[i.priority]||i.priority}</td>
<td>${i.sw_deployed_at||'-'}</td> <td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.manufacturer_name||'-'}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer"><span class="badge s-pending">${i.report_count}건</span></td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.statusBadge(i.status)}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${Auth.fmtDt(i.created_at)}</td>
<td onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'" style="cursor:pointer">${i.sw_deployed_at||'-'}</td>
</tr>`).join(''); </tr>`).join('');
} }
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('');
} }
@@ -132,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>';
} }

View File

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

View File

@@ -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>
@@ -11,8 +12,10 @@
<a href="/pages/admin/improvements.html">🔧 개선항목</a> <a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a> <a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a> <a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/issue-types.html">📝 유형관리</a>
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a> <a href="/pages/admin/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">

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -5,13 +5,36 @@
<title>로그인 — EV AS 관리</title> <title>로그인 — EV AS 관리</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<style> <style>
body{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--navy);} body { display:flex; align-items:center; justify-content:center; min-height:100vh; background:var(--navy); }
.login-box{background:white;border-radius:14px;padding:40px 36px;width:100%;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,.3);} .login-box {
.login-logo{text-align:center;margin-bottom:28px;} background:white; border-radius:14px; padding:40px 36px;
.login-logo h1{font-size:22px;font-weight:900;color:var(--navy);} width:100%; max-width:380px; box-shadow:0 8px 32px rgba(0,0,0,.3);
.login-logo p{font-size:12px;color:var(--gray4);margin-top:4px;} }
.login-box .form-group{margin-bottom:14px;} .login-logo { text-align:center; margin-bottom:28px; }
#err{color:var(--red);font-size:13px;text-align:center;min-height:18px;margin-bottom:8px;} .login-logo h1 { font-size:22px; font-weight:900; color:var(--navy); }
.login-logo p { font-size:12px; color:var(--gray4); margin-top:4px; }
.login-box .form-group { margin-bottom:14px; }
#err, #regErr { font-size:13px; text-align:center; min-height:18px; margin-bottom:8px; }
#err { color:var(--red); }
#regErr { color:var(--red); }
.tab-row {
display:flex; gap:0; border-bottom:2px solid var(--gray2); margin-bottom:24px;
}
.tab-row button {
flex:1; background:none; border:none; padding:9px 0; font-size:14px; font-weight:600;
color:var(--gray4); border-bottom:3px solid transparent; margin-bottom:-2px; cursor:pointer;
transition:color .15s, border-color .15s;
}
.tab-row button.active { color:var(--navy); border-bottom-color:var(--accent); }
.pane { display:none; }
.pane.active { display:block; }
.reg-notice {
background:#EFF6FF; border:1px solid #BFDBFE; border-radius:8px;
padding:10px 14px; font-size:12px; color:#1E40AF; margin-bottom:16px; line-height:1.6;
}
</style> </style>
</head> </head>
<body> <body>
@@ -20,20 +43,97 @@ body{display:flex;align-items:center;justify-content:center;min-height:100vh;bac
<h1>⚡ EV AS 관리</h1> <h1>⚡ EV AS 관리</h1>
<p>cs.byunc.com</p> <p>cs.byunc.com</p>
</div> </div>
<div class="form-group">
<label>아이디</label> <div class="tab-row">
<input type="text" id="username" placeholder="아이디 입력" autofocus> <button id="tabLogin" class="active" onclick="switchTab('login')">로그인</button>
<button id="tabRegister" onclick="switchTab('register')">회원가입</button>
</div> </div>
<div class="form-group">
<label>비밀번호</label> <!-- 로그인 -->
<input type="password" id="password" placeholder="비밀번호 입력"> <div class="pane active" id="paneLogin">
<div class="form-group">
<label>아이디</label>
<input type="text" id="username" placeholder="아이디 입력" autofocus>
</div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="password" placeholder="비밀번호 입력">
</div>
<div id="err"></div>
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button>
</div> </div>
<div id="err"></div>
<button class="btn btn-primary btn-lg" id="loginBtn">로그인</button> <!-- 회원가입 -->
<div class="pane" id="paneRegister">
<div class="form-group" style="margin-bottom:12px">
<label>계정 유형 <span style="color:var(--red)">*</span></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:4px">
<label id="roleCardMechanic" onclick="selectRole('mechanic')" style="border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;">
<div style="font-size:18px;margin-bottom:2px">🔧</div>
<div style="font-size:13px;font-weight:700;color:var(--navy)">정비사</div>
<div style="font-size:11px;color:var(--gray4)">조치 입력·처리</div>
</label>
<label id="roleCardObserver" onclick="selectRole('observer')" style="border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;">
<div style="font-size:18px;margin-bottom:2px">👁</div>
<div style="font-size:13px;font-weight:700;color:var(--navy)">옵저버</div>
<div style="font-size:11px;color:var(--gray4)">현황 조회만 가능</div>
</label>
</div>
<input type="hidden" id="regRole" value="mechanic">
</div>
<div id="regNotice" class="reg-notice">
📌 정비사 계정으로 가입됩니다.<br>
가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.
</div>
<div class="form-group">
<label>이름 <span style="color:var(--red)">*</span></label>
<input type="text" id="regName" placeholder="실명 입력">
</div>
<div class="form-group">
<label>아이디 <span style="color:var(--red)">*</span></label>
<input type="text" id="regUsername" placeholder="영문·숫자 조합">
</div>
<div class="form-group">
<label>비밀번호 <span style="color:var(--red)">*</span></label>
<input type="password" id="regPassword" placeholder="8자 이상 권장">
</div>
<div class="form-group">
<label>비밀번호 확인 <span style="color:var(--red)">*</span></label>
<input type="password" id="regPassword2" placeholder="비밀번호 재입력">
</div>
<div class="form-group">
<label>전화번호 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
<input type="tel" id="regPhone" placeholder="예) 010-1234-5678">
</div>
<div class="form-group">
<label>회사명 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택)</span></label>
<select id="regCompany">
<option value="">-- 소속 제조사 없음 --</option>
</select>
</div>
<div id="regErr"></div>
<button class="btn btn-primary btn-lg" id="regBtn">가입 신청</button>
<div id="regOk" class="alert alert-success" style="display:none;margin-top:14px;text-align:center">
✅ 가입 신청이 완료되었습니다.<br>
<span style="font-size:12px">관리자 승인 후 로그인 가능합니다.</span>
</div>
</div>
</div> </div>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script> <script>
function switchTab(name) {
document.getElementById('tabLogin').classList.toggle('active', name==='login');
document.getElementById('tabRegister').classList.toggle('active', name==='register');
document.getElementById('paneLogin').classList.toggle('active', name==='login');
document.getElementById('paneRegister').classList.toggle('active', name==='register');
document.getElementById('err').textContent = '';
document.getElementById('regErr').textContent = '';
}
// ── 로그인 ──
async function doLogin() { async function doLogin() {
const u = document.getElementById('username').value.trim(); const u = document.getElementById('username').value.trim();
const p = document.getElementById('password').value; const p = document.getElementById('password').value;
@@ -47,16 +147,92 @@ async function doLogin() {
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json(); const data = await res.json();
Auth.save(data.access_token, data.role, data.name, data.user_id); Auth.save(data.access_token, data.role, data.name, data.user_id);
if (data.role === 'admin') location.href = '/pages/admin/dashboard.html'; const redirect = sessionStorage.getItem('ev_redirect');
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html'; sessionStorage.removeItem('ev_redirect');
else location.href = '/pages/manufacturer/dashboard.html'; if (redirect && redirect !== '/pages/login.html') {
location.href = redirect;
} else if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else if (data.role === 'observer') location.href = '/pages/observer/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html';
} catch(e) { } catch(e) {
document.getElementById('err').textContent = e.message; document.getElementById('err').textContent = e.message;
document.getElementById('loginBtn').disabled = false; document.getElementById('loginBtn').disabled = false;
} }
} }
// ── 계정 유형 선택 ──
function selectRole(role) {
document.getElementById('regRole').value = role;
const mc = document.getElementById('roleCardMechanic');
const oc = document.getElementById('roleCardObserver');
if (role === 'mechanic') {
mc.style.cssText = 'border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;';
oc.style.cssText = 'border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;';
document.getElementById('regNotice').innerHTML = '📌 정비사 계정으로 가입됩니다.<br>가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.';
} else {
oc.style.cssText = 'border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;';
mc.style.cssText = 'border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;';
document.getElementById('regNotice').innerHTML = '👁 현황 조회 전용 계정입니다.<br>신고 등록·조치 등 쓰기 기능은 사용할 수 없습니다.<br>가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.';
}
}
// ── 제조사 목록 로드 (비인증) ──
async function loadCompanies() {
try {
const list = await fetch('/api/manufacturers/public').then(r => r.json());
const sel = document.getElementById('regCompany');
list.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name; opt.textContent = m.name;
sel.appendChild(opt);
});
} catch {}
}
loadCompanies();
// ── 회원가입 ──
async function doRegister() {
const name = document.getElementById('regName').value.trim();
const uname = document.getElementById('regUsername').value.trim();
const pw = document.getElementById('regPassword').value;
const pw2 = document.getElementById('regPassword2').value;
const phone = document.getElementById('regPhone').value.trim();
const company = document.getElementById('regCompany').value;
const errEl = document.getElementById('regErr');
errEl.textContent = '';
if (!name) { errEl.textContent = '이름을 입력하세요.'; return; }
if (!uname) { errEl.textContent = '아이디를 입력하세요.'; return; }
if (!pw) { errEl.textContent = '비밀번호를 입력하세요.'; return; }
if (pw !== pw2) { errEl.textContent = '비밀번호가 일치하지 않습니다.'; return; }
document.getElementById('regBtn').disabled = true;
try {
const fd = new FormData();
fd.append('username', uname);
fd.append('password', pw);
fd.append('name', name);
fd.append('phone', phone);
fd.append('company', company);
fd.append('role', document.getElementById('regRole').value);
const res = await fetch('/api/auth/register', { method:'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
document.getElementById('regOk').style.display = 'block';
document.getElementById('regBtn').style.display = 'none';
['regName','regUsername','regPassword','regPassword2','regPhone'].forEach(id =>
document.getElementById(id).value = '');
document.getElementById('regCompany').value = '';
} catch(e) {
errEl.textContent = e.message;
document.getElementById('regBtn').disabled = false;
}
}
document.getElementById('loginBtn').addEventListener('click', doLogin); document.getElementById('loginBtn').addEventListener('click', doLogin);
document.getElementById('password').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); }); document.getElementById('password').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
document.getElementById('regBtn').addEventListener('click', doRegister);
document.getElementById('regPassword2').addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,9 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>제조사 대시보드</title><link rel="stylesheet" href="/css/style.css"></head> <!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>

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head> <!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>

View File

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

View File

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

View File

@@ -10,14 +10,36 @@
.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">
<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="main" style="max-width:640px;margin:0 auto;"> <div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="mech-tab-bar">
<a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
<a href="/pages/mechanic/scan.html">📷<span>QR 스캔</span></a>
<a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
</div>
<div class="layout">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="max-width:640px;margin:0 auto;">
<div style="margin-bottom:14px;"> <div style="margin-bottom:14px;">
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a> <a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
</div> </div>
@@ -29,15 +51,13 @@
</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>
<div class="check-group" id="repairTypes"> <div class="check-group" id="repairTypes">
<label class="check-item"><input type="checkbox" value="부품교체"> 부품 교체</label> <div style="color:var(--gray4);font-size:12px">불러오는 중...</div>
<label class="check-item"><input type="checkbox" value="재시작"> 재시작</label>
<label class="check-item"><input type="checkbox" value="설정변경"> 설정 변경</label>
<label class="check-item"><input type="checkbox" value="기타"> 기타</label>
</div> </div>
</div> </div>
@@ -46,139 +66,423 @@
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea> <textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
</div> </div>
<!-- 사진 안내 -->
<div style="background:#FFF8E6;border:1px solid #FFD600;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;line-height:1.7;">
📌 <strong>촬영 필수 항목</strong><br>
· 충전기 <strong>명판(제조사·모델명)</strong><strong>충전기 식별 ID</strong>가 선명하게 보이도록 촬영해 주세요.<br>
· 조치 전·후 상태를 각각 촬영하면 검증에 도움이 됩니다.
</div>
<!-- 조치 전 사진 --> <!-- 조치 전 사진 -->
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label> <label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
<label class="upload-area" for="photosBefore">📷 촬영 또는 앨범 선택</label> <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
<label class="upload-area" for="photosBeforeCamera" style="margin:0">📷 카메라 촬영</label>
<label class="upload-area" for="photosBefore" style="margin:0">🖼 갤러리 선택</label>
</div>
<input type="file" id="photosBeforeCamera" accept="image/*" capture="environment" style="display:none">
<input type="file" id="photosBefore" accept="image/*" multiple style="display:none"> <input type="file" id="photosBefore" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="previewBefore"></div> <div class="photo-preview" id="previewBefore"></div>
<div class="photo-info" id="infoBefore"></div> <div class="photo-info" id="infoBefore"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label> <label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
<label class="upload-area" for="photosAfter">📷 촬영 또는 앨범 선택</label> <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
<label class="upload-area" for="photosAfterCamera" style="margin:0">📷 카메라 촬영</label>
<label class="upload-area" for="photosAfter" style="margin:0">🖼 갤러리 선택</label>
</div>
<input type="file" id="photosAfterCamera" accept="image/*" capture="environment" style="display:none">
<input type="file" id="photosAfter" accept="image/*" multiple style="display:none"> <input type="file" id="photosAfter" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="previewAfter"></div> <div class="photo-preview" id="previewAfter"></div>
<div class="photo-info" id="infoAfter"></div> <div class="photo-info" id="infoAfter"></div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-row" style="margin-bottom:14px;">
<label>처리 상태 <span class="req">*</span></label> <div class="form-group" style="margin-bottom:0">
<select id="resultStatus"> <label>🕐 조치 시작 시각 <span style="font-size:11px;color:var(--gray4);font-weight:400">(직접 수정 가능)</span></label>
<option value="done">✅ 처리 완료</option> <input type="datetime-local" id="startedAt" style="width:100%">
<option value="waiting">⏳ 부품 대기</option> </div>
<option value="revisit">🔄 재방문 필요</option> <div class="form-group" style="margin-bottom:0">
</select> <label>🏁 조치 완료 시각 <span style="font-size:11px;color:var(--gray4);font-weight:400">(직접 수정 가능)</span></label>
<input type="datetime-local" id="completedAt" style="width:100%">
</div>
</div> </div>
<div class="alert alert-info" style="margin-bottom:14px;"> <div id="gpsStatus" class="alert alert-info" style="margin-bottom:14px;">
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록) 📍 위치 정보 수집 중...
</div> </div>
<input type="hidden" id="mechanicLat">
<input type="hidden" id="mechanicLng">
<div id="formErr" class="alert alert-danger" style="display:none"></div> <div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">조치 완료 저장</button>
</div>
</div>
<script src="/js/api.js"></script> <!-- 저장 버튼 영역 -->
<script src="/js/auth.js"></script> <button class="btn btn-primary btn-lg" id="doneBtn" style="width:100%;margin-top:4px;" onclick="submitForm(true)">
<script src="/js/imageCompress.js"></script> ✅ 조치 완료 저장
</button>
<input type="hidden" id="resultStatus" value="done">
</div>
</div><!-- max-width wrapper -->
</div><!-- .main -->
</div><!-- .layout -->
<script src="/js/api.js?v=20260603"></script>
<script src="/js/auth.js?v=20260603"></script>
<script src="/js/imageCompress.js?v=20260603"></script>
<script> <script>
Auth.require(['mechanic','admin']); Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search); function toggleSidebar() {
const chargerId = params.get('charger_id'); const s = document.getElementById('sidebar');
const o = document.getElementById('navOverlay');
if (s) s.classList.toggle('mobile-open');
if (o) o.classList.toggle('show');
}
const params = new URLSearchParams(location.search);
const repairId = params.get('repair_id'); // 편집 모드
const chargerId = params.get('charger_id'); // 신규 모드
const initReportId = params.get('report_id'); const initReportId = params.get('report_id');
const startTime = new Date(); const isEditMode = !!repairId;
document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR');
// datetime-local 입력값 포맷: "YYYY-MM-DDTHH:mm"
function toLocalDtInput(date) {
const d = new Date(date);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const startTime = new Date();
document.getElementById('startedAt').value = toLocalDtInput(startTime);
document.getElementById('completedAt').value = toLocalDtInput(startTime);
// 조치 시작시각 변경 시 완료시각이 시작보다 이전이면 자동 보정
document.getElementById('startedAt').addEventListener('change', function () {
const started = document.getElementById('startedAt').value;
const completed = document.getElementById('completedAt').value;
if (started && completed && completed < started) {
document.getElementById('completedAt').value = started;
}
});
const selectedReports = new Set(); const selectedReports = new Set();
if (initReportId) selectedReports.add(parseInt(initReportId)); if (initReportId) selectedReports.add(parseInt(initReportId));
async function load() { // ── 신규 모드 ──
const charger = await API.get('/chargers/' + chargerId); async function loadCreate() {
document.getElementById('chargerCard').innerHTML = ` try {
<div class="card-title">⚡ 충전기 정보</div> const charger = await API.get('/chargers/' + chargerId);
<div class="form-row"> if (!charger) return; // 401 → 로그아웃 리다이렉트 진행 중
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${charger.id}</strong></div> document.getElementById('chargerCard').innerHTML =
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${charger.name}</strong></div> '<div class="card-title">⚡ 충전기 정보</div>' +
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${charger.station_name}</strong></div> '<div class="form-row">' +
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name || '-'}</strong></div> '<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>' + charger.id + '</strong></div>' +
</div>`; '<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>' + charger.name + '</strong></div>' +
'<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>' + charger.station_name + '</strong></div>' +
'<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>' + (charger.cpo_name || '-') + '</strong></div>' +
'</div>';
const reports = await API.get('/repairs/charger/' + chargerId + '/open'); const reports = await API.get('/repairs/charger/' + chargerId + '/open');
const list = document.getElementById('reportList'); const list = document.getElementById('reportList');
if (!reports.length) { if (!reports || !reports.length) {
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>'; list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
return; return;
}
list.innerHTML = reports.map(function(r) {
var bg = selectedReports.has(r.id) ? '#E3EDFF' : 'white';
var checked = selectedReports.has(r.id) ? 'checked' : '';
var photoHtml = r.photos && r.photos.length
? '<div class="photo-preview">' + r.photos.map(function(p) { return '<img src="' + p + '">'; }).join('') + '</div>'
: '';
return '<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:' + bg + '">' +
'<input type="checkbox" ' + checked + ' value="' + r.id + '"' +
' style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"' +
' onchange="toggleReport(' + r.id + ',this.checked,this.closest(\'label\'))">' +
'<div>' +
'<div><strong>#' + r.id + '</strong> ' + Auth.statusBadge(r.status) +
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 ' + (r.re_dispatch_count + 1) + '차 조치</span>' : '') + '</div>' +
'<div style="font-size:12px;color:var(--text2);margin-top:2px">' + ((r.issue_types || []).join(', ')) + '</div>' +
'<div style="font-size:11px;color:var(--gray4)">' + Auth.fmtDt(r.reported_at) + '</div>' +
photoHtml +
'</div>' +
'</label>';
}).join('');
// 차수 배너: 대상 신고(initReportId 또는 첫 번째)의 re_dispatch_count 기준
var targetReport = reports.find(function(r) { return r.id === parseInt(initReportId); }) || reports[0];
if (targetReport && targetReport.re_dispatch_count > 0) {
var nth = targetReport.re_dispatch_count + 1;
document.getElementById('repairCardTitle').textContent = '🔧 조치 내역 입력 (' + nth + '차 조치)';
document.getElementById('attemptBanner').innerHTML =
'<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">' +
'🔁 이 건은 <strong>' + nth + '차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)' +
'</div>';
}
} catch(e) {
document.getElementById('chargerCard').innerHTML =
'<div class="alert alert-danger">충전기 정보를 불러오지 못했습니다.<br><small style="opacity:.8">' + e.message + '</small></div>';
document.getElementById('reportList').innerHTML = '';
} }
list.innerHTML = reports.map(r => `
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:${selectedReports.has(r.id)?'#E3EDFF':'white'}">
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''}
value="${r.id}"
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"
onchange="toggleReport(${r.id}, this.checked, this.closest('label'))">
<div>
<div><strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
<div style="font-size:11px;color:var(--gray4)">${Auth.fmtDt(r.reported_at)}</div>
${r.photos.length
? `<div class="photo-preview">${r.photos.map(p=>`<img src="${p}">`).join('')}</div>`
: ''}
</div>
</label>`).join('');
} }
function toggleReport(id, checked, label) { function toggleReport(id, checked, label) {
if (checked) { selectedReports.add(id); label.style.background = '#E3EDFF'; } if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; }
else { selectedReports.delete(id); label.style.background = 'white'; } else { selectedReports.delete(id); label.style.background='white'; }
} }
// ── 편집 모드 ──
async function loadEdit() {
let repair;
try { repair = await API.get('/repairs/' + repairId); }
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
if (!repair) return; // 401 → 로그아웃 리다이렉트 진행 중
// 헤더 업데이트
var h2el = document.querySelector('.main > div > h2') || document.querySelector('h2');
if (h2el) h2el.parentNode.removeChild(h2el);
const attemptLabel = repair.attempt > 1 ? ` · ${repair.attempt}차 조치` : '';
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}${attemptLabel}</span>`);
// 차수 배너 (2차 이상일 때)
if (repair.attempt > 1) {
document.getElementById('repairCardTitle').textContent = `🔧 조치 내역 입력 (${repair.attempt}차 조치)`;
document.getElementById('attemptBanner').innerHTML =
`<div style="background:#FFF7E6;border:1px solid #F59E0B;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:13px;font-weight:600;color:#92400E;">` +
`🔁 이 건은 <strong>${repair.attempt}차 조치</strong> 대상입니다. (이전 조치 후 관리자 재조치 요청됨)` +
`</div>`;
}
// 충전기 카드
document.getElementById('chargerCard').innerHTML = `
<div class="card-title">⚡ 충전기 정보</div>
<div class="form-row">
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${repair.charger_id||'-'}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${repair.charger_name||'-'}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${repair.station_name||'-'}</strong></div>
</div>`;
// 연결된 신고 (읽기 전용)
document.getElementById('reportList').innerHTML = (repair.reports||[]).length
? (repair.reports||[]).map(r => `
<div style="padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;background:#F8FAFF;">
<strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div>
</div>`).join('')
: '<div class="alert alert-info">연결된 신고 없음</div>';
// 승인 완료 → 잠금
if (repair.approved_at) {
const dt = new Date(repair.approved_at).toLocaleString('ko-KR');
document.querySelector('.card:last-child').innerHTML = `
<div class="alert alert-success" style="margin-bottom:0">
✅ <strong>관리자 승인 완료</strong> (${repair.approved_by_name||''} · ${dt})<br>
<span style="font-size:12px;">승인된 조치는 수정할 수 없습니다.</span>
</div>
${renderRepairView(repair)}`;
return;
}
// 폼 미리채우기 — 조치유형 동적 로드 후 체크 복원
await loadRepairTypes(repair.repair_types || []);
document.getElementById('description').value = repair.description || '';
if (repair.started_at) document.getElementById('startedAt').value = toLocalDtInput(repair.started_at);
if (repair.completed_at) document.getElementById('completedAt').value = toLocalDtInput(repair.completed_at);
// 기존 사진 표시
renderExistingPhotos(repair);
}
function renderRepairView(r) {
const LABEL = {done:'✅ 완료',in_progress:'🔧 진행중',waiting:'⏳ 부품대기',revisit:'🔄 재방문'};
const photoHtml = (type, list) => (list||[]).length
? `<div style="margin-top:8px"><label style="font-size:11px;font-weight:700;color:var(--navy2)">${type}</label>
<div class="photo-preview">${(list||[]).map(p=>`<img src="${p.path||p}" onclick="window.open('${p.path||p}')" style="cursor:zoom-in">`).join('')}</div></div>`
: '';
return `<div style="padding:14px 0">
<table style="font-size:13px;width:100%">
<tr><td style="color:var(--gray4);width:90px">조치유형</td><td>${(r.repair_types||[]).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">조치내용</td><td>${r.description||'-'}</td></tr>
<tr><td style="color:var(--gray4)">처리결과</td><td>${LABEL[r.result_status]||r.result_status}</td></tr>
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(r.completed_at)}</td></tr>
</table>
${photoHtml('조치 전 사진', r.photos_before)}
${photoHtml('조치 후 사진', r.photos_after)}
</div>`;
}
function renderExistingPhotos(repair) {
const mkGrid = (list, type) => {
if (!list || !list.length) return '';
return `<div style="display:flex;flex-wrap:wrap;gap:7px;margin-bottom:8px;">
${list.map(p => `
<div style="position:relative;">
<img src="${p.path}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);display:block;">
<button onclick="deleteRepairPhoto(${repair.id},${p.id},'${type}')"
style="position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:#e53e3e;color:white;border:none;font-size:11px;cursor:pointer;line-height:1;padding:0;">✕</button>
</div>`).join('')}
</div>`;
};
const bWrap = document.getElementById('previewBefore');
const aWrap = document.getElementById('previewAfter');
if (repair.photos_before && repair.photos_before.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
if (repair.photos_after && repair.photos_after.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
}
async function deleteRepairPhoto(rId, pId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
await API.delete(`/repairs/${rId}/photos/${pId}`);
location.reload();
} catch(e) { alert(e.message); }
}
// GPS 수집 — 1단계: 저정밀(WiFi/셀) 즉시, 2단계: 고정밀(GPS) 백그라운드
(function acquireGPS() {
if (!navigator.geolocation) {
document.getElementById('gpsStatus').className = 'alert alert-warn';
document.getElementById('gpsStatus').textContent = '⚠️ 이 기기는 위치 정보를 지원하지 않습니다.';
return;
}
function applyPos(pos, label) {
document.getElementById('mechanicLat').value = pos.coords.latitude;
document.getElementById('mechanicLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').className = 'alert alert-success';
document.getElementById('gpsStatus').innerHTML =
`📍 위치 수집 완료${label} <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
}
function failGPS() {
if (document.getElementById('mechanicLat').value) return; // 이미 성공
document.getElementById('gpsStatus').className = 'alert alert-warn';
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다. (저장은 가능)';
}
// 1단계: 캐시 허용 + 저정밀 → 5초 내 응답 (WiFi/셀 기반, 실내에서도 동작)
navigator.geolocation.getCurrentPosition(
pos => {
applyPos(pos, '');
// 2단계: 고정밀 GPS 백그라운드로 시도해 더 정확한 좌표로 업데이트
navigator.geolocation.getCurrentPosition(
pos2 => applyPos(pos2, ' (고정밀)'),
() => {}, // 실패해도 1단계 좌표 유지
{ enableHighAccuracy: true, timeout: 30000 }
);
},
() => {
// 저정밀도 실패 시 고정밀 한 번 더 시도
navigator.geolocation.getCurrentPosition(
pos => applyPos(pos, ''),
failGPS,
{ enableHighAccuracy: true, timeout: 30000 }
);
},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
);
})();
// 이미지 압축 + 다중 선택 프리뷰 // 이미지 압축 + 다중 선택 프리뷰
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore'); ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter'); ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
ImageCompressor.setupCameraAppend('photosBeforeCamera', 'photosBefore');
ImageCompressor.setupCameraAppend('photosAfterCamera', 'photosAfter');
document.getElementById('submitBtn').addEventListener('click', async () => { async function submitForm(isDone) {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value); const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; } if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; }
const desc = document.getElementById('description').value.trim(); const desc = document.getElementById('description').value.trim();
if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; } if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; }
document.getElementById('submitBtn').disabled = true; if (!isEditMode) {
document.getElementById('submitBtn').textContent = '저장 중...'; const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
}
const doneBtn = document.getElementById('doneBtn');
doneBtn.disabled = true;
doneBtn.textContent = '저장 중...';
const resultStatus = 'done';
const lat = document.getElementById('mechanicLat').value;
const lng = document.getElementById('mechanicLng').value;
const fd = new FormData(); const fd = new FormData();
fd.append('report_ids', JSON.stringify(rids));
fd.append('repair_types', JSON.stringify(types)); fd.append('repair_types', JSON.stringify(types));
fd.append('description', desc); fd.append('description', desc);
fd.append('result_status', document.getElementById('resultStatus').value); fd.append('result_status', resultStatus);
const startedAtVal = document.getElementById('startedAt').value;
const completedAtVal = document.getElementById('completedAt').value;
if (startedAtVal) fd.append('started_at_input', startedAtVal);
if (completedAtVal) fd.append('completed_at_input', completedAtVal);
if (lat) fd.append('mechanic_lat', lat);
if (lng) fd.append('mechanic_lng', lng);
Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f)); Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f));
Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f)); Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f));
try { try {
await API.post('/repairs', fd); if (isEditMode) {
alert('✅ 조치 완료 저장되었습니다.'); await API.put('/repairs/' + repairId, fd);
location.href = '/pages/mechanic/dashboard.html'; alert('✅ 조치 완료로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
} else {
fd.append('report_ids', JSON.stringify([...selectedReports]));
await API.post('/repairs', fd);
alert('✅ 조치 완료로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
}
} catch(e) { } catch(e) {
showErr(e.message); showErr(e.message);
document.getElementById('submitBtn').disabled = false; doneBtn.disabled = false;
document.getElementById('submitBtn').textContent = '조치 완료 저장'; doneBtn.textContent = '조치 완료 저장';
} }
}); }
function showErr(msg) { function showErr(msg) {
const el = document.getElementById('formErr'); const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block'; el.textContent = msg; el.style.display = 'block';
} }
load(); const DEFAULT_REPAIR_TYPES = [
{key:'부품교체',label:'🔩 부품 교체'},
{key:'재시작', label:'🔄 재시작'},
{key:'설정변경',label:'⚙️ 설정 변경'},
{key:'청소', label:'🧹 청소'},
{key:'배선정리',label:'🔌 배선 정리'},
{key:'펌웨어', label:'💾 펌웨어 업데이트'},
{key:'기타', label:'📋 기타'},
];
function renderRepairTypeList(types, preChecked) {
const el = document.getElementById('repairTypes');
if (!el) return;
el.innerHTML = types.map(t => `
<label class="check-item">
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
${t.label}
</label>`).join('');
}
async function loadRepairTypes(preChecked = []) {
// 기본값 즉시 표시 — 네트워크 대기 없이 바로 사용 가능
renderRepairTypeList(DEFAULT_REPAIR_TYPES, preChecked);
// API에서 커스텀 유형 로드해 덮어쓰기 (실패해도 기본값 유지)
try {
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), 8000);
const res = await fetch('/api/settings/repair-types', { signal: controller.signal });
clearTimeout(tid);
if (!res.ok) return;
const types = await res.json();
if (Array.isArray(types) && types.length) renderRepairTypeList(types, preChecked);
} catch(_) { /* 기본값 유지 */ }
}
if (isEditMode) {
loadEdit();
} else {
loadRepairTypes();
loadCreate();
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -6,30 +6,46 @@
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<style> <style>
#reader{width:100%;max-width:400px;margin:0 auto;border-radius:10px;overflow:hidden;} #reader{width:100%;max-width:400px;margin:0 auto;border-radius:10px;overflow:hidden;}
.scan-wrap{max-width:480px;margin:0 auto;padding:20px;}
</style> </style>
</head> </head>
<body> <body>
<nav class="nav"> <nav class="nav">
<span class="nav-brand">QR 스캔</span> <div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">EV AS 관리</span></div>
<div id="navUser"></div> <div id="navUser"></div>
</nav> </nav>
<div class="scan-wrap"> <div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div> <div class="mech-tab-bar">
<div id="reader"></div> <a href="/pages/mechanic/dashboard.html">📋<span>AS 목록</span></a>
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div> <a href="/pages/mechanic/scan.html" class="active">📷<span>QR 스캔</span></a>
<div style="margin-top:16px;"> <a href="/pages/mechanic/history.html">🗂<span>처리 이력</span></a>
<div class="form-group"> </div>
<label>충전기 ID 직접 입력</label> <div class="layout">
<div style="display:flex;gap:8px;"> <div class="sidebar" id="sidebar">
<input type="text" id="manualId" placeholder="예: CG-003"> <div class="sidebar-section">메뉴</div>
<button class="btn btn-primary" onclick="goManual()">이동</button> <a href="/pages/mechanic/dashboard.html">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html" class="active">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="max-width:480px;margin:0 auto;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:16px">📷 QR 스캔</h2>
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
<div id="reader"></div>
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
<div style="margin-top:16px;">
<div class="form-group">
<label>충전기 ID 직접 입력</label>
<div style="display:flex;gap:8px;">
<input type="text" id="manualId" placeholder="예: CG-003">
<button class="btn btn-primary" onclick="goManual()">이동</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="https://unpkg.com/html5-qrcode/minified/html5-qrcode.min.js"></script> <script src="https://unpkg.com/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script> <script src="/js/api.js?v=20260603"></script><script src="/js/auth.js?v=20260603"></script>
<script> <script>
Auth.require(['mechanic','admin']); Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>현황 조회</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.ro-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:700;background:#EDE9FE;color:#5B21B6;margin-left:8px;vertical-align:middle;}
.stat-link{text-decoration:none;display:block;}
.stat-link:hover .stat{box-shadow:0 4px 16px rgba(0,0,0,.12);transform:translateY(-1px);transition:all .15s;}
.filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;}
.filter-bar select,.filter-bar input{padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;font-family:inherit;color:var(--text);}
</style>
</head>
<body>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html" class="active">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html">📋 신고 목록</a>
</div>
<div class="main">
<!-- 통계 카드 -->
<div class="stats" id="statsRow" style="margin-bottom:24px">
<div class="stat"><div class="stat-num" id="sTotal">-</div><div class="stat-label">전체 신고</div></div>
<div class="stat warn"><div class="stat-num" id="sPendingApproval">-</div><div class="stat-label">승인대기</div></div>
<div class="stat warn"><div class="stat-num" id="sPending">-</div><div class="stat-label">접수</div></div>
<div class="stat warn"><div class="stat-num" id="sInProgress">-</div><div class="stat-label">처리중</div></div>
<div class="stat good"><div class="stat-num" id="sDone">-</div><div class="stat-label">완료</div></div>
<div class="stat"><div class="stat-num" id="sClosed">-</div><div class="stat-label">상황종료</div></div>
</div>
<!-- 신고 목록 -->
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-wrap:wrap;gap:8px;">
<div class="card-title" style="margin:0">📋 신고 현황</div>
<div class="filter-bar" style="margin:0">
<select id="fStatus" onchange="load()">
<option value="">전체 상태</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
<option value="done">완료</option>
<option value="closed">상황종료</option>
</select>
<input type="text" id="fCharger" placeholder="충전기ID 검색" style="width:130px" oninput="load()">
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>접수번호</th><th>충전기ID</th><th>충전소명</th><th>문제유형</th>
<th>에러코드</th><th>발생일시</th><th>신고일시</th><th>정비사</th><th>상태</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" style="display:none;text-align:center;padding:40px;color:var(--gray4);font-size:13px">신고 내역이 없습니다.</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['observer', 'admin']);
Auth.renderNav(document.getElementById('navUser'));
let _allReports = [];
async function loadStats() {
try {
const s = await API.get('/stats');
document.getElementById('sTotal').textContent = s.total ?? '-';
document.getElementById('sPendingApproval').textContent = s.pending_approval ?? 0;
document.getElementById('sPending').textContent = s.pending ?? 0;
document.getElementById('sInProgress').textContent = s.in_progress ?? 0;
document.getElementById('sDone').textContent = s.done ?? 0;
document.getElementById('sClosed').textContent = s.closed ?? 0;
} catch(e) { console.error(e); }
}
async function load() {
const status = document.getElementById('fStatus').value;
const chargerId = document.getElementById('fCharger').value.trim();
try {
let url = '/reports';
if (status) url += '?status=' + status;
_allReports = await API.get(url);
} catch(e) { _allReports = []; }
render(chargerId);
}
function render(chargerId) {
let rows = _allReports;
if (chargerId) rows = rows.filter(r => r.charger_id?.includes(chargerId));
const tbody = document.getElementById('tbody');
const empty = document.getElementById('empty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr style="cursor:default">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong></td>
<td>${r.station_name || '-'}</td>
<td>${(r.issue_types || []).join(', ') || '-'}</td>
<td>${r.error_code || '-'}</td>
<td>${r.occurred_at ? Auth.fmtDt(r.occurred_at) : '<span style="color:var(--gray4)">-</span>'}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${r.mechanic_name || '-'}</td>
<td>${Auth.statusBadge(r.status)}</td>
</tr>`).join('');
}
loadStats();
load();
</script>
</body></html>

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>신고 목록</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.ro-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:700;background:#EDE9FE;color:#5B21B6;margin-left:8px;vertical-align:middle;}
</style>
</head>
<body>
<nav class="nav">
<div style="display:flex;align-items:center;gap:2px;"><button class="nav-hamburger" onclick="toggleSidebar()"></button><span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span></div>
<div id="navUser"></div>
</nav>
<div class="mobile-nav-overlay" id="navOverlay" onclick="toggleSidebar()"></div>
<div class="layout">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html" class="active">📋 신고 목록</a>
</div>
<div class="main">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px;">
<div class="card-title" style="margin:0">📋 신고 목록 <span id="totalBadge" style="font-size:12px;color:var(--gray4);font-weight:400"></span></div>
</div>
<!-- 필터 -->
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
<select id="fStatus" onchange="load()" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;font-family:inherit;">
<option value="">전체 상태</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
<option value="done">완료</option>
<option value="closed">상황종료</option>
</select>
<input type="text" id="fCharger" placeholder="충전기ID" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;width:130px" oninput="render()">
<input type="text" id="fStation" placeholder="충전소명" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;width:130px" oninput="render()">
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>접수번호</th><th>충전기ID</th><th>충전소명</th><th>CPO</th>
<th>문제유형</th><th>에러코드</th><th>발생일시</th><th>신고일시</th>
<th>정비사</th><th>조치완료</th><th>상태</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" style="display:none;text-align:center;padding:40px;color:var(--gray4);font-size:13px">신고 내역이 없습니다.</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['observer', 'admin']);
Auth.renderNav(document.getElementById('navUser'));
let _allReports = [];
async function load() {
const status = document.getElementById('fStatus').value;
try {
_allReports = await API.get('/reports' + (status ? '?status=' + status : ''));
} catch(e) { _allReports = []; }
render();
}
function render() {
const cid = document.getElementById('fCharger').value.trim().toLowerCase();
const station = document.getElementById('fStation').value.trim().toLowerCase();
let rows = _allReports;
if (cid) rows = rows.filter(r => r.charger_id?.toLowerCase().includes(cid));
if (station) rows = rows.filter(r => r.station_name?.toLowerCase().includes(station));
document.getElementById('totalBadge').textContent = `(${rows.length}건)`;
const tbody = document.getElementById('tbody');
const empty = document.getElementById('empty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr style="cursor:default">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong></td>
<td>${r.station_name || '-'}</td>
<td>${r.cpo_name || '-'}</td>
<td>${(r.issue_types || []).join(', ') || '-'}</td>
<td>${r.error_code || '-'}</td>
<td>${r.occurred_at ? Auth.fmtDt(r.occurred_at) : '<span style="color:var(--gray4)">-</span>'}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${r.mechanic_name || '-'}</td>
<td>${r.mechanic_name && r.status === 'done' ? '완료' : '-'}</td>
<td>${Auth.statusBadge(r.status)}</td>
</tr>`).join('');
}
load();
</script>
</body></html>

View File

@@ -221,7 +221,8 @@ body { background: var(--gray1); }
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3> <h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
<div class="issue-grid" id="issueGrid"></div> <div class="issue-grid" id="issueGrid"></div>
<div id="errorCodeWrap" style="margin-top:10px;display:none;"> <div id="errorCodeWrap" style="margin-top:10px;display:none;">
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)"> <!-- populated as dropdown or text input depending on chargerErrors -->
<div id="errorCodeInner"></div>
</div> </div>
<div id="etcWrap" style="margin-top:10px;display:none;"> <div id="etcWrap" style="margin-top:10px;display:none;">
<input type="text" id="etcText" placeholder="기타 문제 내용 입력"> <input type="text" id="etcText" placeholder="기타 문제 내용 입력">
@@ -241,10 +242,11 @@ body { background: var(--gray1); }
충전기 사진 <span style="color:var(--red)">*필수</span> 충전기 사진 <span style="color:var(--red)">*필수</span>
<span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span> <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span>
</label> </label>
<label class="upload-area" for="chargerPhoto"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
📷 탭하여 촬영하거나 앨범에서 선택<br> <label class="upload-area" for="chargerPhotoCamera" style="margin:0">📷 카메라 촬영</label>
<span style="font-size:11px">여러 장 동시 선택 가능</span> <label class="upload-area" for="chargerPhoto" style="margin:0">🖼 갤러리 선택</label>
</label> </div>
<input type="file" id="chargerPhotoCamera" accept="image/*" capture="environment" style="display:none">
<input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none"> <input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="chargerPreview"></div> <div class="photo-preview" id="chargerPreview"></div>
<div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div> <div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div>
@@ -253,13 +255,35 @@ body { background: var(--gray1); }
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block"> <label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span> 차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span>
</label> </label>
<label class="upload-area" for="carPhoto">📷 탭하여 촬영하거나 앨범에서 선택</label> <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;">
<label class="upload-area" for="carPhotoCamera" style="margin:0">📷 카메라 촬영</label>
<label class="upload-area" for="carPhoto" style="margin:0">🖼 갤러리 선택</label>
</div>
<input type="file" id="carPhotoCamera" accept="image/*" capture="environment" style="display:none">
<input type="file" id="carPhoto" accept="image/*" multiple style="display:none"> <input type="file" id="carPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="carPreview"></div> <div class="photo-preview" id="carPreview"></div>
<div class="photo-info" id="carInfo" style="color:var(--gray4)"></div> <div class="photo-info" id="carInfo" style="color:var(--gray4)"></div>
</div> </div>
</div> </div>
<div class="section">
<h3>📡 신고 범위</h3>
<div style="display:flex;flex-direction:column;gap:10px">
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
<input type="radio" name="scope" value="single" checked style="width:auto;accent-color:var(--accent)">
<div><strong>이 충전기만</strong><div style="font-size:11px;color:var(--gray4)">현재 스캔한 충전기에만 신고</div></div>
</label>
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
<input type="radio" name="scope" value="station" style="width:auto;accent-color:var(--accent)">
<div><strong>충전소 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전소의 모든 충전기에 신고</div></div>
</label>
<label style="display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer">
<input type="radio" name="scope" value="type" style="width:auto;accent-color:var(--accent)">
<div><strong>동일 모델 전체</strong><div style="font-size:11px;color:var(--gray4)">같은 충전기 모델 전체에 신고</div></div>
</label>
</div>
</div>
<div class="section"> <div class="section">
<h3>📝 상세 설명 (선택)</h3> <h3>📝 상세 설명 (선택)</h3>
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea> <textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
@@ -292,9 +316,9 @@ body { background: var(--gray1); }
</div> </div>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/imageCompress.js"></script> <script src="/js/imageCompress.js?v=20260603"></script>
<script> <script>
const ISSUES = [ let ISSUES = [
{key:'충전불가', label:'⚡ 충전 불가'}, {key:'충전불가', label:'⚡ 충전 불가'},
{key:'화면오류', label:'🖥 화면 오류'}, {key:'화면오류', label:'🖥 화면 오류'},
{key:'케이블불량',label:'🔌 케이블 불량'}, {key:'케이블불량',label:'🔌 케이블 불량'},
@@ -303,6 +327,7 @@ const ISSUES = [
{key:'에러발생', label:'⚠️ 에러 발생'}, {key:'에러발생', label:'⚠️ 에러 발생'},
{key:'기타', label:'📋 기타'}, {key:'기타', label:'📋 기타'},
]; ];
let chargerErrors = [];
const STATUS_ICON = { const STATUS_ICON = {
pending_approval: '🕐', pending_approval: '🕐',
@@ -327,7 +352,11 @@ let isStatusOpen = true;
// ── 충전기 정보 로드 ── // ── 충전기 정보 로드 ──
async function loadCharger() { async function loadCharger() {
try { try {
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json()); const [c, errs] = await Promise.all([
fetch('/api/chargers/' + chargerId).then(r => r.json()),
fetch('/api/chargers/' + chargerId + '/errors').then(r => r.json()).catch(() => []),
]);
chargerErrors = errs;
document.getElementById('chargerInfo').innerHTML = ` document.getElementById('chargerInfo').innerHTML = `
<h2>⚡ ${c.name}</h2> <h2>⚡ ${c.name}</h2>
<div class="row"><span>충전소</span><span>${c.station_name}</span></div> <div class="row"><span>충전소</span><span>${c.station_name}</span></div>
@@ -421,37 +450,83 @@ function initCollapseHeight() {
} }
// ── GPS ── // ── GPS ──
navigator.geolocation?.getCurrentPosition( if (navigator.geolocation) {
pos => { navigator.geolocation.getCurrentPosition(
document.getElementById('gpsLat').value = pos.coords.latitude; function(pos) {
document.getElementById('gpsLng').value = pos.coords.longitude; document.getElementById('gpsLat').value = pos.coords.latitude;
document.getElementById('gpsStatus').textContent = document.getElementById('gpsLng').value = pos.coords.longitude;
`📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`; document.getElementById('gpsStatus').textContent =
document.getElementById('gpsStatus').className = 'alert alert-success'; '📍 위치 수집 완료 (' + pos.coords.latitude.toFixed(5) + ', ' + pos.coords.longitude.toFixed(5) + ')';
}, document.getElementById('gpsStatus').className = 'alert alert-success';
() => { },
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)'; function() {
document.getElementById('gpsStatus').className = 'alert alert-warn'; document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
document.getElementById('gpsStatus').className = 'alert alert-warn';
}
);
}
// ── 에러코드 UI 갱신 ──
function updateErrorCodeUI() {
const wrap = document.getElementById('errorCodeWrap');
const inner = document.getElementById('errorCodeInner');
if (!selected.has('에러발생')) { wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
if (chargerErrors.length > 0) {
inner.innerHTML = `
<select id="errorCode" style="width:100%">
<option value="">-- 에러코드 선택 --</option>
${chargerErrors.map(e =>
`<option value="${e.error_code}">${e.error_code}${e.error_name}${e.range_condition ? ' ('+e.range_condition+')' : ''}</option>`
).join('')}
<option value="__other__">기타 (직접 입력)</option>
</select>
<input type="text" id="errorCodeCustom" placeholder="에러코드 직접 입력" style="margin-top:6px;display:none">`;
document.getElementById('errorCode').onchange = function() {
document.getElementById('errorCodeCustom').style.display =
this.value === '__other__' ? 'block' : 'none';
};
} else {
inner.innerHTML = `<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">`;
} }
); }
// ── 에러코드 값 가져오기 ──
function getErrorCodeValue() {
const sel = document.getElementById('errorCode');
if (!sel) return '';
if (sel.tagName === 'SELECT') {
if (sel.value === '__other__') { var ecEl = document.getElementById('errorCodeCustom'); return ecEl ? ecEl.value : ''; }
return sel.value;
}
return sel.value;
}
// ── 문제 유형 버튼 ── // ── 문제 유형 버튼 ──
const grid = document.getElementById('issueGrid'); function renderIssueButtons(issues) {
ISSUES.forEach(issue => { const grid = document.getElementById('issueGrid');
const btn = document.createElement('button'); grid.innerHTML = '';
btn.className = 'issue-btn'; issues.forEach(issue => {
btn.textContent = issue.label; const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'issue-btn';
btn.onclick = () => { btn.textContent = issue.label;
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); } btn.type = 'button';
else { selected.add(issue.key); btn.classList.add('sel'); } btn.onclick = () => {
document.getElementById('errorCodeWrap').style.display = if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
selected.has('에러발생') ? 'block' : 'none'; else { selected.add(issue.key); btn.classList.add('sel'); }
document.getElementById('etcWrap').style.display = updateErrorCodeUI();
selected.has('기타') ? 'block' : 'none'; document.getElementById('etcWrap').style.display =
}; selected.has('기타') ? 'block' : 'none';
grid.appendChild(btn); };
}); grid.appendChild(btn);
});
}
fetch('/api/settings/issue-types')
.then(r => r.json())
.then(data => { if (Array.isArray(data) && data.length) { ISSUES = data; } })
.catch(() => {})
.finally(() => renderIssueButtons(ISSUES));
// ── 이미지 압축 + 다중 선택 ── // ── 이미지 압축 + 다중 선택 ──
ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2'); ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2');
@@ -474,11 +549,15 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
document.getElementById('submitBtn').disabled = true; document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...'; document.getElementById('submitBtn').textContent = '접수 중...';
var scopeEl = document.querySelector('input[name="scope"]:checked');
var scope = scopeEl ? scopeEl.value : 'single';
const fd = new FormData(); const fd = new FormData();
fd.append('charger_id', chargerId); fd.append('charger_id', chargerId);
fd.append('scope', scope);
fd.append('issue_types', JSON.stringify(issues)); fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('detail').value); fd.append('issue_detail', document.getElementById('detail').value);
fd.append('error_code', document.getElementById('errorCode').value); fd.append('error_code', getErrorCodeValue());
fd.append('occurred_at', document.getElementById('occurredAt').value || ''); fd.append('occurred_at', document.getElementById('occurredAt').value || '');
fd.append('contact', contact); fd.append('contact', contact);
fd.append('consent', consent); fd.append('consent', consent);
@@ -488,12 +567,13 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f)); Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
try { try {
const res = await fetch('/api/reports', { method: 'POST', body: fd }); const res = await fetch('/api/reports/batch', { method: 'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json(); const data = await res.json();
document.getElementById('mainForm').style.display = 'none'; document.getElementById('mainForm').style.display = 'none';
document.getElementById('resultBox').style.display = 'block'; document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`; const label = data.count > 1 ? `접수번호: #${data.primary_id}${data.count-1}` : `접수번호: #${data.primary_id}`;
document.getElementById('resultMsg').textContent = label;
// 현황 새로고침 // 현황 새로고침
document.getElementById('statusSection').style.display = 'none'; document.getElementById('statusSection').style.display = 'none';
document.getElementById('noReportNotice').style.display = 'none'; document.getElementById('noReportNotice').style.display = 'none';

View File

@@ -42,6 +42,14 @@ http {
expires 7d; expires 7d;
} }
# HTML·JS·CSS 파일 — 캐시 금지 (중간 프록시/CDN 포함)
location ~* \.(html|js|css)$ {
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# 정적 파일 (SPA 라우팅) # 정적 파일 (SPA 라우팅)
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;