초기 커밋 - EV AS 관리 시스템

This commit is contained in:
root
2026-04-18 06:18:58 +09:00
commit 7a5c397983
52 changed files with 6044 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.env
uploads/
certbot/
__pycache__/
*.pyc
*.pyo
.DS_Store
postgres_data/

196
README.md Normal file
View File

@@ -0,0 +1,196 @@
# ⚡ EV 충전기 AS 관리 시스템
## 아키텍처 구성
```
인터넷
cs.byunc.com (DNS)
5825u 서버 ── Nginx 리버스 프록시 + Certbot SSL (HTTPS 443)
↓ 내부 포워딩 (127.0.0.1:5700)
Docker Compose (HTTP :5700)
├── ev_nginx :5700 → 컨테이너 내부 :80
├── ev_backend :8000 (내부)
└── ev_db :5432 (내부)
```
---
## 🚀 최초 설치 및 실행
### 1. .env 환경변수 수정 (필수)
```bash
cd /home/byun/ev-charger-as
nano .env
```
아래 두 가지 반드시 변경:
```
POSTGRES_PASSWORD=원하는_비밀번호
SECRET_KEY=아래_명령어로_생성한_값
```
SECRET_KEY 생성:
```bash
openssl rand -hex 32
```
DATABASE_URL 도 POSTGRES_PASSWORD 와 맞게 수정:
```
DATABASE_URL=postgresql://evuser:변경한_비밀번호@db:5432/evcharger
```
### 2. 빌드 및 실행
```bash
docker compose up -d --build
```
### 3. 동작 확인
```bash
docker compose ps
curl http://localhost:5700/api/health
# {"status":"ok"} 확인
```
---
## 🔑 초기 로그인
| 항목 | 값 |
|------|----|
| URL | https://cs.byunc.com/pages/login.html |
| 아이디 | admin |
| 비밀번호 | admin1234 |
> ⚠️ 로그인 후 즉시 비밀번호 변경 (설정 → 비밀번호 변경)
---
## ♻️ DB 초기화가 필요할 때 (재설치 시)
이미 한 번 실행했던 서버에서 init_db.sql 변경사항을 반영하려면
볼륨을 삭제하고 다시 올려야 합니다.
```bash
# 컨테이너 + 볼륨 완전 삭제 (DB 데이터 초기화됨 — 주의)
docker compose down -v
# 다시 빌드 및 실행
docker compose up -d --build
# 확인
docker compose logs db
docker compose logs backend
```
---
## 🌐 5825u Nginx 설정
기존 nginx 설정에 아래 파일 추가:
```bash
sudo nano /etc/nginx/sites-available/ev-charger
```
```nginx
server {
listen 80;
server_name cs.byunc.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name cs.byunc.com;
ssl_certificate /etc/letsencrypt/live/cs.byunc.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cs.byunc.com/privkey.pem;
client_max_body_size 20M;
location / {
proxy_pass http://127.0.0.1:5700;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 60s;
}
}
```
```bash
sudo ln -s /etc/nginx/sites-available/ev-charger /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
---
## 🗄️ DB 백업 / 복원
```bash
# 백업
docker exec ev_db pg_dump -U evuser evcharger > backup_$(date +%Y%m%d).sql
# 복원
docker exec -i ev_db psql -U evuser evcharger < backup_20240101.sql
# 업로드 파일 백업
tar -czf uploads_$(date +%Y%m%d).tar.gz uploads/
```
자동 백업 크론:
```bash
crontab -e
# 매일 새벽 3시
0 3 * * * cd ~/ev-charger-as && docker exec ev_db pg_dump -U evuser evcharger > ~/backups/db_$(date +\%Y\%m\%d).sql
```
---
## 🔄 코드 수정 후 재배포
```bash
# 백엔드 코드만 수정한 경우
docker compose build backend
docker compose up -d --no-deps backend
# 전체 재빌드
docker compose up -d --build
```
---
## 🛠 유용한 명령어
```bash
# 상태 확인
docker compose ps
# 로그 확인
docker compose logs -f backend
docker compose logs -f nginx
# DB 직접 접속
docker exec -it ev_db psql -U evuser evcharger
# 전체 재시작
docker compose restart
# 전체 중지 (데이터 보존)
docker compose down
```
---
## 🔒 보안 체크리스트
- [ ] .env POSTGRES_PASSWORD, SECRET_KEY 변경
- [ ] 초기 admin 비밀번호 변경
- [ ] 방화벽: 5432(DB) 포트 외부 노출 차단
- [ ] 정기 DB 백업 크론 설정

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
libpq-dev gcc libzbar0 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

70
backend/auth.py Normal file
View File

@@ -0,0 +1,70 @@
import os
import bcrypt
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from database import get_db
import models
SECRET_KEY = os.getenv("SECRET_KEY", "changeme")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
"""비밀번호 bcrypt 해시 생성"""
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""비밀번호 검증"""
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except Exception:
return False
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> models.User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증 정보가 유효하지 않습니다.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(models.User).filter(
models.User.id == int(user_id),
models.User.is_active == True
).first()
if user is None:
raise credentials_exception
return user
def require_role(*roles):
def checker(current_user: models.User = Depends(get_current_user)):
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="권한이 없습니다.")
return current_user
return checker
require_admin = require_role("admin")
require_mechanic = require_role("mechanic", "admin")
require_manufacturer = require_role("manufacturer", "admin")

18
backend/database.py Normal file
View File

@@ -0,0 +1,18 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://evuser:evpassword@db:5432/evcharger")
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

169
backend/init_db.sql Normal file
View File

@@ -0,0 +1,169 @@
-- ============================================================
-- EV 충전기 AS 관리 시스템 DB 스키마
-- ============================================================
CREATE TABLE IF NOT EXISTS charger_types (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL CHECK (role IN ('admin','mechanic','manufacturer')),
company VARCHAR(100),
name VARCHAR(50) NOT NULL,
phone VARCHAR(20),
email VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS chargers (
id VARCHAR(50) PRIMARY KEY,
charger_type_id INT REFERENCES charger_types(id),
name VARCHAR(100) NOT NULL,
station_name VARCHAR(100) NOT NULL,
location_detail TEXT,
cpo_name VARCHAR(100),
installed_at DATE,
gps_lat DOUBLE PRECISION,
gps_lng DOUBLE PRECISION,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS reports (
id SERIAL PRIMARY KEY,
charger_id VARCHAR(50) REFERENCES chargers(id),
issue_types TEXT[] NOT NULL,
issue_detail TEXT,
error_code VARCHAR(100),
occurred_at TIMESTAMP,
contact VARCHAR(20),
consent BOOLEAN DEFAULT FALSE,
gps_lat DOUBLE PRECISION,
gps_lng DOUBLE PRECISION,
status VARCHAR(30) DEFAULT 'pending'
CHECK (status IN ('pending_approval','pending','in_progress','done','waiting','revisit')),
reported_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS report_photos (
id SERIAL PRIMARY KEY,
report_id INT REFERENCES reports(id) ON DELETE CASCADE,
file_path VARCHAR(255) NOT NULL,
uploaded_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS repairs (
id SERIAL PRIMARY KEY,
mechanic_id INT REFERENCES users(id),
repair_types TEXT[] NOT NULL,
description TEXT NOT NULL,
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
result_status VARCHAR(20) DEFAULT 'done'
CHECK (result_status IN ('done','waiting','revisit'))
);
CREATE TABLE IF NOT EXISTS repair_reports (
repair_id INT REFERENCES repairs(id) ON DELETE CASCADE,
report_id INT REFERENCES reports(id) ON DELETE CASCADE,
PRIMARY KEY (repair_id, report_id)
);
CREATE TABLE IF NOT EXISTS repair_photos (
id SERIAL PRIMARY KEY,
repair_id INT REFERENCES repairs(id) ON DELETE CASCADE,
photo_type VARCHAR(10) DEFAULT 'after' CHECK (photo_type IN ('before','after')),
file_path VARCHAR(255) NOT NULL,
uploaded_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS repair_costs (
id SERIAL PRIMARY KEY,
repair_id INT UNIQUE REFERENCES repairs(id) ON DELETE CASCADE,
root_cause TEXT,
admin_note TEXT,
cost_party_type VARCHAR(20)
CHECK (cost_party_type IN ('cpo','manufacturer','self','user','other')),
cost_party_manufacturer_id INT REFERENCES users(id),
cost_party_custom VARCHAR(100),
cost_amount INT DEFAULT 0,
cost_status VARCHAR(20) DEFAULT 'pending'
CHECK (cost_status IN ('pending','billed','waived','settled')),
reviewed_by INT REFERENCES users(id),
reviewed_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS improvements (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
category VARCHAR(20) NOT NULL
CHECK (category IN ('sw','hw','ui','firmware','other')),
description TEXT NOT NULL,
priority VARCHAR(10) DEFAULT 'normal'
CHECK (priority IN ('urgent','high','normal','low')),
part_name VARCHAR(100),
status VARCHAR(20) DEFAULT 'registered'
CHECK (status IN ('registered','reviewing','developing','deployed','done')),
manufacturer_id INT REFERENCES users(id),
created_by INT REFERENCES users(id),
sw_deploy_target DATE,
sw_deployed_at DATE,
manufacturer_memo TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS improvement_reports (
improvement_id INT REFERENCES improvements(id) ON DELETE CASCADE,
report_id INT REFERENCES reports(id) ON DELETE CASCADE,
PRIMARY KEY (improvement_id, report_id)
);
CREATE TABLE IF NOT EXISTS improvement_attachments (
id SERIAL PRIMARY KEY,
improvement_id INT REFERENCES improvements(id) ON DELETE CASCADE,
file_path VARCHAR(255) NOT NULL,
file_name VARCHAR(255),
uploaded_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS improvement_logs (
id SERIAL PRIMARY KEY,
improvement_id INT REFERENCES improvements(id) ON DELETE CASCADE,
changed_by INT REFERENCES users(id),
old_status VARCHAR(20),
new_status VARCHAR(20),
memo TEXT,
changed_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS system_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
);
-- ============================================================
-- 기본 데이터
-- ============================================================
INSERT INTO charger_types (name, description) VALUES
('완속충전기', 'AC 7kW 이하'),
('급속충전기', 'DC 50kW'),
('초급속충전기', 'DC 100kW 이상')
ON CONFLICT DO NOTHING;
INSERT INTO system_settings (key, value) VALUES
('report_visibility_policy', 'immediate')
ON CONFLICT DO NOTHING;
-- 초기 관리자 계정: admin / admin1234
INSERT INTO users (username, password_hash, role, name, is_active) VALUES
('admin', '$2b$12$ocMnUviG6lYZ4BP4Ut00KumPg/L73b82eJCEfrXUmwfFcFy3zfWDO', 'admin', '관리자', true)
ON CONFLICT DO NOTHING;

53
backend/main.py Normal file
View File

@@ -0,0 +1,53 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import os
from routers import auth_router, chargers, reports, repairs, costs, improvements, accounts, settings, export
app = FastAPI(title="EV 충전기 AS 관리 시스템", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 라우터 등록
app.include_router(auth_router.router)
app.include_router(chargers.router)
app.include_router(reports.router)
app.include_router(repairs.router)
app.include_router(costs.router)
app.include_router(improvements.router)
app.include_router(accounts.router)
app.include_router(settings.router)
app.include_router(export.router)
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/stats")
def stats(db=None):
from database import SessionLocal
from sqlalchemy import func
from models import Report, Repair, RepairCost, Improvement
db = SessionLocal()
try:
total = db.query(Report).count()
pending = db.query(Report).filter(Report.status.in_(["pending","pending_approval"])).count()
in_prog = db.query(Report).filter(Report.status == "in_progress").count()
done = db.query(Report).filter(Report.status == "done").count()
cost_pend = db.query(RepairCost).filter(RepairCost.cost_status == "pending").count()
imp_open = db.query(Improvement).filter(
Improvement.status.in_(["registered","reviewing","developing"])).count()
return {
"total": total, "pending": pending,
"in_progress": in_prog, "done": done,
"cost_pending": cost_pend, "improvement_open": imp_open,
}
finally:
db.close()

168
backend/models.py Normal file
View File

@@ -0,0 +1,168 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, Float, Date, TIMESTAMP, ARRAY, ForeignKey, func
from sqlalchemy.orm import relationship
from database import Base
class ChargerType(Base):
__tablename__ = "charger_types"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
chargers = relationship("Charger", back_populates="charger_type")
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(20), nullable=False)
company = Column(String(100))
name = Column(String(50), nullable=False)
phone = Column(String(20))
email = Column(String(100))
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
class Charger(Base):
__tablename__ = "chargers"
id = Column(String(50), primary_key=True)
charger_type_id = Column(Integer, ForeignKey("charger_types.id"))
name = Column(String(100), nullable=False)
station_name = Column(String(100), nullable=False)
location_detail = Column(Text)
cpo_name = Column(String(100))
installed_at = Column(Date)
gps_lat = Column(Float)
gps_lng = Column(Float)
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
charger_type = relationship("ChargerType", back_populates="chargers")
reports = relationship("Report", back_populates="charger")
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True)
charger_id = Column(String(50), ForeignKey("chargers.id"))
issue_types = Column(ARRAY(Text), nullable=False)
issue_detail = Column(Text)
error_code = Column(String(100))
occurred_at = Column(TIMESTAMP)
contact = Column(String(20))
consent = Column(Boolean, default=False)
gps_lat = Column(Float)
gps_lng = Column(Float)
status = Column(String(30), default="pending")
reported_at = Column(TIMESTAMP, server_default=func.now())
charger = relationship("Charger", back_populates="reports")
photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan")
repair_links = relationship("RepairReport", back_populates="report")
class ReportPhoto(Base):
__tablename__ = "report_photos"
id = Column(Integer, primary_key=True)
report_id = Column(Integer, ForeignKey("reports.id", ondelete="CASCADE"))
file_path = Column(String(255), nullable=False)
uploaded_at = Column(TIMESTAMP, server_default=func.now())
report = relationship("Report", back_populates="photos")
class Repair(Base):
__tablename__ = "repairs"
id = Column(Integer, primary_key=True)
mechanic_id = Column(Integer, ForeignKey("users.id"))
repair_types = Column(ARRAY(Text), nullable=False)
description = Column(Text, nullable=False)
started_at = Column(TIMESTAMP, nullable=False)
completed_at = Column(TIMESTAMP)
result_status = Column(String(20), default="done")
mechanic = relationship("User", foreign_keys=[mechanic_id])
report_links = relationship("RepairReport", back_populates="repair", cascade="all, delete-orphan")
photos = relationship("RepairPhoto", back_populates="repair", cascade="all, delete-orphan")
cost = relationship("RepairCost", back_populates="repair", uselist=False)
class RepairReport(Base):
__tablename__ = "repair_reports"
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), primary_key=True)
report_id = Column(Integer, ForeignKey("reports.id", ondelete="CASCADE"), primary_key=True)
repair = relationship("Repair", back_populates="report_links")
report = relationship("Report", back_populates="repair_links")
class RepairPhoto(Base):
__tablename__ = "repair_photos"
id = Column(Integer, primary_key=True)
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"))
photo_type = Column(String(10), default="after")
file_path = Column(String(255), nullable=False)
uploaded_at = Column(TIMESTAMP, server_default=func.now())
repair = relationship("Repair", back_populates="photos")
class RepairCost(Base):
__tablename__ = "repair_costs"
id = Column(Integer, primary_key=True)
repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), unique=True)
root_cause = Column(Text)
admin_note = Column(Text)
cost_party_type = Column(String(20))
cost_party_manufacturer_id = Column(Integer, ForeignKey("users.id"))
cost_party_custom = Column(String(100))
cost_amount = Column(Integer, default=0)
cost_status = Column(String(20), default="pending")
reviewed_by = Column(Integer, ForeignKey("users.id"))
reviewed_at = Column(TIMESTAMP)
repair = relationship("Repair", back_populates="cost")
reviewer = relationship("User", foreign_keys=[reviewed_by])
manufacturer = relationship("User", foreign_keys=[cost_party_manufacturer_id])
class Improvement(Base):
__tablename__ = "improvements"
id = Column(Integer, primary_key=True)
title = Column(String(200), nullable=False)
category = Column(String(20), nullable=False)
description = Column(Text, nullable=False)
priority = Column(String(10), default="normal")
part_name = Column(String(100))
status = Column(String(20), default="registered")
manufacturer_id = Column(Integer, ForeignKey("users.id"))
created_by = Column(Integer, ForeignKey("users.id"))
sw_deploy_target = Column(Date)
sw_deployed_at = Column(Date)
manufacturer_memo = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
manufacturer = relationship("User", foreign_keys=[manufacturer_id])
creator = relationship("User", foreign_keys=[created_by])
report_links = relationship("ImprovementReport", back_populates="improvement", cascade="all, delete-orphan")
attachments = relationship("ImprovementAttachment", back_populates="improvement", cascade="all, delete-orphan")
logs = relationship("ImprovementLog", back_populates="improvement", cascade="all, delete-orphan")
class ImprovementReport(Base):
__tablename__ = "improvement_reports"
improvement_id = Column(Integer, ForeignKey("improvements.id", ondelete="CASCADE"), primary_key=True)
report_id = Column(Integer, ForeignKey("reports.id", ondelete="CASCADE"), primary_key=True)
improvement = relationship("Improvement", back_populates="report_links")
report = relationship("Report")
class ImprovementAttachment(Base):
__tablename__ = "improvement_attachments"
id = Column(Integer, primary_key=True)
improvement_id = Column(Integer, ForeignKey("improvements.id", ondelete="CASCADE"))
file_path = Column(String(255), nullable=False)
file_name = Column(String(255))
uploaded_at = Column(TIMESTAMP, server_default=func.now())
improvement = relationship("Improvement", back_populates="attachments")
class ImprovementLog(Base):
__tablename__ = "improvement_logs"
id = Column(Integer, primary_key=True)
improvement_id = Column(Integer, ForeignKey("improvements.id", ondelete="CASCADE"))
changed_by = Column(Integer, ForeignKey("users.id"))
old_status = Column(String(20))
new_status = Column(String(20))
memo = Column(Text)
changed_at = Column(TIMESTAMP, server_default=func.now())
improvement = relationship("Improvement", back_populates="logs")
changer = relationship("User")
class SystemSetting(Base):
__tablename__ = "system_settings"
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=False)
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())

13
backend/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
bcrypt==4.0.1
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
qrcode[pil]==7.4.2
Pillow==10.3.0
openpyxl==3.1.2
python-dotenv==1.0.1
pydantic[email]==2.7.1

View File

View File

@@ -0,0 +1,77 @@
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, hash_password, get_current_user
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
@router.get("")
def list_users(role: Optional[str] = None, db: Session = Depends(get_db), _=Depends(require_admin)):
q = db.query(models.User)
if role: q = q.filter(models.User.role == role)
return [{
"id": u.id, "username": u.username, "role": u.role,
"company": u.company, "name": u.name, "phone": u.phone,
"email": u.email, "is_active": u.is_active,
"created_at": u.created_at.isoformat(),
} for u in q.order_by(models.User.id).all()]
@router.post("")
def create_user(
username: str = Form(...), password: str = Form(...),
role: str = Form(...), name: str = Form(...),
company: str = Form(""), phone: str = Form(""), email: str = Form(""),
db: Session = Depends(get_db), _=Depends(require_admin)
):
if db.query(models.User).filter_by(username=username).first():
raise HTTPException(400, "이미 존재하는 아이디입니다.")
u = models.User(
username=username, password_hash=hash_password(password),
role=role, name=name, company=company or None,
phone=phone or None, email=email or None
)
db.add(u); db.commit(); db.refresh(u)
return {"id": u.id, "username": u.username}
@router.put("/{user_id}")
def update_user(
user_id: int,
name: str = Form(...), company: str = Form(""),
phone: str = Form(""), email: str = Form(""),
is_active: bool = Form(True),
password: Optional[str] = Form(None),
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.name = name; u.company = company or None
u.phone = phone or None; u.email = email or None
u.is_active = is_active
if password: u.password_hash = hash_password(password)
db.commit()
return {"ok": True}
@router.delete("/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)):
if user_id == current_user.id:
raise HTTPException(400, "자신의 계정은 삭제할 수 없습니다.")
u = db.query(models.User).filter_by(id=user_id).first()
if not u: raise HTTPException(404)
u.is_active = False; db.commit()
return {"ok": True}
@router.patch("/me/password")
def change_my_password(
current_password: str = Form(...), new_password: str = Form(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
from auth import verify_password
if not verify_password(current_password, current_user.password_hash):
raise HTTPException(400, "현재 비밀번호가 올바르지 않습니다.")
current_user.password_hash = hash_password(new_password)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from database import get_db
import models
from auth import verify_password, create_access_token, get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(models.User).filter(
models.User.username == form.username,
models.User.is_active == True
).first()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다.")
token = create_access_token({"sub": str(user.id)})
return {
"access_token": token,
"token_type": "bearer",
"role": user.role,
"name": user.name,
"user_id": user.id
}
@router.get("/me")
def me(current_user: models.User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"role": current_user.role,
"name": current_user.name,
"company": current_user.company,
}

126
backend/routers/chargers.py Normal file
View File

@@ -0,0 +1,126 @@
import os
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
import models
from auth import require_admin, get_current_user
from utils import generate_qr
router = APIRouter(prefix="/api/chargers", tags=["chargers"])
# ── 충전기 종류 ──────────────────────────────────────
@router.get("/types")
def list_types(db: Session = Depends(get_db)):
types = db.query(models.ChargerType).order_by(models.ChargerType.id).all()
return [{"id": t.id, "name": t.name, "description": t.description,
"charger_count": len(t.chargers)} for t in types]
@router.post("/types")
def create_type(name: str = Form(...), description: str = Form(""),
db: Session = Depends(get_db), _=Depends(require_admin)):
t = models.ChargerType(name=name, description=description)
db.add(t); db.commit(); db.refresh(t)
return {"id": t.id, "name": t.name}
@router.put("/types/{type_id}")
def update_type(type_id: int, name: str = Form(...), description: str = Form(""),
db: Session = Depends(get_db), _=Depends(require_admin)):
t = db.query(models.ChargerType).filter_by(id=type_id).first()
if not t: raise HTTPException(404, "종류를 찾을 수 없습니다.")
t.name = name; t.description = description
db.commit()
return {"id": t.id, "name": t.name}
@router.delete("/types/{type_id}")
def delete_type(type_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
t = db.query(models.ChargerType).filter_by(id=type_id).first()
if not t: raise HTTPException(404)
if t.chargers: raise HTTPException(400, "해당 종류로 등록된 충전기가 있어 삭제할 수 없습니다.")
db.delete(t); db.commit()
return {"ok": True}
# ── 충전기 ──────────────────────────────────────────
@router.get("")
def list_chargers(db: Session = Depends(get_db)):
chargers = db.query(models.Charger).order_by(models.Charger.id).all()
result = []
for c in chargers:
pending = db.query(models.Report).filter(
models.Report.charger_id == c.id,
models.Report.status.in_(["pending", "in_progress"])
).count()
result.append({
"id": c.id, "name": c.name, "station_name": c.station_name,
"cpo_name": c.cpo_name, "location_detail": c.location_detail,
"installed_at": str(c.installed_at) if c.installed_at else None,
"gps_lat": c.gps_lat, "gps_lng": c.gps_lng, "is_active": c.is_active,
"charger_type": c.charger_type.name if c.charger_type else None,
"charger_type_id": c.charger_type_id,
"pending_reports": pending,
})
return result
@router.get("/{charger_id}")
def get_charger(charger_id: str, db: Session = Depends(get_db)):
c = db.query(models.Charger).filter_by(id=charger_id).first()
if not c: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
return {
"id": c.id, "name": c.name, "station_name": c.station_name,
"cpo_name": c.cpo_name, "location_detail": c.location_detail,
"installed_at": str(c.installed_at) if c.installed_at else None,
"gps_lat": c.gps_lat, "gps_lng": c.gps_lng, "is_active": c.is_active,
"charger_type": c.charger_type.name if c.charger_type else None,
"charger_type_id": c.charger_type_id,
}
@router.post("")
def create_charger(
id: str = Form(...), charger_type_id: int = Form(...),
name: str = Form(...), station_name: str = Form(...),
location_detail: str = Form(""), cpo_name: str = Form(""),
installed_at: Optional[str] = Form(None),
gps_lat: Optional[float] = Form(None), gps_lng: Optional[float] = Form(None),
db: Session = Depends(get_db), _=Depends(require_admin)
):
if db.query(models.Charger).filter_by(id=id).first():
raise HTTPException(400, "이미 존재하는 충전기 ID입니다.")
c = models.Charger(
id=id, charger_type_id=charger_type_id, name=name,
station_name=station_name, location_detail=location_detail,
cpo_name=cpo_name, installed_at=installed_at or None,
gps_lat=gps_lat, gps_lng=gps_lng
)
db.add(c); db.commit()
domain = os.getenv("DOMAIN", "localhost")
qr_path = generate_qr(id, domain)
return {"id": c.id, "qr_path": qr_path}
@router.put("/{charger_id}")
def update_charger(
charger_id: str,
charger_type_id: int = Form(...), name: str = Form(...),
station_name: str = Form(...), location_detail: str = Form(""),
cpo_name: str = Form(""), installed_at: Optional[str] = Form(None),
gps_lat: Optional[float] = Form(None), gps_lng: Optional[float] = Form(None),
db: Session = Depends(get_db), _=Depends(require_admin)
):
c = db.query(models.Charger).filter_by(id=charger_id).first()
if not c: raise HTTPException(404)
c.charger_type_id = charger_type_id; c.name = name
c.station_name = station_name; c.location_detail = location_detail
c.cpo_name = cpo_name; c.installed_at = installed_at or None
c.gps_lat = gps_lat; c.gps_lng = gps_lng
db.commit()
domain = os.getenv("DOMAIN", "localhost")
qr_path = generate_qr(charger_id, domain)
return {"id": c.id, "qr_path": qr_path}
@router.post("/{charger_id}/qr")
def regenerate_qr(charger_id: str, db: Session = Depends(get_db), _=Depends(require_admin)):
c = db.query(models.Charger).filter_by(id=charger_id).first()
if not c: raise HTTPException(404)
domain = os.getenv("DOMAIN", "localhost")
qr_path = generate_qr(charger_id, domain)
return {"qr_path": qr_path}

97
backend/routers/costs.py Normal file
View File

@@ -0,0 +1,97 @@
from fastapi import APIRouter, Depends, HTTPException, Form
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import Optional
from datetime import datetime
from database import get_db
import models
from auth import require_admin
router = APIRouter(prefix="/api/costs", tags=["costs"])
@router.get("")
def list_costs(
cost_status: Optional[str] = None,
cost_party_type: Optional[str] = None,
db: Session = Depends(get_db), _=Depends(require_admin)
):
q = db.query(models.RepairCost).join(models.Repair)
if cost_status: q = q.filter(models.RepairCost.cost_status == cost_status)
if cost_party_type: q = q.filter(models.RepairCost.cost_party_type == cost_party_type)
q = q.order_by(desc(models.RepairCost.reviewed_at))
result = []
for cost in q.all():
repair = cost.repair
rids = [rr.report_id for rr in repair.report_links]
charger_id, station_name, charger_type = None, None, None
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r and r.charger:
charger_id = r.charger_id
station_name = r.charger.station_name
charger_type = r.charger.charger_type.name if r.charger.charger_type else None
result.append({
"id": cost.id, "repair_id": cost.repair_id,
"report_ids": rids, "charger_id": charger_id,
"station_name": station_name, "charger_type": charger_type,
"mechanic_name": repair.mechanic.name if repair.mechanic else None,
"mechanic_company": repair.mechanic.company if repair.mechanic else None,
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"root_cause": cost.root_cause, "admin_note": cost.admin_note,
"cost_party_type": cost.cost_party_type,
"cost_party_custom": cost.cost_party_custom,
"cost_amount": cost.cost_amount, "cost_status": cost.cost_status,
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
"reviewed_by_name": cost.reviewer.name if cost.reviewer else None,
"reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None,
})
return result
@router.get("/stats")
def cost_stats(db: Session = Depends(get_db), _=Depends(require_admin)):
from sqlalchemy import func, extract
now = datetime.now()
monthly = db.query(func.sum(models.RepairCost.cost_amount)).filter(
extract('year', models.RepairCost.reviewed_at) == now.year,
extract('month', models.RepairCost.reviewed_at) == now.month,
).scalar() or 0
pending = db.query(models.RepairCost).filter_by(cost_status="pending").count()
return {"monthly_total": monthly, "pending_count": pending}
@router.post("/repair/{repair_id}")
def upsert_cost(
repair_id: int,
root_cause: str = Form(""),
admin_note: str = Form(""),
cost_party_type: str = Form(...),
cost_party_manufacturer_id: Optional[int] = Form(None),
cost_party_custom: str = Form(""),
cost_amount: int = Form(0),
cost_status: str = Form("pending"),
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, "조치 내역을 찾을 수 없습니다.")
cost = db.query(models.RepairCost).filter_by(repair_id=repair_id).first()
if cost:
cost.root_cause = root_cause; cost.admin_note = admin_note
cost.cost_party_type = cost_party_type
cost.cost_party_manufacturer_id = cost_party_manufacturer_id or None
cost.cost_party_custom = cost_party_custom or None
cost.cost_amount = cost_amount; cost.cost_status = cost_status
cost.reviewed_by = current_user.id; cost.reviewed_at = datetime.now()
else:
cost = models.RepairCost(
repair_id=repair_id, root_cause=root_cause, admin_note=admin_note,
cost_party_type=cost_party_type,
cost_party_manufacturer_id=cost_party_manufacturer_id or None,
cost_party_custom=cost_party_custom or None,
cost_amount=cost_amount, cost_status=cost_status,
reviewed_by=current_user.id, reviewed_at=datetime.now()
)
db.add(cost)
db.commit()
return {"ok": True}

245
backend/routers/export.py Normal file
View File

@@ -0,0 +1,245 @@
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from io import BytesIO
from datetime import datetime
from urllib.parse import quote
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from database import get_db
import models
from auth import require_admin
router = APIRouter(prefix="/api/export", tags=["export"])
NAVY = "0B1E3D"
LIGHT = "D6EAF8"
def style_header(ws, headers, row=1):
bd = Side(style="thin", color="AAAAAA")
for col, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=h)
cell.font = Font(bold=True, color="FFFFFF", size=11)
cell.fill = PatternFill("solid", fgColor=NAVY)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
ws.row_dimensions[row].height = 20
def style_row(ws, row_num, num_cols, even=True):
bd = Side(style="thin", color="DDDDDD")
for col in range(1, num_cols + 1):
cell = ws.cell(row=row_num, column=col)
if even:
cell.fill = PatternFill("solid", fgColor="F4F7FB")
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
cell.alignment = Alignment(vertical="center", wrap_text=True)
def fmt_dt(dt):
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
def fmt_d(d):
return str(d) if d else ""
def elapsed(start, end):
if not start or not end: return ""
diff = end - start
total = int(diff.total_seconds())
h, m = divmod(total // 60, 60)
return f"{h}시간 {m}"
def make_response(wb: openpyxl.Workbook, korean_name: str) -> StreamingResponse:
"""엑셀 파일을 StreamingResponse로 반환 — 한글 파일명 URL 인코딩 처리"""
buf = BytesIO()
wb.save(buf)
buf.seek(0)
date_str = datetime.now().strftime("%Y%m%d_%H%M")
filename = f"{korean_name}_{date_str}.xlsx"
encoded = quote(filename, safe="") # 한글 URL 인코딩
cd_header = f"attachment; filename*=UTF-8''{encoded}"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": cd_header},
)
# ─────────────────────────────────────────────
# 1. AS 신고 목록
# ─────────────────────────────────────────────
@router.get("/reports")
def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "AS신고목록"
ws.freeze_panes = "A2"
headers = [
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
"신고자연락처","문제발생시각","신고일시","처리상태",
"담당정비사","정비사소속","조치유형","조치내용",
"조치시작","조치완료","작업소요시간","신고→완료소요시간",
"문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태",
"처리담당자","처리일시","연결개선항목번호"
]
style_header(ws, headers)
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,12,
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18]
for i, w in enumerate(col_widths, 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
reports = db.query(models.Report).order_by(desc(models.Report.reported_at)).all()
for row_num, r in enumerate(reports, 2):
c = r.charger
repair = r.repair_links[0].repair if r.repair_links else None
cost = repair.cost if repair else None
imp_ids = [
ir.improvement_id
for ir in db.query(models.ImprovementReport).filter_by(report_id=r.id).all()
]
row_data = [
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 "",
fmt_d(c.installed_at) if c else "",
r.gps_lat or "",
r.gps_lng or "",
", ".join(r.issue_types) if r.issue_types else "",
r.error_code or "",
r.issue_detail or "",
r.contact or "",
fmt_dt(r.occurred_at),
fmt_dt(r.reported_at),
r.status,
repair.mechanic.name if repair and repair.mechanic else "",
repair.mechanic.company if repair and repair.mechanic else "",
", ".join(repair.repair_types) if repair and repair.repair_types else "",
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 "",
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None),
cost.root_cause if cost else "",
cost.admin_note if cost else "",
cost.cost_party_type if cost else "",
cost.cost_amount if cost else "",
cost.cost_status if cost else "",
cost.reviewer.name if cost and cost.reviewer else "",
fmt_dt(cost.reviewed_at) if cost else "",
", ".join(str(i) for i in imp_ids) if imp_ids else "",
]
for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val)
style_row(ws, row_num, len(headers), row_num % 2 == 0)
ws.row_dimensions[row_num].height = 16
return make_response(wb, "AS신고목록")
# ─────────────────────────────────────────────
# 2. 출장비 목록
# ─────────────────────────────────────────────
@router.get("/costs")
def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "출장비목록"
ws.freeze_panes = "A2"
headers = [
"신고번호","충전기ID","충전기종류","충전소명","조치완료일",
"정비사","소속","문제원인","비고",
"출장비부담주체","제조사명","금액(원)","처리상태",
"처리담당자","처리일시"
]
style_header(ws, headers)
for i, w in enumerate([10,14,14,18,16,12,14,24,24,16,16,12,12,12,16], 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
costs = db.query(models.RepairCost).join(models.Repair).order_by(
desc(models.RepairCost.reviewed_at)).all()
for row_num, cost in enumerate(costs, 2):
repair = cost.repair
rids = [rr.report_id for rr in repair.report_links]
charger_id = station_name = charger_type = ""
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r and r.charger:
charger_id = r.charger_id
station_name = r.charger.station_name
charger_type = r.charger.charger_type.name if r.charger.charger_type else ""
row_data = [
", ".join(str(i) for i in rids),
charger_id, charger_type, station_name,
fmt_dt(repair.completed_at),
repair.mechanic.name if repair.mechanic else "",
repair.mechanic.company if repair.mechanic else "",
cost.root_cause or "",
cost.admin_note or "",
cost.cost_party_type or "",
cost.manufacturer.company if cost.manufacturer else (cost.cost_party_custom or ""),
cost.cost_amount or 0,
cost.cost_status or "",
cost.reviewer.name if cost.reviewer else "",
fmt_dt(cost.reviewed_at),
]
for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val)
style_row(ws, row_num, len(headers), row_num % 2 == 0)
ws.row_dimensions[row_num].height = 16
return make_response(wb, "출장비목록")
# ─────────────────────────────────────────────
# 3. 개선항목 목록
# ─────────────────────────────────────────────
@router.get("/improvements")
def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "개선항목목록"
ws.freeze_panes = "A2"
headers = [
"번호","제목","분류","우선순위","개선내용","관련부품",
"담당제조사","담당자","연락처","연결AS건수","연결AS번호",
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
"등록관리자","등록일시"
]
style_header(ws, headers)
for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,12,14,14,24,12,16], 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
imps = db.query(models.Improvement).order_by(desc(models.Improvement.created_at)).all()
for row_num, imp in enumerate(imps, 2):
rids = [ir.report_id for ir in imp.report_links]
row_data = [
imp.id, imp.title, imp.category, imp.priority,
imp.description, imp.part_name or "",
imp.manufacturer.company if imp.manufacturer else "",
imp.manufacturer.name if imp.manufacturer else "",
imp.manufacturer.phone if imp.manufacturer else "",
len(rids),
", ".join(str(i) for i in rids),
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),
]
for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val)
style_row(ws, row_num, len(headers), row_num % 2 == 0)
ws.row_dimensions[row_num].height = 16
return make_response(wb, "개선항목목록")

View File

@@ -0,0 +1,224 @@
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from io import BytesIO
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from database import get_db
import models
from auth import require_admin
router = APIRouter(prefix="/api/export", tags=["export"])
NAVY = "0B1E3D"
ACCENT = "00B4D8"
LIGHT = "D6EAF8"
def style_header(ws, headers, row=1):
for col, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=h)
cell.font = Font(bold=True, color="FFFFFF", size=11)
cell.fill = PatternFill("solid", fgColor=NAVY)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
bd = Side(style="thin", color="AAAAAA")
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
ws.row_dimensions[row].height = 20
def style_row(ws, row_num, num_cols, even=True):
bd = Side(style="thin", color="DDDDDD")
for col in range(1, num_cols + 1):
cell = ws.cell(row=row_num, column=col)
if even:
cell.fill = PatternFill("solid", fgColor="F4F7FB")
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
cell.alignment = Alignment(vertical="center", wrap_text=True)
def fmt_dt(dt):
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
def fmt_d(d):
return str(d) if d else ""
def elapsed(start, end):
if not start or not end: return ""
diff = end - start
total = int(diff.total_seconds())
h, m = divmod(total // 60, 60)
return f"{h}시간 {m}"
@router.get("/reports")
def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "AS신고목록"
ws.freeze_panes = "A2"
headers = [
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
"신고자연락처","문제발생시각","신고일시","처리상태",
"담당정비사","정비사소속","조치유형","조치내용",
"조치시작","조치완료","작업시간","신고→완료소요시간",
"문제원인(관리자)","비고","출장비부담주체","출장비금액","출장비상태",
"처리담당자","처리일시","연결개선항목번호"
]
style_header(ws, headers)
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,12,
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18]
for i, w in enumerate(col_widths, 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
reports = db.query(models.Report).order_by(desc(models.Report.reported_at)).all()
for row_num, r in enumerate(reports, 2):
c = r.charger
repair, cost = None, None
if r.repair_links:
repair = r.repair_links[0].repair
cost = repair.cost if repair else None
imp_ids = [ir.improvement_id for ir in
db.query(models.ImprovementReport).filter_by(report_id=r.id).all()]
row_data = [
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 "",
fmt_d(c.installed_at) if c else "",
r.gps_lat or "",
r.gps_lng or "",
", ".join(r.issue_types) if r.issue_types else "",
r.error_code or "",
r.issue_detail or "",
r.contact or "",
fmt_dt(r.occurred_at),
fmt_dt(r.reported_at),
r.status,
repair.mechanic.name if repair and repair.mechanic else "",
repair.mechanic.company if repair and repair.mechanic else "",
", ".join(repair.repair_types) if repair and repair.repair_types else "",
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 "",
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None),
cost.root_cause if cost else "",
cost.admin_note if cost else "",
cost.cost_party_type if cost else "",
cost.cost_amount if cost else "",
cost.cost_status if cost else "",
cost.reviewer.name if cost and cost.reviewer else "",
fmt_dt(cost.reviewed_at) if cost else "",
", ".join(str(i) for i in imp_ids) if imp_ids else "",
]
for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val)
style_row(ws, row_num, len(headers), row_num % 2 == 0)
ws.row_dimensions[row_num].height = 16
buf = BytesIO()
wb.save(buf)
buf.seek(0)
fname = f"AS신고목록_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
return StreamingResponse(buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{fname}"})
@router.get("/costs")
def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "출장비목록"
ws.freeze_panes = "A2"
headers = ["신고번호","충전기ID","충전기종류","충전소명","조치완료일",
"정비사","소속","문제원인","비고",
"출장비부담주체","제조사명","금액(원)","처리상태",
"처리담당자","처리일시"]
style_header(ws, headers)
for i, w in enumerate([10,14,14,18,16,12,14,24,24,16,16,12,12,12,16], 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
costs = db.query(models.RepairCost).join(models.Repair).order_by(
desc(models.RepairCost.reviewed_at)).all()
for row_num, cost in enumerate(costs, 2):
repair = cost.repair
rids = [rr.report_id for rr in repair.report_links]
charger_id, station_name, charger_type = "", "", ""
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r and r.charger:
charger_id = r.charger_id
station_name = r.charger.station_name
charger_type = r.charger.charger_type.name if r.charger.charger_type else ""
row_data = [
", ".join(str(i) for i in rids),
charger_id, charger_type, station_name,
fmt_dt(repair.completed_at),
repair.mechanic.name if repair.mechanic else "",
repair.mechanic.company if repair.mechanic else "",
cost.root_cause or "", cost.admin_note or "",
cost.cost_party_type or "",
cost.manufacturer.company if cost.manufacturer else (cost.cost_party_custom or ""),
cost.cost_amount or 0, cost.cost_status or "",
cost.reviewer.name if cost.reviewer else "",
fmt_dt(cost.reviewed_at),
]
for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val)
style_row(ws, row_num, len(headers), row_num % 2 == 0)
ws.row_dimensions[row_num].height = 16
buf = BytesIO()
wb.save(buf)
buf.seek(0)
fname = f"출장비목록_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
return StreamingResponse(buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{fname}"})
@router.get("/improvements")
def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "개선항목목록"
ws.freeze_panes = "A2"
headers = ["번호","제목","분류","우선순위","개선내용","관련부품",
"담당제조사","담당자","연락처","연결AS건수","연결AS번호",
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
"등록관리자","등록일시"]
style_header(ws, headers)
for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,12,14,14,24,12,16], 1):
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
imps = db.query(models.Improvement).order_by(desc(models.Improvement.created_at)).all()
for row_num, imp in enumerate(imps, 2):
rids = [ir.report_id for ir in imp.report_links]
row_data = [
imp.id, imp.title, imp.category, imp.priority, imp.description,
imp.part_name or "",
imp.manufacturer.company if imp.manufacturer else "",
imp.manufacturer.name if imp.manufacturer else "",
imp.manufacturer.phone if imp.manufacturer else "",
len(rids), ", ".join(str(i) for i in rids),
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),
]
for col, val in enumerate(row_data, 1):
ws.cell(row=row_num, column=col, value=val)
style_row(ws, row_num, len(headers), row_num % 2 == 0)
ws.row_dimensions[row_num].height = 16
buf = BytesIO()
wb.save(buf)
buf.seek(0)
fname = f"개선항목목록_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
return StreamingResponse(buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{fname}"})

View File

@@ -0,0 +1,108 @@
import json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from datetime import datetime
from database import get_db
import models
from auth import require_admin, require_manufacturer, get_current_user
from utils import save_upload
router = APIRouter(prefix="/api/improvements", tags=["improvements"])
def _fmt(imp: models.Improvement):
return {
"id": imp.id, "title": imp.title, "category": imp.category,
"description": imp.description, "priority": imp.priority,
"part_name": imp.part_name, "status": imp.status,
"manufacturer_id": imp.manufacturer_id,
"manufacturer_name": imp.manufacturer.name if imp.manufacturer else None,
"manufacturer_company": imp.manufacturer.company if imp.manufacturer else None,
"created_by_name": imp.creator.name if imp.creator else None,
"sw_deploy_target": str(imp.sw_deploy_target) if imp.sw_deploy_target else None,
"sw_deployed_at": str(imp.sw_deployed_at) if imp.sw_deployed_at else None,
"manufacturer_memo": imp.manufacturer_memo,
"created_at": imp.created_at.isoformat(),
"report_ids": [ir.report_id for ir in imp.report_links],
"report_count": len(imp.report_links),
"attachments": [{"path": a.file_path, "name": a.file_name} for a in imp.attachments],
"logs": [{"old": l.old_status, "new": l.new_status, "memo": l.memo,
"changed_at": l.changed_at.isoformat(),
"by": l.changer.name if l.changer else None} for l in imp.logs],
}
@router.get("")
def list_improvements(
status: Optional[str] = None, manufacturer_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
q = db.query(models.Improvement).order_by(desc(models.Improvement.created_at))
if current_user.role == "manufacturer":
q = q.filter(models.Improvement.manufacturer_id == current_user.id)
if status: q = q.filter(models.Improvement.status == status)
if manufacturer_id: q = q.filter(models.Improvement.manufacturer_id == manufacturer_id)
return [_fmt(imp) for imp in q.all()]
@router.get("/{imp_id}")
def get_improvement(imp_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
imp = db.query(models.Improvement).filter_by(id=imp_id).first()
if not imp: raise HTTPException(404)
if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id:
raise HTTPException(403)
return _fmt(imp)
@router.post("")
async def create_improvement(
title: str = Form(...), category: str = Form(...),
description: str = Form(...), priority: str = Form("normal"),
part_name: str = Form(""), manufacturer_id: int = Form(...),
report_ids: str = Form("[]"),
sw_deploy_target: Optional[str] = Form(None),
attachments: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)
):
imp = models.Improvement(
title=title, category=category, description=description,
priority=priority, part_name=part_name or None,
manufacturer_id=manufacturer_id, created_by=current_user.id,
sw_deploy_target=sw_deploy_target or None,
)
db.add(imp); db.commit(); db.refresh(imp)
for rid in json.loads(report_ids):
db.add(models.ImprovementReport(improvement_id=imp.id, report_id=int(rid)))
for f in attachments:
if f.filename:
path = save_upload(f, f"improvements/{imp.id}")
db.add(models.ImprovementAttachment(improvement_id=imp.id, file_path=path, file_name=f.filename))
db.add(models.ImprovementLog(improvement_id=imp.id, changed_by=current_user.id,
old_status=None, new_status="registered", memo="개선항목 등록"))
db.commit()
return {"id": imp.id}
@router.patch("/{imp_id}/status")
def update_status(
imp_id: int, status: str = Form(...), memo: str = Form(""),
sw_deployed_at: Optional[str] = Form(None),
manufacturer_memo: str = Form(""),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
imp = db.query(models.Improvement).filter_by(id=imp_id).first()
if not imp: raise HTTPException(404)
if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id:
raise HTTPException(403)
old_status = imp.status
imp.status = status
if sw_deployed_at: imp.sw_deployed_at = sw_deployed_at
if manufacturer_memo: imp.manufacturer_memo = manufacturer_memo
db.add(models.ImprovementLog(improvement_id=imp.id, changed_by=current_user.id,
old_status=old_status, new_status=status, memo=memo))
db.commit()
return {"ok": True}

118
backend/routers/repairs.py Normal file
View File

@@ -0,0 +1,118 @@
import json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from datetime import datetime
from database import get_db
import models
from auth import require_mechanic, get_current_user
from utils import save_upload
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
@router.get("/pending")
def pending_reports(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
"""정비사용: 처리 가능한 신고 목록 (pending / in_progress)"""
q = db.query(models.Report).filter(
models.Report.status.in_(["pending", "in_progress"])
).order_by(desc(models.Report.reported_at))
result = []
for r in q.all():
c = r.charger
result.append({
"id": r.id, "charger_id": r.charger_id,
"charger_name": c.name if c else None,
"station_name": c.station_name if c else None,
"charger_type": c.charger_type.name if c and c.charger_type else None,
"issue_types": r.issue_types, "status": r.status,
"reported_at": r.reported_at.isoformat(),
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
})
return result
@router.get("/charger/{charger_id}/open")
def open_reports_for_charger(charger_id: str, db: Session = Depends(get_db),
_=Depends(require_mechanic)):
"""특정 충전기의 미처리 신고 목록 (중복처리용)"""
reports = db.query(models.Report).filter(
models.Report.charger_id == charger_id,
models.Report.status.in_(["pending", "in_progress"])
).order_by(models.Report.reported_at).all()
return [{
"id": r.id, "issue_types": r.issue_types,
"issue_detail": r.issue_detail, "status": r.status,
"reported_at": r.reported_at.isoformat(),
"photos": [p.file_path for p in r.photos],
} for r in reports]
@router.post("")
async def create_repair(
report_ids: str = Form(...), # JSON 배열
repair_types: str = Form(...), # JSON 배열
description: str = Form(...),
result_status: str = Form("done"),
photos_before: List[UploadFile] = File(default=[]),
photos_after: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)
):
rids = json.loads(report_ids)
rtypes = json.loads(repair_types)
repair = models.Repair(
mechanic_id=current_user.id,
repair_types=rtypes,
description=description,
started_at=datetime.now(),
completed_at=datetime.now(),
result_status=result_status,
)
db.add(repair); db.commit(); db.refresh(repair)
# 신고 연결 및 상태 업데이트
for rid in rids:
r = db.query(models.Report).filter_by(id=rid).first()
if r:
new_status = "done" if result_status == "done" else (
"waiting" if result_status == "waiting" else "revisit"
)
r.status = new_status
db.add(models.RepairReport(repair_id=repair.id, report_id=rid))
# 사진 저장
for photo in photos_before:
if photo.filename:
path = save_upload(photo, f"repairs/{repair.id}")
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="before", file_path=path))
for photo in photos_after:
if photo.filename:
path = save_upload(photo, f"repairs/{repair.id}")
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="after", file_path=path))
db.commit()
return {"id": repair.id}
@router.get("/my")
def my_repairs(db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)):
repairs = db.query(models.Repair).filter_by(
mechanic_id=current_user.id
).order_by(desc(models.Repair.completed_at)).limit(50).all()
result = []
for repair in repairs:
rids = [rr.report_id for rr in repair.report_links]
charger_id = None
if rids:
r = db.query(models.Report).filter_by(id=rids[0]).first()
if r: charger_id = r.charger_id
result.append({
"id": repair.id, "charger_id": charger_id,
"repair_types": repair.repair_types,
"result_status": repair.result_status,
"started_at": repair.started_at.isoformat(),
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"report_count": len(rids),
})
return result

183
backend/routers/reports.py Normal file
View File

@@ -0,0 +1,183 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from datetime import datetime
from database import get_db
import models
from auth import require_admin, get_current_user
from utils import save_upload
router = APIRouter(prefix="/api/reports", tags=["reports"])
def _fmt_report(r: models.Report, db: Session):
c = r.charger
repair_id = None
if r.repair_links:
repair_id = r.repair_links[0].repair_id
return {
"id": r.id, "charger_id": r.charger_id,
"charger_name": c.name if c else None,
"station_name": c.station_name if c else None,
"cpo_name": c.cpo_name if c else None,
"charger_type": c.charger_type.name if c and c.charger_type else None,
"installed_at": str(c.installed_at) if c and c.installed_at else None,
"issue_types": r.issue_types, "issue_detail": r.issue_detail,
"error_code": r.error_code, "contact": r.contact,
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
"reported_at": r.reported_at.isoformat() if r.reported_at else None,
"gps_lat": r.gps_lat, "gps_lng": r.gps_lng,
"status": r.status,
"photos": [p.file_path for p in r.photos],
"repair_id": repair_id,
}
@router.post("")
async def create_report(
charger_id: str = Form(...),
issue_types: str = Form(...), # JSON 배열 문자열
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),
photos: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db)
):
import json
charger = db.query(models.Charger).filter_by(id=charger_id).first()
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
# 신고 공개 정책 확인
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
r = models.Report(
charger_id=charger_id, issue_types=issue_list,
issue_detail=issue_detail or None, error_code=error_code or None,
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
contact=contact or None, consent=consent,
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status
)
db.add(r); db.commit(); db.refresh(r)
for photo in photos:
if photo.filename:
path = save_upload(photo, f"reports/{r.id}")
db.add(models.ReportPhoto(report_id=r.id, file_path=path))
db.commit()
return {"id": r.id, "status": r.status}
@router.get("")
def list_reports(
status: Optional[str] = None,
charger_id: Optional[str] = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
q = db.query(models.Report).order_by(desc(models.Report.reported_at))
if status: q = q.filter(models.Report.status == status)
if charger_id: q = q.filter(models.Report.charger_id == charger_id)
# 정비사는 공개된 것만 (승인 대기 제외)
if current_user.role == "mechanic":
q = q.filter(models.Report.status != "pending_approval")
return [_fmt_report(r, db) for r in q.all()]
@router.get("/{report_id}")
def get_report(report_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
result = _fmt_report(r, db)
# 수리 정보 포함
if r.repair_links:
repair = r.repair_links[0].repair
cost = repair.cost
result["repair"] = {
"id": repair.id,
"mechanic_name": repair.mechanic.name if repair.mechanic else None,
"mechanic_company": repair.mechanic.company if repair.mechanic else None,
"repair_types": repair.repair_types,
"description": repair.description,
"started_at": repair.started_at.isoformat(),
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"result_status": repair.result_status,
"photos_before": [p.file_path for p in repair.photos if p.photo_type == "before"],
"photos_after": [p.file_path for p in repair.photos if p.photo_type == "after"],
"cost": {
"root_cause": cost.root_cause,
"admin_note": cost.admin_note,
"cost_party_type": cost.cost_party_type,
"cost_party_custom": cost.cost_party_custom,
"cost_amount": cost.cost_amount,
"cost_status": cost.cost_status,
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
} if cost else None
}
return result
@router.patch("/{report_id}/approve")
def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
r.status = "pending"; db.commit()
return {"ok": True}
@router.patch("/{report_id}/status")
def update_status(report_id: int, status: str = Form(...),
db: Session = Depends(get_db), _=Depends(require_admin)):
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
r.status = status; db.commit()
return {"ok": True}
# ── 공개 엔드포인트 — 인증 없이 특정 충전기의 진행 중 신고 조회 ──
# QR 신고 페이지에서 기존 접수 현황을 사용자에게 보여줄 때 사용
@router.get("/public/{charger_id}")
def public_charger_reports(charger_id: str, db: Session = Depends(get_db)):
"""
해당 충전기에서 아직 해결되지 않은 신고 목록을 반환.
완료(done) / 면제 · 정산 상태는 제외하고 진행 중인 것만 반환.
개인정보(연락처) 는 반환하지 않음.
"""
active_statuses = ["pending_approval", "pending", "in_progress", "waiting", "revisit"]
rows = (
db.query(models.Report)
.filter(
models.Report.charger_id == charger_id,
models.Report.status.in_(active_statuses),
)
.order_by(models.Report.reported_at.desc())
.limit(20)
.all()
)
STATUS_LABEL = {
"pending_approval": "검토 대기",
"pending": "접수 완료",
"in_progress": "처리 중",
"waiting": "부품 대기",
"revisit": "재방문 예정",
}
result = []
for r in rows:
repair = r.repair_links[0].repair if r.repair_links else None
result.append({
"id": r.id,
"issue_types": r.issue_types,
"issue_detail": r.issue_detail or "",
"status": r.status,
"status_label": STATUS_LABEL.get(r.status, r.status),
"reported_at": r.reported_at.isoformat() if r.reported_at else "",
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else "",
"photo_count": len(r.photos),
"mechanic_name": repair.mechanic.name if repair and repair.mechanic else None,
"started_at": repair.started_at.isoformat() if repair and repair.started_at else None,
})
return result

View File

@@ -0,0 +1,136 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from datetime import datetime
from database import get_db
import models
from auth import require_admin, get_current_user
from utils import save_upload
router = APIRouter(prefix="/api/reports", tags=["reports"])
def _fmt_report(r: models.Report, db: Session):
c = r.charger
repair_id = None
if r.repair_links:
repair_id = r.repair_links[0].repair_id
return {
"id": r.id, "charger_id": r.charger_id,
"charger_name": c.name if c else None,
"station_name": c.station_name if c else None,
"cpo_name": c.cpo_name if c else None,
"charger_type": c.charger_type.name if c and c.charger_type else None,
"installed_at": str(c.installed_at) if c and c.installed_at else None,
"issue_types": r.issue_types, "issue_detail": r.issue_detail,
"error_code": r.error_code, "contact": r.contact,
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
"reported_at": r.reported_at.isoformat() if r.reported_at else None,
"gps_lat": r.gps_lat, "gps_lng": r.gps_lng,
"status": r.status,
"photos": [p.file_path for p in r.photos],
"repair_id": repair_id,
}
@router.post("")
async def create_report(
charger_id: str = Form(...),
issue_types: str = Form(...), # JSON 배열 문자열
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),
photos: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db)
):
import json
charger = db.query(models.Charger).filter_by(id=charger_id).first()
if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
# 신고 공개 정책 확인
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
r = models.Report(
charger_id=charger_id, issue_types=issue_list,
issue_detail=issue_detail or None, error_code=error_code or None,
occurred_at=datetime.fromisoformat(occurred_at) if occurred_at else None,
contact=contact or None, consent=consent,
gps_lat=gps_lat, gps_lng=gps_lng, status=initial_status
)
db.add(r); db.commit(); db.refresh(r)
for photo in photos:
if photo.filename:
path = save_upload(photo, f"reports/{r.id}")
db.add(models.ReportPhoto(report_id=r.id, file_path=path))
db.commit()
return {"id": r.id, "status": r.status}
@router.get("")
def list_reports(
status: Optional[str] = None,
charger_id: Optional[str] = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
q = db.query(models.Report).order_by(desc(models.Report.reported_at))
if status: q = q.filter(models.Report.status == status)
if charger_id: q = q.filter(models.Report.charger_id == charger_id)
# 정비사는 공개된 것만 (승인 대기 제외)
if current_user.role == "mechanic":
q = q.filter(models.Report.status != "pending_approval")
return [_fmt_report(r, db) for r in q.all()]
@router.get("/{report_id}")
def get_report(report_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
result = _fmt_report(r, db)
# 수리 정보 포함
if r.repair_links:
repair = r.repair_links[0].repair
cost = repair.cost
result["repair"] = {
"id": repair.id,
"mechanic_name": repair.mechanic.name if repair.mechanic else None,
"mechanic_company": repair.mechanic.company if repair.mechanic else None,
"repair_types": repair.repair_types,
"description": repair.description,
"started_at": repair.started_at.isoformat(),
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"result_status": repair.result_status,
"photos_before": [p.file_path for p in repair.photos if p.photo_type == "before"],
"photos_after": [p.file_path for p in repair.photos if p.photo_type == "after"],
"cost": {
"root_cause": cost.root_cause,
"admin_note": cost.admin_note,
"cost_party_type": cost.cost_party_type,
"cost_party_custom": cost.cost_party_custom,
"cost_amount": cost.cost_amount,
"cost_status": cost.cost_status,
"manufacturer_name": cost.manufacturer.name if cost.manufacturer else None,
} if cost else None
}
return result
@router.patch("/{report_id}/approve")
def approve_report(report_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
r.status = "pending"; db.commit()
return {"ok": True}
@router.patch("/{report_id}/status")
def update_status(report_id: int, status: str = Form(...),
db: Session = Depends(get_db), _=Depends(require_admin)):
r = db.query(models.Report).filter_by(id=report_id).first()
if not r: raise HTTPException(404)
r.status = status; db.commit()
return {"ok": True}

View File

@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, Form
from sqlalchemy.orm import Session
from datetime import datetime
from typing import Optional
from database import get_db
import models
from auth import require_admin
router = APIRouter(prefix="/api/settings", tags=["settings"])
# 이미지 설정 기본값
IMAGE_DEFAULTS = {
"image_compress_enabled": "true",
"image_max_px": "1024",
"image_quality": "85",
}
def upsert(db, key, value):
s = db.query(models.SystemSetting).filter_by(key=key).first()
if s:
s.value = value
s.updated_at = datetime.now()
else:
db.add(models.SystemSetting(key=key, value=value))
# ── 공개 엔드포인트: 이미지 설정만 반환 (인증 불필요 — 신고 페이지에서 사용)
@router.get("/public")
def get_public_settings(db: Session = Depends(get_db)):
rows = db.query(models.SystemSetting).filter(
models.SystemSetting.key.in_(IMAGE_DEFAULTS.keys())
).all()
result = dict(IMAGE_DEFAULTS) # 기본값으로 채운 뒤
for r in rows:
result[r.key] = r.value # DB 값으로 덮어쓰기
return {
"image_compress_enabled": result["image_compress_enabled"] == "true",
"image_max_px": int(result["image_max_px"]),
"image_quality": int(result["image_quality"]),
}
# ── 관리자 전체 설정 조회
@router.get("")
def get_settings(db: Session = Depends(get_db), _=Depends(require_admin)):
rows = db.query(models.SystemSetting).all()
result = dict(IMAGE_DEFAULTS)
for r in rows:
result[r.key] = r.value
return result
# ── 관리자 설정 저장 (신고공개정책 + 이미지설정 통합)
@router.put("")
def update_settings(
report_visibility_policy: str = Form(...),
image_compress_enabled: str = Form("true"),
image_max_px: str = Form("1024"),
image_quality: str = Form("85"),
db: Session = Depends(get_db),
_ = Depends(require_admin)
):
pairs = [
("report_visibility_policy", report_visibility_policy),
("image_compress_enabled", image_compress_enabled),
("image_max_px", image_max_px),
("image_quality", image_quality),
]
for key, val in pairs:
upsert(db, key, val)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends, Form
from sqlalchemy.orm import Session
from datetime import datetime
from database import get_db
import models
from auth import require_admin
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("")
def get_settings(db: Session = Depends(get_db), _=Depends(require_admin)):
settings = db.query(models.SystemSetting).all()
return {s.key: s.value for s in settings}
@router.put("")
def update_settings(
report_visibility_policy: str = Form(...),
db: Session = Depends(get_db),
_=Depends(require_admin)
):
for key, value in [("report_visibility_policy", report_visibility_policy)]:
s = db.query(models.SystemSetting).filter_by(key=key).first()
if s:
s.value = value
s.updated_at = datetime.now()
else:
db.add(models.SystemSetting(key=key, value=value))
db.commit()
return {"ok": True}

29
backend/utils.py Normal file
View File

@@ -0,0 +1,29 @@
import os, uuid, qrcode
from PIL import Image
from fastapi import UploadFile
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/uploads")
def save_upload(file: UploadFile, sub_dir: str = "general") -> str:
"""파일을 저장하고 /uploads 기준 상대 경로 반환"""
ext = os.path.splitext(file.filename or "file")[1].lower() or ".jpg"
folder = os.path.join(UPLOAD_DIR, sub_dir)
os.makedirs(folder, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(folder, filename)
with open(filepath, "wb") as f:
f.write(file.file.read())
return f"/uploads/{sub_dir}/{filename}"
def generate_qr(charger_id: str, domain: str) -> str:
"""QR 이미지를 저장하고 /uploads 기준 경로 반환"""
url = f"https://{domain}/report/{charger_id}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
folder = os.path.join(UPLOAD_DIR, "qr")
os.makedirs(folder, exist_ok=True)
filepath = os.path.join(folder, f"{charger_id}.png")
img.save(filepath)
return f"/uploads/qr/{charger_id}.png"

56
docker-compose.yml Normal file
View File

@@ -0,0 +1,56 @@
version: '3.8'
# -------------------------------------------------------
# Orange Pi 5 전용 구성
# SSL/DNS/인증서는 외부 서버(5825u)에서 처리
# 이 서버는 192.168.0.114:5700 으로만 서비스
# -------------------------------------------------------
services:
nginx:
image: nginx:alpine
container_name: ev_nginx
ports:
- "5700:80" # 외부 접근 포트: 192.168.0.114:5700
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./frontend/static:/var/www/html:ro
- ./uploads:/var/www/uploads:ro
depends_on:
- backend
restart: unless-stopped
backend:
build: ./backend
container_name: ev_backend
environment:
- DATABASE_URL=${DATABASE_URL}
- SECRET_KEY=${SECRET_KEY}
- UPLOAD_DIR=/uploads
- DOMAIN=${DOMAIN}
volumes:
- ./uploads:/uploads
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: ev_db
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
volumes:
postgres_data:

View File

@@ -0,0 +1,148 @@
:root {
--navy: #0B1E3D;
--navy2: #132848;
--blue: #1565C0;
--accent: #00B4D8;
--green: #00C896;
--orange: #FF8C00;
--red: #E53935;
--gray1: #F4F7FB;
--gray2: #E8EDF5;
--gray3: #C5CFE0;
--gray4: #8899BB;
--text: #1A2B4A;
--white: #FFFFFF;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Noto Sans KR',sans-serif;background:var(--gray1);color:var(--text);font-size:14px;min-height:100vh;}
/* ── NAV ── */
.nav{background:var(--navy);color:white;padding:0 24px;height:54px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.3);}
.nav-brand{font-size:16px;font-weight:700;color:var(--accent);}
.nav-user{font-size:13px;color:rgba(255,255,255,.7);display:flex;align-items:center;gap:12px;}
.nav-user a{color:rgba(255,255,255,.7);text-decoration:none;cursor:pointer;}
.nav-user a:hover{color:white;}
/* ── SIDEBAR (admin/mechanic) ── */
.layout{display:flex;min-height:calc(100vh - 54px);}
.sidebar{background:var(--navy2);width:200px;flex-shrink:0;padding:16px 0;}
.sidebar a{display:block;padding:10px 20px;color:rgba(255,255,255,.7);text-decoration:none;font-size:13px;border-left:3px solid transparent;transition:all .15s;}
.sidebar a:hover,.sidebar a.active{color:white;background:rgba(255,255,255,.06);border-left-color:var(--accent);}
.sidebar-section{font-size:10px;letter-spacing:2px;color:rgba(255,255,255,.3);padding:14px 20px 6px;text-transform:uppercase;}
.main{flex:1;padding:28px;overflow-x:auto;}
/* ── CARD ── */
.card{background:white;border-radius:10px;padding:22px;box-shadow:0 2px 8px rgba(0,0,0,.06);margin-bottom:20px;}
.card-title{font-size:15px;font-weight:700;color:var(--navy);border-left:4px solid var(--accent);padding-left:10px;margin-bottom:16px;}
/* ── STAT CARDS ── */
.stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-bottom:24px;}
.stat{background:white;border-radius:10px;padding:18px;box-shadow:0 2px 8px rgba(0,0,0,.06);text-align:center;}
.stat-num{font-size:32px;font-weight:900;color:var(--navy);}
.stat-label{font-size:12px;color:var(--gray4);margin-top:4px;}
.stat.warn .stat-num{color:var(--orange);}
.stat.danger .stat-num{color:var(--red);}
.stat.good .stat-num{color:var(--green);}
/* ── TABLE ── */
.tbl-wrap{overflow-x:auto;border-radius:8px;box-shadow:0 2px 6px rgba(0,0,0,.06);}
table{width:100%;border-collapse:collapse;font-size:13px;background:white;}
thead tr{background:var(--navy);color:white;}
th{padding:10px 12px;text-align:left;font-weight:600;font-size:12px;white-space:nowrap;}
td{padding:9px 12px;border-bottom:1px solid var(--gray2);vertical-align:middle;}
tr:nth-child(even) td{background:var(--gray1);}
tr:hover td{background:#EBF5FF;cursor:pointer;}
.no-hover:hover td{background:inherit;cursor:default;}
/* ── FORMS ── */
.form-group{margin-bottom:14px;}
.form-group label{display:block;font-size:12px;font-weight:600;color:var(--navy2);margin-bottom:5px;}
.form-group label .req{color:var(--red);}
input[type=text],input[type=password],input[type=number],input[type=tel],
input[type=date],input[type=datetime-local],select,textarea{
width:100%;padding:9px 12px;border:1px solid var(--gray3);border-radius:6px;
font-size:13px;font-family:inherit;color:var(--text);background:white;
transition:border-color .15s;}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);}
textarea{resize:vertical;min-height:80px;}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.form-row-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;}
/* ── CHECKBOXES ── */
.check-group{display:flex;flex-wrap:wrap;gap:8px;}
.check-item{display:flex;align-items:center;gap:6px;padding:7px 12px;background:var(--gray1);
border:1px solid var(--gray3);border-radius:6px;cursor:pointer;font-size:13px;}
.check-item input{accent-color:var(--accent);}
.check-item:has(input:checked){background:#E3EDFF;border-color:var(--accent);}
/* ── BUTTONS ── */
.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border-radius:7px;
font-size:13px;font-weight:600;cursor:pointer;border:none;transition:all .15s;text-decoration:none;}
.btn-primary{background:var(--blue);color:white;}
.btn-primary:hover{background:#1251A3;}
.btn-accent{background:var(--accent);color:white;}
.btn-accent:hover{background:#009BBF;}
.btn-success{background:var(--green);color:white;}
.btn-danger{background:var(--red);color:white;}
.btn-outline{background:white;color:var(--navy);border:1px solid var(--gray3);}
.btn-outline:hover{background:var(--gray1);}
.btn-sm{padding:5px 12px;font-size:12px;}
.btn-lg{padding:13px 28px;font-size:15px;width:100%;}
/* ── BADGE / STATUS ── */
.badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:700;}
.s-pending_approval{background:#FFF3CD;color:#856404;}
.s-pending{background:#DBEAFE;color:#1565C0;}
.s-in_progress{background:#FEF3C7;color:#B45309;}
.s-done{background:#D1FAE5;color:#065F46;}
.s-waiting{background:#FFE4E4;color:#C0392B;}
.s-revisit{background:#EDE9FE;color:#5B21B6;}
.s-registered{background:#E0F7F1;color:#00875A;}
.s-reviewing{background:#DBEAFE;color:#1565C0;}
.s-developing{background:#FEF3C7;color:#B45309;}
.s-deployed{background:#D1FAE5;color:#065F46;}
.s-cost-pending{background:#FFF3CD;color:#856404;}
.s-cost-billed{background:#DBEAFE;color:#1565C0;}
.s-cost-waived{background:#F0F0F0;color:#555;}
.s-cost-settled{background:#D1FAE5;color:#065F46;}
/* ── ALERTS ── */
.alert{padding:12px 16px;border-radius:6px;margin-bottom:14px;font-size:13px;}
.alert-info{background:#E3EDFF;border-left:4px solid var(--accent);color:var(--navy);}
.alert-warn{background:#FFF8E6;border-left:4px solid var(--orange);color:#5A4000;}
.alert-success{background:#E8F8F2;border-left:4px solid var(--green);color:#00531A;}
.alert-danger{background:#FFE4E4;border-left:4px solid var(--red);color:#7A0000;}
/* ── PHOTO PREVIEW ── */
.photo-preview{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;}
.photo-preview img{width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);}
/* ── MODAL ── */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:200;display:flex;align-items:center;justify-content:center;padding:20px;}
.modal{background:white;border-radius:12px;padding:28px;max-width:600px;width:100%;max-height:90vh;overflow-y:auto;}
.modal-title{font-size:17px;font-weight:700;color:var(--navy);margin-bottom:18px;}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px;}
.hidden{display:none!important;}
/* ── PHOTO UPLOAD ── */
.upload-area{border:2px dashed var(--gray3);border-radius:8px;padding:18px;text-align:center;cursor:pointer;color:var(--gray4);transition:border-color .15s;}
.upload-area:hover{border-color:var(--accent);}
/* ── TIMELINE ── */
.timeline{padding-left:16px;border-left:2px solid var(--gray2);}
.tl-item{position:relative;padding:0 0 16px 16px;}
.tl-item::before{content:'';position:absolute;left:-6px;top:4px;width:10px;height:10px;border-radius:50%;background:var(--accent);}
.tl-time{font-size:11px;color:var(--gray4);margin-bottom:2px;}
.tl-text{font-size:13px;}
/* ── LOADING ── */
.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);}}
/* ── RESPONSIVE ── */
@media(max-width:768px){
.form-row,.form-row-3{grid-template-columns:1fr;}
.sidebar{display:none;}
.main{padding:16px;}
.stats{grid-template-columns:repeat(2,1fr);}
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EV 충전기 AS 관리</title>
<script>
const role = localStorage.getItem('ev_role');
const token = localStorage.getItem('ev_token');
if (!token) location.href = '/pages/login.html';
else if (role === 'admin') location.href = '/pages/admin/dashboard.html';
else if (role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else if (role === 'manufacturer') location.href = '/pages/manufacturer/dashboard.html';
else location.href = '/pages/login.html';
</script>
</head>
<body></body>
</html>

72
frontend/static/js/api.js Normal file
View File

@@ -0,0 +1,72 @@
const API = (() => {
const BASE = '/api';
function token() { return localStorage.getItem('ev_token') || ''; }
async function req(method, path, body = null, isForm = false) {
const headers = {};
if (token()) headers['Authorization'] = 'Bearer ' + token();
let fetchBody = null;
if (body) {
if (isForm) {
fetchBody = body;
} else {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
}
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
if (res.status === 401) { Auth.logout(); return; }
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
throw new Error(err.detail || '오류');
}
const ct = res.headers.get('content-type') || '';
if (ct.includes('spreadsheet') || ct.includes('octet') || ct.includes('excel')) {
return res.blob();
}
return res.json().catch(() => ({}));
}
// 엑셀 다운로드 전용 함수 — 인증 토큰 포함, 에러 처리 강화
async function download(path, filename) {
try {
const headers = {};
if (token()) headers['Authorization'] = 'Bearer ' + token();
const res = await fetch(BASE + path, { method: 'GET', headers });
if (res.status === 401) { Auth.logout(); return; }
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '다운로드 오류' }));
throw new Error(err.detail || '다운로드 오류');
}
const blob = await res.blob();
if (!blob || blob.size === 0) {
alert('데이터가 없어 엑셀 파일을 생성할 수 없습니다.');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch(e) {
alert('엑셀 다운로드 실패: ' + e.message);
}
}
return {
get: (path) => req('GET', path),
post: (path, body) => req('POST', path, body, body instanceof FormData),
put: (path, body) => req('PUT', path, body, body instanceof FormData),
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
delete: (path) => req('DELETE', path),
download,
};
})();

View File

@@ -0,0 +1,42 @@
const API = (() => {
const BASE = '/api';
function token() { return localStorage.getItem('ev_token') || ''; }
async function req(method, path, body = null, isForm = false) {
const headers = {};
if (token()) headers['Authorization'] = 'Bearer ' + token();
let fetchBody = null;
if (body) {
if (isForm) {
fetchBody = body; // FormData
} else {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
}
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
if (res.status === 401) { Auth.logout(); return; }
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
throw new Error(err.detail || '오류');
}
const ct = res.headers.get('content-type') || '';
if (ct.includes('spreadsheet') || ct.includes('octet')) return res.blob();
return res.json().catch(() => ({}));
}
return {
get: (path) => req('GET', path),
post: (path, body) => req('POST', path, body, body instanceof FormData),
put: (path, body) => req('PUT', path, body, body instanceof FormData),
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
delete: (path) => req('DELETE', path),
download: async (path, filename) => {
const blob = await req('GET', path);
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename;
a.click(); URL.revokeObjectURL(url);
}
};
})();

View File

@@ -0,0 +1,63 @@
const Auth = (() => {
const KEY_TOKEN = 'ev_token';
const KEY_ROLE = 'ev_role';
const KEY_NAME = 'ev_name';
const KEY_ID = 'ev_uid';
function save(token, role, name, id) {
localStorage.setItem(KEY_TOKEN, token);
localStorage.setItem(KEY_ROLE, role);
localStorage.setItem(KEY_NAME, name);
localStorage.setItem(KEY_ID, id);
}
function token() { return localStorage.getItem(KEY_TOKEN); }
function role() { return localStorage.getItem(KEY_ROLE); }
function name() { return localStorage.getItem(KEY_NAME); }
function uid() { return localStorage.getItem(KEY_ID); }
function logout() {
[KEY_TOKEN, KEY_ROLE, KEY_NAME, KEY_ID].forEach(k => localStorage.removeItem(k));
location.href = '/pages/login.html';
}
function require(allowedRoles) {
if (!token()) { logout(); return false; }
if (allowedRoles && !allowedRoles.includes(role())) {
alert('접근 권한이 없습니다.');
history.back();
return false;
}
return true;
}
function renderNav(el) {
if (!el) return;
el.innerHTML = `
<span class="nav-user">
<span>${name()} <small style="color:var(--accent)">[${role()}]</small></span>
<a onclick="Auth.logout()">로그아웃</a>
</span>`;
}
function statusBadge(status) {
const map = {
pending_approval: '승인대기', pending: '접수', in_progress: '처리중',
done: '완료', waiting: '부품대기', revisit: '재방문',
registered: '등록', reviewing: '검토중', developing: '개발중',
deployed: '배포완료',
};
return `<span class="badge s-${status}">${map[status] || status}</span>`;
}
function costStatusBadge(s) {
const map = { pending:'미처리', billed:'청구완료', waived:'면제', settled:'정산완료' };
return `<span class="badge s-cost-${s}">${map[s] || s}</span>`;
}
function fmtDt(dt) {
if (!dt) return '-';
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 };
})();

View File

@@ -0,0 +1,174 @@
/**
* imageCompress.js
* 업로드 전 브라우저에서 이미지를 리사이즈 + JPEG 압축
* Canvas API 사용 — 외부 라이브러리 불필요
*/
const ImageCompressor = (() => {
// 서버에서 가져온 설정 캐시
let _cfg = null;
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */
async function loadConfig() {
if (_cfg) return _cfg;
try {
const res = await fetch('/api/settings/public');
_cfg = await res.json();
} catch {
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
}
return _cfg;
}
/**
* File 객체 하나를 압축해서 새 File 로 반환
* @param {File} file - 원본 이미지 파일
* @param {Object} cfg - { image_compress_enabled, image_max_px, image_quality }
* @returns {Promise<File>}
*/
function compressOne(file, cfg) {
return new Promise((resolve) => {
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
return resolve(file);
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const maxPx = cfg.image_max_px;
let { width, height } = img;
// 긴 변이 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');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const compressed = new File(
[blob],
file.name.replace(/\.[^.]+$/, '') + '.jpg',
{ type: 'image/jpeg', lastModified: Date.now() }
);
resolve(compressed);
},
'image/jpeg',
cfg.image_quality / 100 // 0~1 범위
);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
/**
* FileList / File[] 전체를 압축
* @param {FileList|File[]} files
* @returns {Promise<File[]>}
*/
async function compressAll(files) {
const cfg = await loadConfig();
return Promise.all(Array.from(files).map(f => compressOne(f, cfg)));
}
/**
* input[type=file] + 미리보기 div 에 압축 프리뷰 설정
* @param {string} inputId - file input 요소 id
* @param {string} previewId - 미리보기 컨테이너 id
* @param {string} infoId - 용량 정보 표시 span id (선택)
*/
function setupPreview(inputId, previewId, infoId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
const info = infoId ? document.getElementById(infoId) : null;
if (!input || !preview) return;
input.addEventListener('change', async function () {
preview.innerHTML = '';
if (info) info.textContent = '압축 중...';
const cfg = await loadConfig();
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0);
const compressed = await compressAll(this.files);
const compBytes = compressed.reduce((s, f) => s + f.size, 0);
// DataTransfer 로 input.files 교체 (압축된 파일로)
const dt = new DataTransfer();
compressed.forEach(f => dt.items.add(f));
this.files = dt.files;
// 미리보기 렌더링
compressed.forEach((f, i) => {
const url = URL.createObjectURL(f);
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;display:inline-block;';
const img = document.createElement('img');
img.src = url;
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;';
img.onload = () => URL.revokeObjectURL(url);
// 삭제 버튼
const del = document.createElement('button');
del.textContent = '×';
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;';
del.onclick = () => {
// 해당 파일 제거
const cur = Array.from(input.files);
cur.splice(i, 1);
const dt2 = new DataTransfer();
cur.forEach(f2 => dt2.items.add(f2));
input.files = dt2.files;
wrap.remove();
updateInfo(input, info);
};
wrap.appendChild(img);
wrap.appendChild(del);
preview.appendChild(wrap);
});
// 용량 정보
if (info) {
const pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
if (cfg.image_compress_enabled && pct > 0) {
info.textContent = `${compressed.length}장 | ${fmt(origBytes)}${fmt(compBytes)} (${pct}% 절약) | 최대 ${cfg.image_max_px}px / 품질 ${cfg.image_quality}%`;
info.style.color = '#00875A';
} else {
info.textContent = `${compressed.length}장 | ${fmt(compBytes)} (압축 비활성)`;
info.style.color = '#8899BB';
}
}
});
}
function updateInfo(input, info) {
if (!info) return;
const bytes = Array.from(input.files).reduce((s, f) => s + f.size, 0);
info.textContent = `${input.files.length}장 | ${fmt(bytes)}`;
}
function fmt(bytes) {
return bytes < 1024 * 1024
? (bytes / 1024).toFixed(0) + ' KB'
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return { compressAll, setupPreview, loadConfig };
})();

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>계정 관리</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html" class="active">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">계정 관리</h2>
<button class="btn btn-primary" onclick="openModal()">+ 계정 생성</button>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;">
<select id="fRole" onchange="load()" style="width:auto">
<option value="">전체</option><option value="mechanic">정비사</option>
<option value="manufacturer">제조사</option><option value="admin">관리자</option>
</select>
</div>
<div class="tbl-wrap"><table>
<thead><tr><th>ID</th><th>아이디</th><th>역할</th><th>이름</th><th>회사/제조사</th><th>전화번호</th><th>상태</th><th>수정</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
</div>
</div>
</div>
<div class="modal-bg hidden" id="modal">
<div class="modal" style="max-width:480px">
<div class="modal-title" id="modalTitle">계정 생성</div>
<input type="hidden" id="eId">
<div class="form-row">
<div class="form-group"><label>역할 <span class="req">*</span></label>
<select id="eRole" onchange="toggleFields()">
<option value="mechanic">정비사</option>
<option value="manufacturer">제조사</option>
<option value="admin">관리자</option>
</select>
</div>
<div class="form-group"><label>로그인 ID <span class="req">*</span></label><input type="text" id="eUsername"></div>
</div>
<div class="form-group"><label>비밀번호 <span class="req" id="pwReq">*</span></label><input type="password" id="ePassword" placeholder="수정 시 변경할 경우에만 입력"></div>
<div class="form-row">
<div class="form-group"><label>이름 <span class="req">*</span></label><input type="text" id="eName"></div>
<div class="form-group" id="companyField"><label>회사명</label><input type="text" id="eCompany"></div>
</div>
<div class="form-row">
<div class="form-group"><label>전화번호</label><input type="tel" id="ePhone"></div>
<div class="form-group" id="emailField"><label>이메일</label><input type="email" id="eEmail"></div>
</div>
<div class="form-group"><label>계정 활성화</label>
<select id="eActive"><option value="true">활성</option><option value="false">비활성</option></select>
</div>
<div id="modalErr" class="alert alert-danger" style="display:none"></div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="save()">저장</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'));
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사'};
async function load() {
const role = document.getElementById('fRole').value;
const users = await API.get('/accounts'+(role?'?role='+role:''));
document.getElementById('tbody').innerHTML = users.map(u=>`
<tr><td>${u.id}</td><td>${u.username}</td><td>${ROLE_LABEL[u.role]||u.role}</td>
<td>${u.name}</td><td>${u.company||'-'}</td><td>${u.phone||'-'}</td>
<td><span class="badge ${u.is_active?'s-done':'s-waiting'}">${u.is_active?'활성':'비활성'}</span></td>
<td><button class="btn btn-outline btn-sm" onclick="editUser(${u.id})">수정</button>
<button class="btn btn-danger btn-sm" onclick="delUser(${u.id})">삭제</button></td></tr>`).join('');
}
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 toggleFields() {}
async function editUser(id) {
const users = await API.get('/accounts');
const u = users.find(x=>x.id===id);
openModal();
document.getElementById('eId').value=id;
document.getElementById('eRole').value=u.role;
document.getElementById('eUsername').value=u.username; document.getElementById('eUsername').disabled=true;
document.getElementById('eName').value=u.name;
document.getElementById('eCompany').value=u.company||'';
document.getElementById('ePhone').value=u.phone||'';
document.getElementById('eEmail').value=u.email||'';
document.getElementById('eActive').value=String(u.is_active);
document.getElementById('pwReq').style.display='none';
document.getElementById('modalTitle').textContent='계정 수정';
}
async function save() {
const id = document.getElementById('eId').value;
const fd = new FormData();
if (!id) { fd.append('username',document.getElementById('eUsername').value.trim()); fd.append('role',document.getElementById('eRole').value); }
const pw = document.getElementById('ePassword').value;
if (pw) fd.append('password', pw);
else if (!id) { alert('비밀번호를 입력하세요.'); return; }
fd.append('name',document.getElementById('eName').value.trim());
fd.append('company',document.getElementById('eCompany').value);
fd.append('phone',document.getElementById('ePhone').value);
fd.append('email',document.getElementById('eEmail').value);
fd.append('is_active',document.getElementById('eActive').value);
try {
if (id) await API.put('/accounts/'+id, fd);
else await API.post('/accounts', fd);
closeModal(); load();
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
}
async function delUser(id) { if(!confirm('비활성 처리하시겠습니까?')) return; await API.delete('/accounts/'+id); load(); }
load();
</script></body></html>

View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>충전기 종류 관리</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html" class="active">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">충전기 종류 관리</h2>
<!-- 추가 / 수정 폼 -->
<div class="card" style="max-width:520px">
<div class="card-title" id="formTitle">종류 추가</div>
<input type="hidden" id="editId">
<div class="form-group">
<label>종류명 <span class="req">*</span></label>
<input type="text" id="typeName" placeholder="예: 급속충전기 100kW">
</div>
<div class="form-group">
<label>설명</label>
<input type="text" id="typeDesc" placeholder="설명 입력">
</div>
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<div style="display:flex;gap:10px;">
<button class="btn btn-primary" id="submitBtn" onclick="submitForm()">추가</button>
<button class="btn btn-outline" id="cancelBtn" onclick="cancelEdit()" style="display:none">취소</button>
</div>
</div>
<!-- 목록 -->
<div class="card">
<div class="card-title">등록된 충전기 종류</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>종류명</th>
<th>설명</th>
<th>충전기 수</th>
<th>수정</th>
<th>삭제</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none;margin-top:12px">
등록된 충전기 종류가 없습니다.
</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'));
async function load() {
const types = await API.get('/chargers/types');
const tbody = document.getElementById('tbody');
document.getElementById('empty').style.display = types.length ? 'none' : 'block';
tbody.innerHTML = types.map(t => `
<tr>
<td>${t.id}</td>
<td><strong>${t.name}</strong></td>
<td>${t.description || '-'}</td>
<td>${t.charger_count}개</td>
<td>
<button class="btn btn-outline btn-sm" onclick="startEdit(${t.id}, '${escQ(t.name)}', '${escQ(t.description||'')}')">
수정
</button>
</td>
<td>
${t.charger_count === 0
? `<button class="btn btn-danger btn-sm" onclick="del(${t.id})">삭제</button>`
: `<span style="font-size:12px;color:var(--gray4)">사용중</span>`}
</td>
</tr>`).join('');
}
function escQ(str) {
return str.replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
/* ── 수정 모드 진입 ── */
function startEdit(id, name, desc) {
document.getElementById('editId').value = id;
document.getElementById('typeName').value = name;
document.getElementById('typeDesc').value = desc;
document.getElementById('formTitle').textContent = '종류 수정';
document.getElementById('submitBtn').textContent = '수정 저장';
document.getElementById('submitBtn').className = 'btn btn-accent';
document.getElementById('cancelBtn').style.display = 'inline-flex';
document.getElementById('formErr').style.display = 'none';
// 폼으로 스크롤
document.getElementById('typeName').focus();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/* ── 수정 취소 ── */
function cancelEdit() {
document.getElementById('editId').value = '';
document.getElementById('typeName').value = '';
document.getElementById('typeDesc').value = '';
document.getElementById('formTitle').textContent = '종류 추가';
document.getElementById('submitBtn').textContent = '추가';
document.getElementById('submitBtn').className = 'btn btn-primary';
document.getElementById('cancelBtn').style.display = 'none';
document.getElementById('formErr').style.display = 'none';
}
/* ── 추가 / 수정 공통 제출 ── */
async function submitForm() {
const id = document.getElementById('editId').value;
const name = document.getElementById('typeName').value.trim();
const desc = document.getElementById('typeDesc').value.trim();
const errEl = document.getElementById('formErr');
errEl.style.display = 'none';
if (!name) {
errEl.textContent = '종류명을 입력하세요.';
errEl.style.display = 'block';
return;
}
const fd = new FormData();
fd.append('name', name);
fd.append('description', desc);
try {
if (id) {
await API.put('/chargers/types/' + id, fd);
} else {
await API.post('/chargers/types', fd);
}
cancelEdit();
load();
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
/* ── 삭제 ── */
async function del(id) {
if (!confirm('삭제하시겠습니까?')) return;
try {
await API.delete('/chargers/types/' + id);
load();
} catch(e) {
alert(e.message);
}
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>충전기 종류 관리</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html" class="active">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">충전기 종류 관리</h2>
<div class="card" style="max-width:500px">
<div class="card-title">종류 추가</div>
<div class="form-group"><label>종류명 <span class="req">*</span></label><input type="text" id="newName" placeholder="예: 급속충전기 100kW"></div>
<div class="form-group"><label>설명</label><input type="text" id="newDesc" placeholder="설명 입력"></div>
<div id="addErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="addType()">추가</button>
</div>
<div class="card">
<div class="card-title">등록된 충전기 종류</div>
<div class="tbl-wrap"><table>
<thead><tr><th>ID</th><th>종류명</th><th>설명</th><th>충전기 수</th><th>삭제</th></tr></thead>
<tbody id="tbody"></tbody>
</table></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'));
async function load() {
const types = await API.get('/chargers/types');
document.getElementById('tbody').innerHTML = types.map(t=>`
<tr><td>${t.id}</td><td><strong>${t.name}</strong></td><td>${t.description||'-'}</td>
<td>${t.charger_count}개</td>
<td>${t.charger_count===0?`<button class="btn btn-danger btn-sm" onclick="del(${t.id})">삭제</button>`:'사용중'}</td></tr>`).join('');
}
async function addType() {
const name = document.getElementById('newName').value.trim();
if (!name) return;
const fd = new FormData(); fd.append('name',name); fd.append('description',document.getElementById('newDesc').value);
try { await API.post('/chargers/types',fd); document.getElementById('newName').value=''; document.getElementById('newDesc').value=''; load(); }
catch(e) { const el=document.getElementById('addErr'); el.textContent=e.message; el.style.display='block'; }
}
async function del(id) { if (!confirm('삭제하시겠습니까?')) return; await API.delete('/chargers/types/'+id); load(); }
load();
</script></body></html>

View File

@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>충전기 관리</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html" class="active">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">충전기 관리</h2>
<button class="btn btn-primary" onclick="openModal()">+ 충전기 등록</button>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead><tr><th>ID</th><th>종류</th><th>충전기명</th><th>충전소</th><th>CPO</th><th>설치일</th><th>미처리</th><th>QR</th><th>수정</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal-bg hidden" id="modal">
<div class="modal">
<div class="modal-title" id="modalTitle">충전기 등록</div>
<input type="hidden" id="editId">
<div class="form-row">
<div class="form-group"><label>충전기 종류 <span class="req">*</span></label><select id="fTypeId"></select></div>
<div class="form-group"><label>충전기 ID <span class="req">*</span></label><input type="text" id="fId" placeholder="예: CG-003"></div>
</div>
<div class="form-row">
<div class="form-group"><label>충전기명 <span class="req">*</span></label><input type="text" id="fName" placeholder="예: 1호기"></div>
<div class="form-group"><label>충전소명 <span class="req">*</span></label><input type="text" id="fStation"></div>
</div>
<div class="form-row">
<div class="form-group"><label>CPO명</label><input type="text" id="fCpo"></div>
<div class="form-group"><label>설치일</label><input type="date" id="fInstalled"></div>
</div>
<div class="form-group"><label>위치 상세</label><input type="text" id="fLocation"></div>
<div class="form-row">
<div class="form-group"><label>위도</label><input type="number" id="fLat" step="0.000001"></div>
<div class="form-group"><label>경도</label><input type="number" id="fLng" step="0.000001"></div>
</div>
<div id="modalErr" class="alert alert-danger" style="display:none"></div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="save()">저장</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 types = [], isEdit = false;
async function load() {
[types] = await Promise.all([API.get('/chargers/types')]);
const chargers = await API.get('/chargers');
document.getElementById('fTypeId').innerHTML = types.map(t=>`<option value="${t.id}">${t.name}</option>`).join('');
document.getElementById('tbody').innerHTML = chargers.map(c => `
<tr>
<td><strong>${c.id}</strong></td>
<td>${c.charger_type||'-'}</td>
<td>${c.name}</td>
<td>${c.station_name}</td>
<td>${c.cpo_name||'-'}</td>
<td>${c.installed_at||'-'}</td>
<td><span class="badge ${c.pending_reports>0?'s-pending':'s-done'}">${c.pending_reports}건</span></td>
<td><a class="btn btn-outline btn-sm" href="/pages/admin/qr.html?id=${c.id}">QR</a></td>
<td><button class="btn btn-outline btn-sm" onclick="editCharger('${c.id}')">수정</button></td>
</tr>`).join('');
}
function openModal(id=null) { isEdit=!!id; document.getElementById('modal').classList.remove('hidden'); document.getElementById('modalTitle').textContent = id?'충전기 수정':'충전기 등록'; }
function closeModal() { document.getElementById('modal').classList.add('hidden'); clearForm(); }
function clearForm() { ['fId','fName','fStation','fCpo','fInstalled','fLocation','fLat','fLng','editId'].forEach(id=>document.getElementById(id).value=''); document.getElementById('modalErr').style.display='none'; }
async function editCharger(id) {
const c = await API.get('/chargers/'+id);
openModal(id);
document.getElementById('editId').value = id;
document.getElementById('fTypeId').value = c.charger_type_id;
document.getElementById('fId').value = c.id; document.getElementById('fId').disabled = true;
document.getElementById('fName').value = c.name;
document.getElementById('fStation').value = c.station_name;
document.getElementById('fCpo').value = c.cpo_name||'';
document.getElementById('fInstalled').value = c.installed_at||'';
document.getElementById('fLocation').value = c.location_detail||'';
document.getElementById('fLat').value = c.gps_lat||'';
document.getElementById('fLng').value = c.gps_lng||'';
}
async function save() {
const fd = new FormData();
const id = document.getElementById('editId').value;
fd.append('charger_type_id', document.getElementById('fTypeId').value);
fd.append('name', document.getElementById('fName').value.trim());
fd.append('station_name', document.getElementById('fStation').value.trim());
fd.append('cpo_name', document.getElementById('fCpo').value);
fd.append('installed_at', document.getElementById('fInstalled').value);
fd.append('location_detail', document.getElementById('fLocation').value);
fd.append('gps_lat', document.getElementById('fLat').value||'');
fd.append('gps_lng', document.getElementById('fLng').value||'');
if (!id) fd.append('id', document.getElementById('fId').value.trim());
try {
if (id) await API.put('/chargers/'+id, fd);
else await API.post('/chargers', fd);
closeModal(); load();
} catch(e) { const el=document.getElementById('modalErr'); el.textContent=e.message; el.style.display='block'; }
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>출장비 관리</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html" class="active">💰 출장비 관리</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/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">출장비 관리</h2>
<button class="btn btn-success btn-sm" onclick="API.download('/export/costs','출장비목록.xlsx')">📥 엑셀 다운로드</button>
</div>
<div class="stats" id="stats"></div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending">미처리</option>
<option value="billed">청구완료</option>
<option value="waived">면제</option>
<option value="settled">정산완료</option>
</select>
<select id="fParty" style="width:auto">
<option value="">전체 부담주체</option>
<option value="cpo">CPO</option>
<option value="manufacturer">제조사</option>
<option value="self">자체</option>
<option value="user">사용자과실</option>
<option value="other">기타</option>
</select>
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>신고#</th><th>충전기</th><th>충전소</th><th>정비사</th><th>부담주체</th><th>금액</th><th>상태</th><th>처리일시</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none">조회된 출장비가 없습니다.</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
const PARTY_LABEL = {cpo:'CPO',manufacturer:'제조사',self:'자체',user:'사용자과실',other:'기타'};
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)]);
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="stat-num">${statsData.monthly_total.toLocaleString()}</div><div class="stat-label">이달 출장비 합계(원)</div></div>
<div class="stat danger"><div class="stat-num">${statsData.pending_count}</div><div class="stat-label">미처리 건수</div></div>`;
const tbody = document.getElementById('tbody');
document.getElementById('empty').style.display = costs.length ? 'none' : 'block';
tbody.innerHTML = costs.map(c => `
<tr onclick="location.href='/pages/admin/report-detail.html?id=${(c.report_ids||[])[0]||''}'">
<td>${(c.report_ids||[]).map(i=>'#'+i).join(', ')}</td>
<td>${c.charger_id||'-'}</td>
<td>${c.station_name||'-'}</td>
<td>${c.mechanic_name||'-'}<br><small>${c.mechanic_company||''}</small></td>
<td>${PARTY_LABEL[c.cost_party_type]||c.cost_party_type||'-'}${c.cost_party_type==='manufacturer'?`<br><small>${c.manufacturer_name||''}</small>`:''}</td>
<td style="font-weight:700;color:var(--orange)">${(c.cost_amount||0).toLocaleString()}원</td>
<td>${Auth.costStatusBadge(c.cost_status)}</td>
<td>${Auth.fmtDt(c.reviewed_at)}</td>
</tr>`).join('');
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>관리자 대시보드</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html" class="active">📊 대시보드</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/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:20px">대시보드</h2>
<div class="stats" id="stats"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="card">
<div class="card-title">🔴 최근 신고 (미처리)</div>
<div id="recentReports"></div>
</div>
<div class="card">
<div class="card-title">💰 출장비 미처리 현황</div>
<div id="costPending"></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'));
async function load() {
const [stats, reports, costs] = await Promise.all([
API.get('/stats'),
API.get('/reports?status=pending'),
API.get('/costs?cost_status=pending'),
]);
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="stat-num">${stats.total}</div><div class="stat-label">전체 신고</div></div>
<div class="stat warn"><div class="stat-num">${stats.pending}</div><div class="stat-label">접수 대기</div></div>
<div class="stat warn"><div class="stat-num">${stats.in_progress}</div><div class="stat-label">처리중</div></div>
<div class="stat good"><div class="stat-num">${stats.done}</div><div class="stat-label">완료</div></div>
<div class="stat danger"><div class="stat-num">${stats.cost_pending}</div><div class="stat-label">출장비 미처리</div></div>
<div class="stat warn"><div class="stat-num">${stats.improvement_open}</div><div class="stat-label">개선항목 진행중</div></div>
`;
document.getElementById('recentReports').innerHTML = reports.slice(0,8).map(r => `
<div onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'"
style="padding:9px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>#${r.id}</strong> <small style="color:var(--gray4)">${r.charger_id}</small>
<div style="font-size:12px;color:var(--text2)">${(r.issue_types||[]).join(', ')}</div>
</div>
<div style="text-align:right">
${Auth.statusBadge(r.status)}
<div style="font-size:11px;color:var(--gray4);margin-top:2px">${Auth.fmtDt(r.reported_at)}</div>
</div>
</div>`).join('') || '<div style="color:var(--gray4);font-size:13px">미처리 신고가 없습니다.</div>';
document.getElementById('costPending').innerHTML = costs.slice(0,8).map(c => `
<div onclick="location.href='/pages/admin/report-detail.html?repair_id=${c.repair_id}'"
style="padding:9px 0;border-bottom:1px solid var(--gray2);cursor:pointer;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${c.charger_id||'-'}</strong> <small style="color:var(--gray4)">${c.station_name||''}</small>
<div style="font-size:12px;color:var(--text2)">${c.mechanic_name||''} (${c.mechanic_company||''})</div>
</div>
<div style="text-align:right">
${Auth.costStatusBadge(c.cost_status)}
<div style="font-size:12px;color:var(--orange);font-weight:700;margin-top:2px">${(c.cost_amount||0).toLocaleString()}원</div>
</div>
</div>`).join('') || '<div style="color:var(--gray4);font-size:13px">미처리 출장비가 없습니다.</div>';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="main" style="max-width:860px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/improvements.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">개선항목 상세</h2>
</div>
<div id="content"></div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
const id = new URLSearchParams(location.search).get('id');
const CAT={sw:'SW개선',hw:'HW개선',ui:'UI개선',firmware:'펌웨어',other:'기타'};
const STATUS_OPTIONS = ['registered','reviewing','developing','deployed','done'];
const STATUS_LABEL = {registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
async function load() {
const imp = await API.get('/improvements/'+id);
document.getElementById('pageTitle').textContent = `개선항목 #${imp.id}`;
document.getElementById('content').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="card">
<div class="card-title">📋 기본 정보</div>
<table class="no-hover" style="font-size:13px">
<tr><td style="color:var(--gray4);width:90px">제목</td><td><strong>${imp.title}</strong></td></tr>
<tr><td style="color:var(--gray4)">분류</td><td>${CAT[imp.category]||imp.category}</td></tr>
<tr><td style="color:var(--gray4)">우선순위</td><td>${imp.priority}</td></tr>
<tr><td style="color:var(--gray4)">관련 부품</td><td>${imp.part_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">담당 제조사</td><td><strong>${imp.manufacturer_company||'-'}</strong><br>${imp.manufacturer_name||''}</td></tr>
<tr><td style="color:var(--gray4)">등록자</td><td>${imp.created_by_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
<tr><td style="color:var(--gray4)">실제 배포일</td><td>${imp.sw_deployed_at||'-'}</td></tr>
<tr><td style="color:var(--gray4)">현재 상태</td><td>${Auth.statusBadge(imp.status)}</td></tr>
</table>
<div style="margin-top:12px">
<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>
${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>`:''}
</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>
${imp.attachments.length ? imp.attachments.map(a=>`
<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-bottom:6px;display:block">
📄 ${a.name||a.path.split('/').pop()}
</a>`).join('') : '<div style="font-size:13px;color:var(--gray4)">첨부 파일 없음</div>'}
</div>
</div>
<!-- 상태 변경 -->
<div class="card" style="margin-top:0">
<div class="card-title">🔄 상태 변경</div>
<div class="form-row">
<div class="form-group">
<label>상태 변경</label>
<select id="newStatus">
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>SW 실제 배포일 (배포완료 시)</label>
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
</div>
</div>
<div class="form-group"><label>변경 메모</label><input type="text" id="changeMemo" placeholder="상태 변경 사유 또는 메모"></div>
<button class="btn btn-primary" onclick="changeStatus()">상태 저장</button>
</div>
<!-- 이력 로그 -->
<div class="card" style="margin-top:0">
<div class="card-title">📜 변경 이력</div>
${imp.logs.length ? `<div class="timeline">${imp.logs.map(l=>`
<div class="tl-item">
<div class="tl-time">${Auth.fmtDt(l.changed_at)}${l.by||'시스템'}</div>
<div class="tl-text">${l.old_status?`${STATUS_LABEL[l.old_status]||l.old_status}`:''}${STATUS_LABEL[l.new_status]||l.new_status}${l.memo?` / ${l.memo}`:''}</div>
</div>`).join('')}</div>` : '<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
</div>
`;
}
async function changeStatus() {
const status = document.getElementById('newStatus').value;
const memo = document.getElementById('changeMemo').value;
const date = document.getElementById('deployedAt').value;
const fd = new FormData();
fd.append('status', status); fd.append('memo', memo);
if (date) fd.append('sw_deployed_at', date);
await API.patch('/improvements/'+id+'/status', fd);
load();
}
load();
</script></body></html>

View File

@@ -0,0 +1,164 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 관리</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html" class="active">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">개선항목 관리</h2>
<div style="display:flex;gap:8px">
<button class="btn btn-success btn-sm" onclick="API.download('/export/improvements','개선항목목록.xlsx')">📥 엑셀</button>
<button class="btn btn-primary" onclick="openModal()">+ 개선항목 등록</button>
</div>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="registered">등록</option><option value="reviewing">검토중</option>
<option value="developing">개발중</option><option value="deployed">배포완료</option>
<option value="done">완료</option>
</select>
<select id="fMfr" style="width:auto"><option value="">전체 제조사</option></select>
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</div>
<div class="tbl-wrap"><table>
<thead><tr><th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>담당제조사</th><th>연결AS</th><th>상태</th><th>등록일</th><th>SW배포일</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
<div id="empty" class="alert alert-info" style="display:none">등록된 개선항목이 없습니다.</div>
</div>
</div>
</div>
<!-- 등록 모달 -->
<div class="modal-bg hidden" id="modal">
<div class="modal" style="max-width:680px">
<div class="modal-title">개선항목 등록</div>
<div class="form-row">
<div class="form-group">
<label>분류 <span class="req">*</span></label>
<select id="mCat">
<option value="sw">SW 개선</option><option value="hw">HW 개선</option>
<option value="ui">UI 개선</option><option value="firmware">펌웨어</option>
<option value="other">기타</option>
</select>
</div>
<div class="form-group">
<label>우선순위</label>
<select id="mPri">
<option value="urgent">🔴 긴급</option><option value="high">🟠 높음</option>
<option value="normal" selected>🟡 보통</option><option value="low">🟢 낮음</option>
</select>
</div>
</div>
<div class="form-group"><label>제목 <span class="req">*</span></label><input type="text" id="mTitle" placeholder="개선 항목 제목"></div>
<div class="form-group"><label>개선 내용 <span class="req">*</span></label><textarea id="mDesc" rows="4" placeholder="문제점 및 개선 요구 사항을 상세히 작성하세요."></textarea></div>
<div class="form-row">
<div class="form-group"><label>관련 부품명</label><input type="text" id="mPart" placeholder="예: 전력변환모듈"></div>
<div class="form-group"><label>담당 제조사 <span class="req">*</span></label><select id="mMfr"></select></div>
</div>
<div class="form-group">
<label>관련 AS 신고 연결 (복수 선택)</label>
<input type="text" id="mReportSearch" placeholder="신고번호 또는 충전기 ID로 검색" oninput="searchReports()" style="margin-bottom:8px">
<div id="mReportList" style="max-height:150px;overflow-y:auto;border:1px solid var(--gray3);border-radius:6px;padding:8px;font-size:12px"></div>
</div>
<div class="form-row">
<div class="form-group"><label>보고서 첨부</label><input type="file" id="mFiles" multiple></div>
<div class="form-group"><label>SW 배포 목표일</label><input type="date" id="mTarget"></div>
</div>
<div id="modalErr" class="alert alert-danger" style="display:none"></div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal()">취소</button>
<button class="btn btn-primary" onclick="saveImprovement()">등록</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'));
const CAT = {sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
const PRI = {urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
const selectedReports = new Set();
let allReports = [];
async function load() {
const [mfrs, imps] = await Promise.all([
API.get('/accounts?role=manufacturer'),
API.get('/improvements?status='+document.getElementById('fStatus').value+'&manufacturer_id='+document.getElementById('fMfr').value)
]);
// 제조사 필터 드롭다운
const mfrSel = document.getElementById('fMfr');
if (mfrSel.options.length <= 1)
mfrs.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=`${m.company||''} / ${m.name}`; mfrSel.appendChild(o); });
document.getElementById('empty').style.display = imps.length ? 'none' : 'block';
document.getElementById('tbody').innerHTML = imps.map(i => `
<tr onclick="location.href='/pages/admin/improvement-detail.html?id=${i.id}'">
<td>#${i.id}</td>
<td style="max-width:200px"><strong>${i.title}</strong></td>
<td>${CAT[i.category]||i.category}</td>
<td>${PRI[i.priority]||i.priority}</td>
<td>${i.manufacturer_company||'-'}<br><small>${i.manufacturer_name||''}</small></td>
<td><span class="badge s-pending">${i.report_count}건</span></td>
<td>${Auth.statusBadge(i.status)}</td>
<td>${Auth.fmtDt(i.created_at)}</td>
<td>${i.sw_deployed_at||'-'}</td>
</tr>`).join('');
}
async function openModal() {
document.getElementById('modal').classList.remove('hidden');
const mfrs = await API.get('/accounts?role=manufacturer');
document.getElementById('mMfr').innerHTML = '<option value="">제조사 선택</option>' +
mfrs.map(m=>`<option value="${m.id}">${m.company||''} / ${m.name}</option>`).join('');
allReports = await API.get('/reports');
renderReportList('');
}
function closeModal() { document.getElementById('modal').classList.add('hidden'); selectedReports.clear(); document.getElementById('modalErr').style.display='none'; }
function searchReports() { renderReportList(document.getElementById('mReportSearch').value.toLowerCase()); }
function renderReportList(q) {
const filtered = allReports.filter(r => !q || String(r.id).includes(q) || (r.charger_id||'').toLowerCase().includes(q)).slice(0,20);
document.getElementById('mReportList').innerHTML = filtered.map(r => `
<label style="display:flex;gap:8px;align-items:center;padding:5px;cursor:pointer;${selectedReports.has(r.id)?'background:#E3EDFF;border-radius:4px':''}">
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}" style="accent-color:var(--accent);flex-shrink:0"
onchange="${selectedReports.has(r.id)?'selectedReports.delete':'selectedReports.add'}(${r.id}); renderReportList('${q}')">
<span><strong>#${r.id}</strong> ${r.charger_id||''}${(r.issue_types||[]).join(', ')}</span>
</label>`).join('') || '<div style="color:var(--gray4)">검색 결과 없음</div>';
}
async function saveImprovement() {
const title = document.getElementById('mTitle').value.trim();
const desc = document.getElementById('mDesc').value.trim();
const mfr = document.getElementById('mMfr').value;
if (!title) { showErr('제목을 입력하세요.'); return; }
if (!desc) { showErr('내용을 입력하세요.'); return; }
if (!mfr) { showErr('담당 제조사를 선택하세요.'); return; }
const fd = new FormData();
fd.append('title',title); fd.append('category',document.getElementById('mCat').value);
fd.append('description',desc); fd.append('priority',document.getElementById('mPri').value);
fd.append('part_name',document.getElementById('mPart').value);
fd.append('manufacturer_id',mfr);
fd.append('report_ids', JSON.stringify([...selectedReports]));
fd.append('sw_deploy_target',document.getElementById('mTarget').value);
Array.from(document.getElementById('mFiles').files).forEach(f => fd.append('attachments',f));
try { await API.post('/improvements',fd); closeModal(); load(); }
catch(e) { showErr(e.message); }
}
function showErr(m) { const el=document.getElementById('modalErr'); el.textContent=m; el.style.display='block'; }
load();
</script></body></html>

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>QR 생성</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html" class="active">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">QR 코드 생성</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;max-width:800px">
<div class="card">
<div class="card-title">충전기 선택</div>
<div class="form-group"><label>등록된 충전기 선택</label>
<select id="chargerSel" onchange="onSelect()"><option value="">직접 입력</option></select>
</div>
<div class="form-group"><label>충전기 ID</label><input type="text" id="cId" placeholder="CG-003"></div>
<button class="btn btn-primary" onclick="genQR()">📷 QR 생성</button>
<div id="err" class="alert alert-danger" style="display:none;margin-top:10px"></div>
</div>
<div class="card" id="qrResult" style="display:none">
<div class="card-title">생성된 QR 코드</div>
<div style="text-align:center">
<img id="qrImg" style="width:200px;height:200px;border:1px solid var(--gray3);border-radius:8px">
<div id="qrInfo" style="margin-top:10px;font-size:13px;color:var(--text2)"></div>
<button class="btn btn-success" style="margin-top:12px" onclick="printQR()">🖨 인쇄</button>
<a id="qrDownload" class="btn btn-outline" style="margin-top:8px;display:block">⬇ 이미지 저장</a>
</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'));
let chargerMap = {};
async function load() {
const chargers = await API.get('/chargers');
chargers.forEach(c => chargerMap[c.id] = c);
document.getElementById('chargerSel').innerHTML = '<option value="">직접 입력</option>' +
chargers.map(c=>`<option value="${c.id}">${c.id}${c.name} (${c.station_name})</option>`).join('');
const urlId = new URLSearchParams(location.search).get('id');
if (urlId) { document.getElementById('chargerSel').value = urlId; onSelect(); genQR(); }
}
function onSelect() {
const id = document.getElementById('chargerSel').value;
document.getElementById('cId').value = id;
}
async function genQR() {
const id = document.getElementById('cId').value.trim();
if (!id) { const el=document.getElementById('err'); el.textContent='충전기 ID를 입력하세요.'; el.style.display='block'; return; }
document.getElementById('err').style.display='none';
try {
const res = await API.post('/chargers/'+id+'/qr');
const c = chargerMap[id] || {};
document.getElementById('qrResult').style.display='block';
document.getElementById('qrImg').src = res.qr_path + '?t=' + Date.now();
document.getElementById('qrInfo').innerHTML = `<strong>${id}</strong><br>${c.name||''} / ${c.station_name||''}`;
const dl = document.getElementById('qrDownload');
dl.href = res.qr_path; dl.download = id + '_QR.png';
} catch(e) { const el=document.getElementById('err'); el.textContent=e.message; el.style.display='block'; }
}
function printQR() {
const img = document.getElementById('qrImg').src;
const w = window.open(''); w.document.write(`<html><body style="text-align:center"><img src="${img}" style="width:300px"><br><script>window.print()<\/script></body></html>`); w.document.close();
}
load();
</script></body></html>

View File

@@ -0,0 +1,438 @@
<!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>
/* 출장비 요약 카드 */
.cost-summary {
border-radius: 10px;
padding: 18px 20px;
margin-bottom: 16px;
position: relative;
}
.cost-summary.s-pending { background: #FFF8E6; border: 2px solid #FFD600; }
.cost-summary.s-billed { background: #E3EDFF; border: 2px solid var(--blue); }
.cost-summary.s-waived { background: #F0F0F0; border: 2px solid #aaa; }
.cost-summary.s-settled { background: #E8F8F2; border: 2px solid var(--green); }
.cost-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.cost-summary-title {
font-size: 15px;
font-weight: 700;
color: var(--navy);
display: flex;
align-items: center;
gap: 8px;
}
.cost-status-badge {
display: inline-block;
padding: 3px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
}
.csb-pending { background: #FFF3CD; color: #856404; }
.csb-billed { background: #DBEAFE; color: #1565C0; }
.csb-waived { background: #F0F0F0; color: #555; }
.csb-settled { background: #D1FAE5; color: #065F46; }
.cost-summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.cost-summary-item label {
font-size: 10px;
letter-spacing: 1px;
color: var(--gray4);
text-transform: uppercase;
display: block;
margin-bottom: 3px;
}
.cost-summary-item span {
font-size: 13px;
color: var(--text);
font-weight: 500;
}
.cost-summary-item span.amount {
font-size: 18px;
font-weight: 900;
color: var(--orange);
}
.cost-summary-divider {
border: none;
border-top: 1px solid rgba(0,0,0,.08);
margin: 12px 0;
}
.cost-note-box {
background: rgba(0,0,0,.04);
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
color: var(--text2);
line-height: 1.7;
}
.edit-toggle-btn {
font-size: 12px;
color: var(--blue);
background: none;
border: 1px solid var(--blue);
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.edit-toggle-btn:hover { background: #E3EDFF; }
/* 수정 폼 슬라이드 */
.cost-edit-wrap {
overflow: hidden;
transition: max-height .3s ease;
}
.cost-edit-wrap.collapsed { max-height: 0 !important; }
.cost-edit-inner {
border-top: 1px dashed var(--gray3);
padding-top: 16px;
margin-top: 4px;
}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 — 관리자</span>
<div id="navUser"></div>
</nav>
<div class="main" style="max-width:860px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/admin/reports.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">신고 상세</h2>
</div>
<div id="content"></div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search);
const reportId = params.get('id');
const PARTY_LABEL = {
cpo: 'CPO (운영사)',
manufacturer: '제조사',
self: '자체 부담',
user: '사용자 과실',
other: '기타',
};
const COST_STATUS_LABEL = {
pending: '미처리',
billed: '청구 완료',
waived: '면제',
settled: '정산 완료',
};
const COST_STATUS_ICON = {
pending: '🕐',
billed: '📨',
waived: '🔖',
settled: '✅',
};
let editOpen = false;
function toggleEdit() {
editOpen = !editOpen;
const wrap = document.getElementById('costEditWrap');
const btn = document.getElementById('editToggleBtn');
if (editOpen) {
wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
wrap.classList.remove('collapsed');
btn.innerHTML = '▲ 수정 접기';
} else {
wrap.style.maxHeight = '0';
wrap.classList.add('collapsed');
btn.innerHTML = '✏️ 수정하기';
}
}
async function load() {
const r = await API.get('/reports/' + reportId);
const repair = r.repair;
const cost = repair?.cost;
const manufacturers = await API.get('/accounts?role=manufacturer');
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`;
// ── 출장비 요약 HTML 생성 ──
let costHtml = '';
if (repair) {
const hasCost = cost && cost.cost_party_type;
const costStatus = cost?.cost_status || 'pending';
const statusLabel = COST_STATUS_LABEL[costStatus] || costStatus;
const statusIcon = COST_STATUS_ICON[costStatus] || '🕐';
// 부담 주체 텍스트
let partyText = '-';
if (cost?.cost_party_type) {
partyText = PARTY_LABEL[cost.cost_party_type] || cost.cost_party_type;
if (cost.cost_party_type === 'manufacturer' && cost.manufacturer_name) {
partyText += ` (${cost.manufacturer_name})`;
}
if (cost.cost_party_type === 'other' && cost.cost_party_custom) {
partyText += `${cost.cost_party_custom}`;
}
}
// 요약 카드 (처리 내역이 있을 때만 표시)
const summaryHtml = hasCost ? `
<div class="cost-summary s-${costStatus}">
<div class="cost-summary-header">
<div class="cost-summary-title">
${statusIcon} 출장비 처리 내역
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="cost-status-badge csb-${costStatus}">${statusLabel}</span>
<button class="edit-toggle-btn" id="editToggleBtn" onclick="toggleEdit()">✏️ 수정하기</button>
</div>
</div>
<div class="cost-summary-grid">
<div class="cost-summary-item">
<label>출장비 부담 주체</label>
<span>${partyText}</span>
</div>
<div class="cost-summary-item">
<label>출장비 금액</label>
<span class="amount">${(cost.cost_amount || 0).toLocaleString()}원</span>
</div>
<div class="cost-summary-item">
<label>처리 담당자</label>
<span>${cost.reviewed_by_name || '-'}</span>
</div>
<div class="cost-summary-item">
<label>처리 일시</label>
<span>${Auth.fmtDt(cost.reviewed_at)}</span>
</div>
</div>
${(cost.root_cause || cost.admin_note) ? `
<hr class="cost-summary-divider">
${cost.root_cause ? `
<div style="margin-bottom:8px;">
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">문제 원인</div>
<div class="cost-note-box">${cost.root_cause}</div>
</div>` : ''}
${cost.admin_note ? `
<div>
<div style="font-size:10px;letter-spacing:1px;color:var(--gray4);text-transform:uppercase;margin-bottom:4px;">비고</div>
<div class="cost-note-box">${cost.admin_note}</div>
</div>` : ''}
` : ''}
</div>` : '';
// 수정 폼 (항상 존재, 기존 미처리면 바로 펼쳐져 있음)
const formCollapsed = hasCost ? 'collapsed' : '';
if (!hasCost) editOpen = true;
costHtml = `
<div class="card" style="margin-top:0">
<div class="card-title">💰 출장비 처리${hasCost ? '' : ' (관리자)'}</div>
${summaryHtml}
<!-- 입력 / 수정 폼 -->
<div class="cost-edit-wrap ${formCollapsed}" id="costEditWrap">
<div class="cost-edit-inner">
<div class="form-row">
<div class="form-group">
<label>문제 원인 파악 <span class="req">*</span></label>
<textarea id="rootCause" rows="3"
placeholder="조치 내용 검토 후 원인을 기재하세요.">${cost?.root_cause || ''}</textarea>
</div>
<div class="form-group">
<label>비고</label>
<textarea id="adminNote" rows="3"
placeholder="특이사항, 추가 메모 등">${cost?.admin_note || ''}</textarea>
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label>출장비 부담 주체 <span class="req">*</span></label>
<select id="partyType" onchange="toggleParty()">
<option value="">선택</option>
<option value="cpo" ${cost?.cost_party_type === 'cpo' ? 'selected' : ''}>CPO (운영사)</option>
<option value="manufacturer" ${cost?.cost_party_type === 'manufacturer' ? 'selected' : ''}>제조사</option>
<option value="self" ${cost?.cost_party_type === 'self' ? 'selected' : ''}>자체 부담</option>
<option value="user" ${cost?.cost_party_type === 'user' ? 'selected' : ''}>사용자 과실</option>
<option value="other" ${cost?.cost_party_type === 'other' ? 'selected' : ''}>기타</option>
</select>
</div>
<div class="form-group" id="mfrWrap"
style="display:${cost?.cost_party_type === 'manufacturer' ? 'block' : 'none'}">
<label>제조사 선택</label>
<select id="partyMfr">
<option value="">선택</option>
${manufacturers.map(m =>
`<option value="${m.id}"
${cost?.cost_party_manufacturer_id == m.id ? 'selected' : ''}>
${m.company || ''} / ${m.name}
</option>`).join('')}
</select>
</div>
<div class="form-group" id="customWrap"
style="display:${cost?.cost_party_type === 'other' ? 'block' : 'none'}">
<label>기타 직접 입력</label>
<input type="text" id="partyCustom" value="${cost?.cost_party_custom || ''}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>출장비 금액 (원)</label>
<input type="number" id="costAmount"
value="${cost?.cost_amount || 0}" min="0" step="1000">
</div>
<div class="form-group">
<label>처리 상태</label>
<select id="costStatus">
<option value="pending" ${(!cost || cost.cost_status === 'pending') ? 'selected' : ''}>미처리</option>
<option value="billed" ${cost?.cost_status === 'billed' ? 'selected' : ''}>청구완료</option>
<option value="waived" ${cost?.cost_status === 'waived' ? 'selected' : ''}>면제</option>
<option value="settled" ${cost?.cost_status === 'settled' ? 'selected' : ''}>정산완료</option>
</select>
</div>
</div>
<div id="costErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary" onclick="saveCost(${repair.id})">
💾 출장비 처리 저장
</button>
</div>
</div>
</div>`;
}
document.getElementById('content').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<!-- 신고 정보 -->
<div class="card">
<div class="card-title">📋 신고 정보</div>
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">충전기 ID</td><td><strong>${r.charger_id}</strong></td></tr>
<tr><td style="color:var(--gray4)">충전기명</td><td>${r.charger_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">충전소</td><td>${r.station_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">CPO</td><td>${r.cpo_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">종류</td><td>${r.charger_type || '-'}</td></tr>
<tr><td style="color:var(--gray4)">설치일</td><td>${r.installed_at || '-'}</td></tr>
<tr><td style="color:var(--gray4)">문제유형</td><td>${(r.issue_types || []).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">에러코드</td><td>${r.error_code || '-'}</td></tr>
<tr><td style="color:var(--gray4)">상세설명</td><td>${r.issue_detail || '-'}</td></tr>
<tr><td style="color:var(--gray4)">연락처</td><td>${r.contact || '-'}</td></tr>
<tr><td style="color:var(--gray4)">발생시각</td><td>${Auth.fmtDt(r.occurred_at)}</td></tr>
<tr><td style="color:var(--gray4)">신고일시</td><td>${Auth.fmtDt(r.reported_at)}</td></tr>
<tr><td style="color:var(--gray4)">상태</td><td>${Auth.statusBadge(r.status)}</td></tr>
</table>
${r.status === 'pending_approval' ? `
<button class="btn btn-success btn-sm" style="margin-top:12px"
onclick="approveReport(${r.id})">✅ 신고 승인 (정비사 공개)</button>` : ''}
<div style="margin-top:12px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">신고 사진</label>
<div class="photo-preview">
${(r.photos || []).map(p =>
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
).join('') || '<span style="font-size:12px;color:var(--gray4)">첨부 없음</span>'}
</div>
</div>
</div>
<!-- 조치 정보 -->
<div class="card">
<div class="card-title">🔧 조치 정보</div>
${repair ? `
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${repair.mechanic_name || '-'}</td></tr>
<tr><td style="color:var(--gray4)">소속</td><td>${repair.mechanic_company || '-'}</td></tr>
<tr><td style="color:var(--gray4)">조치유형</td><td>${(repair.repair_types || []).join(', ')}</td></tr>
<tr><td style="color:var(--gray4)">조치내용</td><td>${repair.description || '-'}</td></tr>
<tr><td style="color:var(--gray4)">시작시각</td><td>${Auth.fmtDt(repair.started_at)}</td></tr>
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(repair.completed_at)}</td></tr>
<tr><td style="color:var(--gray4)">처리결과</td><td>${Auth.statusBadge(repair.result_status)}</td></tr>
</table>
<div style="margin-top:12px">
<label style="font-size:12px;font-weight:700;color:var(--navy2)">조치 전 사진</label>
<div class="photo-preview">
${(repair.photos_before || []).map(p =>
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
</div>
<label style="font-size:12px;font-weight:700;color:var(--navy2);margin-top:10px;display:block">조치 후 사진</label>
<div class="photo-preview">
${(repair.photos_after || []).map(p =>
`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`
).join('') || '<span style="font-size:12px;color:var(--gray4)">없음</span>'}
</div>
</div>
` : '<div class="alert alert-info">아직 정비사가 조치를 입력하지 않았습니다.</div>'}
</div>
</div>
${costHtml}
`;
// 폼이 처음부터 열려 있는 경우 (미처리) max-height 설정
if (!editOpen) return;
const wrap = document.getElementById('costEditWrap');
if (wrap) wrap.style.maxHeight = wrap.scrollHeight + 2000 + 'px';
}
function toggleParty() {
const v = document.getElementById('partyType').value;
document.getElementById('mfrWrap').style.display = v === 'manufacturer' ? 'block' : 'none';
document.getElementById('customWrap').style.display = v === 'other' ? 'block' : 'none';
}
async function approveReport(id) {
if (!confirm('신고를 승인하여 정비사에게 공개하시겠습니까?')) return;
await API.patch(`/reports/${id}/approve`);
alert('승인되었습니다.');
load();
}
async function saveCost(repairId) {
const partyType = document.getElementById('partyType').value;
if (!partyType) { showCostErr('출장비 부담 주체를 선택해 주세요.'); return; }
const fd = new FormData();
fd.append('root_cause', document.getElementById('rootCause').value);
fd.append('admin_note', document.getElementById('adminNote').value);
fd.append('cost_party_type', partyType);
fd.append('cost_party_manufacturer_id', document.getElementById('partyMfr')?.value || '');
fd.append('cost_party_custom', document.getElementById('partyCustom')?.value || '');
fd.append('cost_amount', document.getElementById('costAmount').value || 0);
fd.append('cost_status', document.getElementById('costStatus').value);
try {
await API.post(`/costs/repair/${repairId}`, fd);
alert('✅ 출장비 처리가 저장되었습니다.');
editOpen = false;
load();
} catch(e) { showCostErr(e.message); }
}
function showCostErr(msg) {
const el = document.getElementById('costErr');
el.textContent = msg;
el.style.display = 'block';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>신고 목록</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html" class="active">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html">⚙️ 설정</a>
</div>
<div class="main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:10px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 신고 목록</h2>
<button class="btn btn-success btn-sm" onclick="API.download('/export/reports','AS신고목록.xlsx')">📥 엑셀 다운로드</button>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
<option value="done">완료</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
</select>
<input type="text" id="fCharger" placeholder="충전기 ID" style="width:150px">
<button class="btn btn-outline btn-sm" onclick="load()">🔍 검색</button>
</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></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none">조회된 신고가 없습니다.</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'));
async function load() {
let url = '/reports?';
const s = document.getElementById('fStatus').value;
const c = document.getElementById('fCharger').value.trim();
if (s) url += 'status='+s+'&';
if (c) url += 'charger_id='+c+'&';
const rows = await API.get(url);
const tbody = document.getElementById('tbody');
document.getElementById('empty').style.display = rows.length ? 'none' : 'block';
tbody.innerHTML = rows.map(r => `
<tr onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>
<td style="max-width:200px">${(r.issue_types||[]).join(', ')}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${Auth.statusBadge(r.status)}</td>
<td>${r.repair?.mechanic_name||'-'}</td>
</tr>`).join('');
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,269 @@
<!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>
.range-wrap{display:flex;align-items:center;gap:12px;}
.range-wrap input[type=range]{flex:1;accent-color:var(--accent);}
.range-val{min-width:48px;text-align:center;font-weight:700;color:var(--navy);font-size:14px;}
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--gray2);}
.toggle-row:last-child{border-bottom:none;}
.toggle-label h4{font-size:14px;font-weight:700;color:var(--navy);}
.toggle-label p{font-size:12px;color:var(--gray4);margin-top:2px;}
.toggle{position:relative;width:44px;height:24px;flex-shrink:0;}
.toggle input{opacity:0;width:0;height:0;}
.toggle-slider{position:absolute;inset:0;background:var(--gray3);border-radius:24px;cursor:pointer;transition:.2s;}
.toggle-slider::before{content:'';position:absolute;width:18px;height:18px;left:3px;bottom:3px;background:white;border-radius:50%;transition:.2s;}
.toggle input:checked + .toggle-slider{background:var(--accent);}
.toggle input:checked + .toggle-slider::before{transform:translateX(20px);}
.preset-btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;}
.preset-btn{padding:5px 14px;border-radius:6px;border:1px solid var(--gray3);background:white;font-size:12px;cursor:pointer;transition:all .15s;font-family:'Noto Sans KR',sans-serif;}
.preset-btn:hover{border-color:var(--accent);color:var(--accent);}
.preset-btn.active{border-color:var(--accent);background:#E3EDFF;color:var(--blue);font-weight:700;}
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html" class="active">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">시스템 설정</h2>
<!-- 신고 공개 정책 -->
<div class="card" style="max-width:560px">
<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-immediate">
<input type="radio" name="policy" value="immediate" 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-approval">
<input type="radio" name="policy" value="admin_approval" 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 id="saveOk" class="alert alert-success" style="display:none">설정이 저장되었습니다.</div>
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:4px">전체 설정 저장</button>
</div>
<!-- 이미지 압축 설정 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🖼️ 사진 업로드 압축 설정</div>
<div class="alert alert-info" style="margin-bottom:16px">
신고·조치 사진 업로드 시 브라우저에서 자동으로 압축합니다.<br>
서버 저장 용량 절약 및 업로드 속도를 개선합니다.
</div>
<!-- 압축 ON/OFF -->
<div class="toggle-row">
<div class="toggle-label">
<h4>📸 자동 압축 사용</h4>
<p>업로드 전 이미지를 자동으로 리사이즈·압축합니다</p>
</div>
<label class="toggle">
<input type="checkbox" id="compressEnabled">
<span class="toggle-slider"></span>
</label>
</div>
<!-- 최대 해상도 -->
<div class="toggle-row" style="flex-direction:column;align-items:flex-start;gap:12px;">
<div class="toggle-label">
<h4>📐 최대 해상도 (긴 변 기준)</h4>
<p>이 픽셀 수를 초과하면 비율 유지하며 축소합니다</p>
</div>
<div style="width:100%">
<div class="preset-btns" id="presetBtns">
<button class="preset-btn" onclick="setMaxPx(640)" data-px="640" >640px <small style="color:var(--gray4)">저화질</small></button>
<button class="preset-btn" onclick="setMaxPx(1024)" data-px="1024">1024px <small style="color:var(--gray4)">권장 ★</small></button>
<button class="preset-btn" onclick="setMaxPx(1920)" data-px="1920">1920px <small style="color:var(--gray4)">FHD</small></button>
<button class="preset-btn" onclick="setMaxPx(2560)" data-px="2560">2560px <small style="color:var(--gray4)">QHD</small></button>
<button class="preset-btn" onclick="setMaxPx(3840)" data-px="3840">3840px <small style="color:var(--gray4)">4K</small></button>
</div>
<div class="range-wrap" style="margin-top:10px;">
<input type="range" id="maxPx" min="320" max="3840" step="64"
value="1024" oninput="syncMaxPx(this.value)">
<span class="range-val" id="maxPxVal">1024px</span>
</div>
</div>
</div>
<!-- JPEG 품질 -->
<div class="toggle-row" style="flex-direction:column;align-items:flex-start;gap:10px;border-bottom:none;">
<div class="toggle-label">
<h4>🎨 JPEG 압축 품질</h4>
<p>높을수록 화질 좋고 용량 큼 / 낮을수록 용량 작고 화질 저하</p>
</div>
<div style="width:100%">
<div class="preset-btns" id="qualityPresets">
<button class="preset-btn" onclick="setQuality(60)" data-q="60" >60% <small style="color:var(--gray4)">압축 최대</small></button>
<button class="preset-btn" onclick="setQuality(75)" data-q="75" >75% <small style="color:var(--gray4)">균형</small></button>
<button class="preset-btn" onclick="setQuality(85)" data-q="85" >85% <small style="color:var(--gray4)">권장 ★</small></button>
<button class="preset-btn" onclick="setQuality(95)" data-q="95" >95% <small style="color:var(--gray4)">고화질</small></button>
</div>
<div class="range-wrap" style="margin-top:10px;">
<input type="range" id="quality" min="30" max="100" step="5"
value="85" oninput="syncQuality(this.value)">
<span class="range-val" id="qualityVal">85%</span>
</div>
</div>
</div>
<!-- 현재 효과 미리보기 텍스트 -->
<div id="effectDesc" style="background:var(--gray1);border-radius:8px;padding:12px 14px;font-size:13px;color:var(--text);margin-top:4px;line-height:1.7;">
</div>
<div id="imgSaveOk" class="alert alert-success" style="display:none;margin-top:12px">이미지 설정이 저장되었습니다.</div>
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:12px">전체 설정 저장</button>
</div>
<!-- 비밀번호 변경 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🔑 내 비밀번호 변경</div>
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="curPw"></div>
<div class="form-group"><label>새 비밀번호</label><input type="password" id="newPw"></div>
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="newPw2"></div>
<div id="pwErr" class="alert alert-danger" style="display:none"></div>
<div id="pwOk" class="alert alert-success" style="display:none">비밀번호가 변경되었습니다.</div>
<button class="btn btn-outline" onclick="changePw()">비밀번호 변경</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'));
function syncMaxPx(v) {
document.getElementById('maxPxVal').textContent = v + 'px';
updatePresetBtns('presetBtns', 'data-px', v);
updateEffect();
}
function syncQuality(v) {
document.getElementById('qualityVal').textContent = v + '%';
updatePresetBtns('qualityPresets', 'data-q', v);
updateEffect();
}
function setMaxPx(v) {
document.getElementById('maxPx').value = v;
syncMaxPx(v);
}
function setQuality(v) {
document.getElementById('quality').value = v;
syncQuality(v);
}
function updatePresetBtns(containerId, attr, val) {
document.querySelectorAll(`#${containerId} .preset-btn`).forEach(b => {
b.classList.toggle('active', b.getAttribute(attr) == val);
});
}
function updateEffect() {
const enabled = document.getElementById('compressEnabled').checked;
const px = parseInt(document.getElementById('maxPx').value);
const q = parseInt(document.getElementById('quality').value);
const el = document.getElementById('effectDesc');
if (!enabled) {
el.innerHTML = '⚪ 압축 비활성 — 원본 파일 그대로 업로드됩니다.';
el.style.color = 'var(--gray4)';
return;
}
// 대략적 용량 절약 예측 (12MP 스마트폰 사진 기준 ~8MB)
const areaRatio = Math.min(1, (px * px) / (4032 * 3024));
const estMB = (8 * areaRatio * (q / 100) * 0.6).toFixed(1);
el.innerHTML = `✅ <strong>${px}px / JPEG ${q}%</strong> 로 압축<br>
스마트폰 고화질 사진(약 8MB) 기준 → 업로드 약 <strong>${estMB}MB</strong> 예상<br>
<span style="color:var(--green);font-size:12px">업로드 속도 향상 + 서버 용량 절약</span>`;
el.style.color = 'var(--text)';
}
async function load() {
const s = await API.get('/settings');
const policy = s.report_visibility_policy || 'immediate';
document.querySelector(`input[value="${policy}"]`).checked = true;
updateLabels();
const enabled = s.image_compress_enabled !== 'false';
document.getElementById('compressEnabled').checked = enabled;
const px = parseInt(s.image_max_px || '1024');
document.getElementById('maxPx').value = px;
syncMaxPx(px);
const q = parseInt(s.image_quality || '85');
document.getElementById('quality').value = q;
syncQuality(q);
updateEffect();
}
function updateLabels() {
document.querySelectorAll('input[name="policy"]').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.getElementById('compressEnabled').addEventListener('change', updateEffect);
async function saveAll() {
const fd = new FormData();
fd.append('report_visibility_policy', document.querySelector('input[name="policy"]:checked').value);
fd.append('image_compress_enabled', document.getElementById('compressEnabled').checked ? 'true' : 'false');
fd.append('image_max_px', document.getElementById('maxPx').value);
fd.append('image_quality', document.getElementById('quality').value);
await API.put('/settings', fd);
const ok = document.getElementById('saveOk');
ok.style.display = 'block';
setTimeout(() => ok.style.display = 'none', 2500);
const ok2 = document.getElementById('imgSaveOk');
ok2.style.display = 'block';
setTimeout(() => ok2.style.display = 'none', 2500);
}
async function changePw() {
const cur = document.getElementById('curPw').value;
const nw = document.getElementById('newPw').value;
const nw2 = document.getElementById('newPw2').value;
const errEl = document.getElementById('pwErr');
errEl.style.display = 'none';
if (!cur || !nw) { errEl.textContent = '현재·새 비밀번호를 입력하세요.'; errEl.style.display = 'block'; return; }
if (nw !== nw2) { errEl.textContent = '새 비밀번호가 일치하지 않습니다.'; errEl.style.display = 'block'; return; }
const fd = new FormData(); fd.append('current_password', cur); fd.append('new_password', nw);
try {
await API.patch('/accounts/me/password', fd);
document.getElementById('pwOk').style.display = 'block';
['curPw','newPw','newPw2'].forEach(id => document.getElementById(id).value = '');
setTimeout(() => document.getElementById('pwOk').style.display = 'none', 2500);
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>로그인 — EV AS 관리</title>
<link rel="stylesheet" href="/css/style.css">
<style>
body{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--navy);}
.login-box{background:white;border-radius:14px;padding:40px 36px;width:100%;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,.3);}
.login-logo{text-align:center;margin-bottom:28px;}
.login-logo h1{font-size:22px;font-weight:900;color:var(--navy);}
.login-logo p{font-size:12px;color:var(--gray4);margin-top:4px;}
.login-box .form-group{margin-bottom:14px;}
#err{color:var(--red);font-size:13px;text-align:center;min-height:18px;margin-bottom:8px;}
</style>
</head>
<body>
<div class="login-box">
<div class="login-logo">
<h1>⚡ EV AS 관리</h1>
<p>cs.byunc.com</p>
</div>
<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>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
async function doLogin() {
const u = document.getElementById('username').value.trim();
const p = document.getElementById('password').value;
if (!u || !p) { document.getElementById('err').textContent = '아이디와 비밀번호를 입력하세요.'; return; }
document.getElementById('loginBtn').disabled = true;
document.getElementById('err').textContent = '';
try {
const fd = new FormData();
fd.append('username', u); fd.append('password', p);
const res = await fetch('/api/auth/login', { method:'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
Auth.save(data.access_token, data.role, data.name, data.user_id);
if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html';
} catch(e) {
document.getElementById('err').textContent = e.message;
document.getElementById('loginBtn').disabled = false;
}
}
document.getElementById('loginBtn').addEventListener('click', doLogin);
document.getElementById('password').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
</script>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>제조사 대시보드</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS — 제조사</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/manufacturer/dashboard.html" class="active">📋 개선항목 목록</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">배정된 개선항목</h2>
<div class="stats" id="stats"></div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="fStatus" style="width:auto">
<option value="">전체</option>
<option value="registered">등록</option><option value="reviewing">검토중</option>
<option value="developing">개발중</option><option value="deployed">배포완료</option>
<option value="done">완료</option>
</select>
<button class="btn btn-outline btn-sm" onclick="load()">검색</button>
</div>
<div class="tbl-wrap"><table>
<thead><tr><th>#</th><th>제목</th><th>분류</th><th>우선순위</th><th>연결AS</th><th>상태</th><th>SW배포목표일</th><th>등록일</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
<div id="empty" class="alert alert-info" style="display:none">배정된 개선항목이 없습니다.</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['manufacturer']); Auth.renderNav(document.getElementById('navUser'));
const CAT={sw:'SW',hw:'HW',ui:'UI',firmware:'펌웨어',other:'기타'};
const PRI={urgent:'🔴 긴급',high:'🟠 높음',normal:'🟡 보통',low:'🟢 낮음'};
async function load() {
const imps = await API.get('/improvements?status='+document.getElementById('fStatus').value);
const counts = {registered:0,reviewing:0,developing:0,deployed:0,done:0};
imps.forEach(i => { if (counts[i.status]!==undefined) counts[i.status]++; });
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="stat-num">${imps.length}</div><div class="stat-label">전체</div></div>
<div class="stat warn"><div class="stat-num">${counts.registered+counts.reviewing}</div><div class="stat-label">검토 필요</div></div>
<div class="stat warn"><div class="stat-num">${counts.developing}</div><div class="stat-label">개발중</div></div>
<div class="stat good"><div class="stat-num">${counts.deployed+counts.done}</div><div class="stat-label">완료/배포</div></div>`;
document.getElementById('empty').style.display = imps.length ? 'none' : 'block';
document.getElementById('tbody').innerHTML = imps.map(i=>`
<tr onclick="location.href='/pages/manufacturer/improvement.html?id=${i.id}'">
<td>#${i.id}</td>
<td><strong>${i.title}</strong></td>
<td>${CAT[i.category]||i.category}</td>
<td>${PRI[i.priority]||i.priority}</td>
<td><span class="badge s-pending">${i.report_count}건</span></td>
<td>${Auth.statusBadge(i.status)}</td>
<td>${i.sw_deploy_target||'-'}</td>
<td>${Auth.fmtDt(i.created_at)}</td>
</tr>`).join('');
}
load();
</script></body></html>

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>개선항목 상세</title><link rel="stylesheet" href="/css/style.css"></head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS — 제조사</span><div id="navUser"></div></nav>
<div class="main" style="max-width:760px;margin:0 auto;">
<div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;">
<a href="/pages/manufacturer/dashboard.html" class="btn btn-outline btn-sm">← 목록</a>
<h2 id="pageTitle" style="font-size:17px;font-weight:700;color:var(--navy)">개선항목 상세</h2>
</div>
<div id="content"></div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['manufacturer']); Auth.renderNav(document.getElementById('navUser'));
const id = new URLSearchParams(location.search).get('id');
const CAT={sw:'SW개선',hw:'HW개선',ui:'UI개선',firmware:'펌웨어',other:'기타'};
const STATUS_OPTIONS=['reviewing','developing','deployed'];
const STATUS_LABEL={registered:'등록',reviewing:'검토중',developing:'개발중',deployed:'배포완료',done:'완료'};
async function load() {
const imp = await API.get('/improvements/'+id);
document.getElementById('pageTitle').textContent = `개선항목 #${imp.id}${imp.title}`;
document.getElementById('content').innerHTML = `
<div class="card">
<div class="card-title">📋 개선 요청 내용</div>
<table class="no-hover" style="font-size:13px">
<tr><td style="color:var(--gray4);width:100px">제목</td><td><strong>${imp.title}</strong></td></tr>
<tr><td style="color:var(--gray4)">분류</td><td>${CAT[imp.category]||imp.category}</td></tr>
<tr><td style="color:var(--gray4)">우선순위</td><td>${imp.priority}</td></tr>
<tr><td style="color:var(--gray4)">관련 부품</td><td>${imp.part_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">현재 상태</td><td>${Auth.statusBadge(imp.status)}</td></tr>
<tr><td style="color:var(--gray4)">배포 목표일</td><td>${imp.sw_deploy_target||'-'}</td></tr>
<tr><td style="color:var(--gray4)">등록일시</td><td>${Auth.fmtDt(imp.created_at)}</td></tr>
</table>
<div style="margin-top:14px">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">개선 요청 내용</div>
<div style="background:var(--gray1);padding:14px;border-radius:6px;font-size:13px;white-space:pre-wrap;line-height:1.7">${imp.description}</div>
</div>
${imp.attachments.length?`
<div style="margin-top:14px">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:6px">첨부 보고서</div>
${imp.attachments.map(a=>`<a href="${a.path}" target="_blank" class="btn btn-outline btn-sm" style="margin-right:6px;margin-bottom:6px">📄 ${a.name||'파일'}</a>`).join('')}
</div>`:''}
</div>
<div class="card">
<div class="card-title">🔗 관련 AS 신고</div>
${imp.report_ids.length?imp.report_ids.map(rid=>`
<div style="padding:8px;border:1px solid var(--gray2);border-radius:6px;margin-bottom:6px;font-size:13px">
신고 #${rid}
</div>`).join(''):'<div style="color:var(--gray4);font-size:13px">연결된 신고 없음</div>'}
</div>
<div class="card">
<div class="card-title">📝 진행 상태 업데이트</div>
<div class="alert alert-info" style="margin-bottom:14px">
아래에서 진행 상태를 업데이트하고 저장해 주세요. 변경 이력이 자동으로 기록됩니다.
</div>
<div class="form-row">
<div class="form-group">
<label>진행 상태 변경</label>
<select id="newStatus">
${STATUS_OPTIONS.map(s=>`<option value="${s}" ${imp.status===s?'selected':''}>${STATUS_LABEL[s]}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>SW 실제 배포일 (배포완료 선택 시)</label>
<input type="date" id="deployedAt" value="${imp.sw_deployed_at||''}">
</div>
</div>
<div class="form-group">
<label>진행 메모</label>
<textarea id="memo" rows="3" placeholder="현재 진행 상황, 예상 완료일, 이슈 등을 기재해 주세요.">${imp.manufacturer_memo||''}</textarea>
</div>
<div id="saveOk" class="alert alert-success" style="display:none">저장되었습니다.</div>
<button class="btn btn-primary" onclick="save()">저장</button>
</div>
<div class="card">
<div class="card-title">📜 변경 이력</div>
${imp.logs.length?`<div class="timeline">${imp.logs.map(l=>`
<div class="tl-item">
<div class="tl-time">${Auth.fmtDt(l.changed_at)}${l.by||'시스템'}</div>
<div class="tl-text">${l.old_status?`${STATUS_LABEL[l.old_status]||l.old_status}`:''}${STATUS_LABEL[l.new_status]||l.new_status}${l.memo?` / ${l.memo}`:''}</div>
</div>`).join('')}</div>`:'<div style="color:var(--gray4);font-size:13px">이력 없음</div>'}
</div>
`;
}
async function save() {
const fd = new FormData();
fd.append('status', document.getElementById('newStatus').value);
fd.append('memo', document.getElementById('memo').value);
fd.append('manufacturer_memo', document.getElementById('memo').value);
const d = document.getElementById('deployedAt').value;
if (d) fd.append('sw_deployed_at', d);
await API.patch('/improvements/'+id+'/status', fd);
const ok = document.getElementById('saveOk');
ok.style.display = 'block';
setTimeout(() => { ok.style.display='none'; load(); }, 1200);
}
load();
</script></body></html>

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>정비사 대시보드</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/mechanic/dashboard.html" class="active">📋 AS 목록</a>
<a href="/pages/mechanic/scan.html">📷 QR 스캔</a>
<a href="/pages/mechanic/history.html">🗂 처리 이력</a>
</div>
<div class="main">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;color:var(--navy)">AS 처리 목록</h2>
<a href="/pages/mechanic/scan.html" class="btn btn-accent">📷 QR 스캔하여 조치 시작</a>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">
<select id="filterStatus" style="width:auto">
<option value="">전체 상태</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
</select>
<button class="btn btn-outline btn-sm" onclick="load()">새로고침</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>#</th><th>충전기</th><th>충전소</th><th>종류</th><th>문제유형</th><th>신고일시</th><th>상태</th><th>조치</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div id="empty" class="alert alert-info" style="display:none">처리 대기 중인 AS가 없습니다.</div>
</div>
</div>
</div>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
async function load() {
const status = document.getElementById('filterStatus').value;
const rows = await API.get('/repairs/pending' + (status ? '?status='+status : ''));
const tbody = document.getElementById('tbody');
if (!rows.length) { tbody.innerHTML=''; document.getElementById('empty').style.display='block'; return; }
document.getElementById('empty').style.display='none';
tbody.innerHTML = rows.map(r => `
<tr onclick="location.href='/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}'">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong><br><small>${r.charger_name||''}</small></td>
<td>${r.station_name||'-'}</td>
<td>${r.charger_type||'-'}</td>
<td>${(r.issue_types||[]).join(', ')}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${Auth.statusBadge(r.status)}</td>
<td><a class="btn btn-primary btn-sm" href="/pages/mechanic/repair.html?charger_id=${r.charger_id}&report_id=${r.id}" onclick="event.stopPropagation()">조치</a></td>
</tr>`).join('');
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +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">
<style>
.upload-area{border:2px dashed var(--gray3);border-radius:8px;padding:12px;text-align:center;cursor:pointer;color:var(--gray4);font-size:12px;transition:border-color .15s;margin-bottom:6px;}
.upload-area:hover{border-color:var(--accent);}
.photo-preview{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.photo-preview img{width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--gray3);}
.photo-info{font-size:11px;margin-top:4px;min-height:14px;color:var(--gray4);}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="main" style="max-width:640px;margin:0 auto;">
<div style="margin-bottom:14px;">
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
</div>
<div id="chargerCard" class="card"></div>
<div class="card">
<div class="card-title">📋 동일 충전기 신고 목록 (중복 선택 가능)</div>
<div id="reportList"></div>
</div>
<div class="card">
<div class="card-title">🔧 조치 내역 입력</div>
<div class="form-group">
<label>조치 유형 <span class="req">*</span></label>
<div class="check-group" id="repairTypes">
<label class="check-item"><input type="checkbox" value="부품교체"> 부품 교체</label>
<label class="check-item"><input type="checkbox" value="재시작"> 재시작</label>
<label class="check-item"><input type="checkbox" value="설정변경"> 설정 변경</label>
<label class="check-item"><input type="checkbox" value="기타"> 기타</label>
</div>
</div>
<div class="form-group">
<label>조치 상세 내용 <span class="req">*</span></label>
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
</div>
<!-- 조치 전 사진 -->
<div class="form-row">
<div class="form-group">
<label>📷 조치 전 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
<label class="upload-area" for="photosBefore">📷 촬영 또는 앨범 선택</label>
<input type="file" id="photosBefore" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="previewBefore"></div>
<div class="photo-info" id="infoBefore"></div>
</div>
<div class="form-group">
<label>📷 조치 후 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 가능)</span></label>
<label class="upload-area" for="photosAfter">📷 촬영 또는 앨범 선택</label>
<input type="file" id="photosAfter" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="previewAfter"></div>
<div class="photo-info" id="infoAfter"></div>
</div>
</div>
<div class="form-group">
<label>처리 상태 <span class="req">*</span></label>
<select id="resultStatus">
<option value="done">✅ 처리 완료</option>
<option value="waiting">⏳ 부품 대기</option>
<option value="revisit">🔄 재방문 필요</option>
</select>
</div>
<div class="alert alert-info" style="margin-bottom:14px;">
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록)
</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>
<script src="/js/imageCompress.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search);
const chargerId = params.get('charger_id');
const initReportId = params.get('report_id');
const startTime = new Date();
document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR');
const selectedReports = new Set();
if (initReportId) selectedReports.add(parseInt(initReportId));
async function load() {
const charger = await API.get('/chargers/' + chargerId);
document.getElementById('chargerCard').innerHTML = `
<div class="card-title">⚡ 충전기 정보</div>
<div class="form-row">
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${charger.id}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${charger.name}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${charger.station_name}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name || '-'}</strong></div>
</div>`;
const reports = await API.get('/repairs/charger/' + chargerId + '/open');
const list = document.getElementById('reportList');
if (!reports.length) {
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
return;
}
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) {
if (checked) { selectedReports.add(id); label.style.background = '#E3EDFF'; }
else { selectedReports.delete(id); label.style.background = 'white'; }
}
// 이미지 압축 + 다중 선택 프리뷰
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
ImageCompressor.setupPreview('photosAfter', 'previewAfter', 'infoAfter');
document.getElementById('submitBtn').addEventListener('click', async () => {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; }
const desc = document.getElementById('description').value.trim();
if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; }
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '저장 중...';
const fd = new FormData();
fd.append('report_ids', JSON.stringify(rids));
fd.append('repair_types', JSON.stringify(types));
fd.append('description', desc);
fd.append('result_status', document.getElementById('resultStatus').value);
Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f));
Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f));
try {
await API.post('/repairs', fd);
alert('✅ 조치 완료 저장되었습니다.');
location.href = '/pages/mechanic/dashboard.html';
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '조치 완료 저장';
}
});
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>조치 입력</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리</span>
<div id="navUser"></div>
</nav>
<div class="main" style="max-width:640px;margin:0 auto;">
<div style="margin-bottom:14px;">
<a href="/pages/mechanic/dashboard.html" class="btn btn-outline btn-sm">← 목록으로</a>
</div>
<div id="chargerCard" class="card"></div>
<div class="card">
<div class="card-title">📋 동일 충전기 신고 목록 (중복 선택 가능)</div>
<div id="reportList"></div>
</div>
<div class="card">
<div class="card-title">🔧 조치 내역 입력</div>
<div class="form-group">
<label>조치 유형 <span class="req">*</span></label>
<div class="check-group" id="repairTypes">
<label class="check-item"><input type="checkbox" value="부품교체"> 부품 교체</label>
<label class="check-item"><input type="checkbox" value="재시작"> 재시작</label>
<label class="check-item"><input type="checkbox" value="설정변경"> 설정 변경</label>
<label class="check-item"><input type="checkbox" value="기타"> 기타</label>
</div>
</div>
<div class="form-group">
<label>조치 상세 내용 <span class="req">*</span></label>
<textarea id="description" rows="4" placeholder="조치한 내용을 상세히 입력하세요."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>📷 조치 전 사진</label>
<input type="file" id="photosBefore" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="previewBefore"></div>
</div>
<div class="form-group">
<label>📷 조치 후 사진</label>
<input type="file" id="photosAfter" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="previewAfter"></div>
</div>
</div>
<div class="form-group">
<label>처리 상태 <span class="req">*</span></label>
<select id="resultStatus">
<option value="done">✅ 처리 완료</option>
<option value="waiting">⏳ 부품 대기</option>
<option value="revisit">🔄 재방문 필요</option>
</select>
</div>
<div class="alert alert-info" style="margin-bottom:14px;">
🕐 조치 시작 시간: <strong id="startedAt"></strong> (자동 기록)
</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>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const params = new URLSearchParams(location.search);
const chargerId = params.get('charger_id');
const initReportId = params.get('report_id');
const startTime = new Date();
document.getElementById('startedAt').textContent = startTime.toLocaleString('ko-KR');
const selectedReports = new Set();
if (initReportId) selectedReports.add(parseInt(initReportId));
async function load() {
const charger = await API.get('/chargers/' + chargerId);
document.getElementById('chargerCard').innerHTML = `
<div class="card-title">⚡ 충전기 정보</div>
<div class="form-row">
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${charger.id}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전기명</label><strong>${charger.name}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>${charger.station_name}</strong></div>
<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>${charger.cpo_name||'-'}</strong></div>
</div>`;
const reports = await API.get('/repairs/charger/' + chargerId + '/open');
const list = document.getElementById('reportList');
if (!reports.length) { list.innerHTML='<div class="alert alert-info">미처리 신고가 없습니다.</div>'; return; }
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) {
if (checked) { selectedReports.add(id); label.style.background='#E3EDFF'; }
else { selectedReports.delete(id); label.style.background='white'; }
}
['photosBefore','photosAfter'].forEach(id => {
const prevId = id === 'photosBefore' ? 'previewBefore' : 'previewAfter';
document.getElementById(id).addEventListener('change', function() {
const prev = document.getElementById(prevId);
prev.innerHTML = '';
Array.from(this.files).forEach(f => {
const img = document.createElement('img'); img.src = URL.createObjectURL(f); prev.appendChild(img);
});
});
});
document.getElementById('submitBtn').addEventListener('click', async () => {
const rids = [...selectedReports];
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
const types = [...document.querySelectorAll('#repairTypes input:checked')].map(c => c.value);
if (!types.length) { showErr('조치 유형을 1개 이상 선택해 주세요.'); return; }
const desc = document.getElementById('description').value.trim();
if (!desc) { showErr('조치 상세 내용을 입력해 주세요.'); return; }
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '저장 중...';
const fd = new FormData();
fd.append('report_ids', JSON.stringify(rids));
fd.append('repair_types', JSON.stringify(types));
fd.append('description', desc);
fd.append('result_status', document.getElementById('resultStatus').value);
Array.from(document.getElementById('photosBefore').files).forEach(f => fd.append('photos_before', f));
Array.from(document.getElementById('photosAfter').files).forEach(f => fd.append('photos_after', f));
try {
await API.post('/repairs', fd);
alert('✅ 조치 완료 저장되었습니다.');
location.href = '/pages/mechanic/dashboard.html';
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '조치 완료 저장';
}
});
function showErr(msg) {
const el = document.getElementById('formErr'); el.textContent = msg; el.style.display = 'block';
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!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">
<style>
#reader{width:100%;max-width:400px;margin:0 auto;border-radius:10px;overflow:hidden;}
.scan-wrap{max-width:480px;margin:0 auto;padding:20px;}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ QR 스캔</span>
<div id="navUser"></div>
</nav>
<div class="scan-wrap">
<div class="alert alert-info" style="margin-bottom:16px;">충전기의 QR 코드를 카메라로 인식해 주세요.</div>
<div id="reader"></div>
<div id="result" class="alert alert-success" style="display:none;margin-top:14px;"></div>
<div style="margin-top:16px;">
<div class="form-group">
<label>충전기 ID 직접 입력</label>
<div style="display:flex;gap:8px;">
<input type="text" id="manualId" placeholder="예: CG-003">
<button class="btn btn-primary" onclick="goManual()">이동</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
const scanner = new Html5Qrcode("reader");
scanner.start({ facingMode: "environment" },
{ fps: 10, qrbox: { width: 250, height: 250 } },
qrText => {
scanner.stop();
document.getElementById('result').style.display = 'block';
document.getElementById('result').textContent = '인식됨: ' + qrText + ' — 이동 중...';
// URL에서 charger_id 추출
try {
const url = new URL(qrText);
const parts = url.pathname.split('/');
const chargerId = parts[parts.length - 1];
setTimeout(() => location.href = `/pages/mechanic/repair.html?charger_id=${chargerId}`, 800);
} catch {
setTimeout(() => location.href = `/pages/mechanic/repair.html?charger_id=${qrText}`, 800);
}
},
() => {}
).catch(() => {
document.getElementById('reader').innerHTML = '<div class="alert alert-warn">카메라 접근이 거부되었습니다. 직접 입력을 이용해 주세요.</div>';
});
function goManual() {
const id = document.getElementById('manualId').value.trim();
if (!id) return;
location.href = `/pages/mechanic/repair.html?charger_id=${id}`;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,522 @@
<!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>
body { background: var(--gray1); }
.report-wrap { max-width: 480px; margin: 0 auto; padding: 20px 16px 40px; }
/* ── 충전기 정보 헤더 ── */
.charger-info {
background: var(--navy);
color: white;
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 14px;
}
.charger-info h2 { font-size: 16px; margin-bottom: 8px; color: var(--accent); }
.charger-info .row {
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255,255,255,.75); margin-top: 4px;
}
/* ── 현황 섹션 ── */
.status-section {
border-radius: 10px;
overflow: hidden;
margin-bottom: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.status-header {
background: #1A2B4A;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.status-header-left { display: flex; align-items: center; gap: 8px; }
.status-header h3 { font-size: 14px; font-weight: 700; color: white; }
.status-badge-count {
background: var(--orange);
color: white;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
}
.status-toggle-icon { font-size: 12px; color: rgba(255,255,255,.5); transition: transform .2s; }
.status-toggle-icon.open { transform: rotate(180deg); }
.status-body { background: white; }
/* ── 개별 신고 현황 카드 ── */
.report-status-card {
padding: 14px 16px;
border-bottom: 1px solid var(--gray2);
position: relative;
}
.report-status-card:last-child { border-bottom: none; }
.rsc-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.rsc-num { font-size: 12px; font-weight: 700; color: var(--navy2); }
.rsc-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
}
.rsc-badge.pending_approval { background: #FFF3CD; color: #856404; }
.rsc-badge.pending { background: #DBEAFE; color: #1565C0; }
.rsc-badge.in_progress { background: #FEF3C7; color: #B45309; }
.rsc-badge.waiting { background: #FFE4E4; color: #C0392B; }
.rsc-badge.revisit { background: #EDE9FE; color: #5B21B6; }
.rsc-issues {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 8px;
}
.rsc-issue-tag {
background: var(--gray1);
color: var(--text2);
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--gray2);
}
.rsc-meta {
font-size: 11px;
color: var(--gray4);
display: flex;
flex-direction: column;
gap: 3px;
}
.rsc-mechanic {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--green);
font-weight: 600;
margin-top: 6px;
padding: 6px 10px;
background: #E8F8F2;
border-radius: 6px;
}
.rsc-progress-bar {
height: 4px;
background: var(--gray2);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.rsc-progress-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width .4s;
}
/* ── 접기/펼치기 버튼 ── */
.collapse-body { overflow: hidden; transition: max-height .3s ease; }
.collapse-body.collapsed { max-height: 0 !important; }
/* ── 신규 신고 폼 ── */
.section {
background: white;
border-radius: 10px;
padding: 18px;
margin-bottom: 14px;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.section h3 {
font-size: 14px; font-weight: 700; color: var(--navy);
border-left: 3px solid var(--accent); padding-left: 9px; margin-bottom: 12px;
}
.issue-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.issue-btn {
padding: 10px; border: 1px solid var(--gray3); border-radius: 7px;
background: white; cursor: pointer; font-size: 13px; text-align: center;
transition: all .15s;
}
.issue-btn.sel { background: #E3EDFF; border-color: var(--accent); font-weight: 700; color: var(--blue); }
.upload-area {
border: 2px dashed var(--gray3); border-radius: 8px; padding: 14px;
text-align: center; cursor: pointer; color: var(--gray4); font-size: 13px;
transition: border-color .15s; margin-bottom: 6px; display: block;
}
.upload-area:hover { border-color: var(--accent); }
.photo-preview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.photo-preview img {
width: 80px; height: 80px; object-fit: cover;
border-radius: 6px; border: 1px solid var(--gray3);
}
.photo-info { font-size: 11px; margin-top: 4px; min-height: 16px; }
/* ── 결과 화면 ── */
#resultBox {
background: var(--navy); color: white;
border-radius: 10px; padding: 24px; text-align: center; display: none;
}
#resultBox h2 { color: var(--green); font-size: 20px; margin-bottom: 10px; }
</style>
</head>
<body>
<div class="report-wrap">
<!-- 충전기 정보 -->
<div id="chargerInfo" class="charger-info">
<h2>⚡ 충전기 정보 로딩 중...</h2>
</div>
<!-- ★ 현재 접수 현황 섹션 -->
<div class="status-section" id="statusSection" style="display:none">
<div class="status-header" onclick="toggleStatus()">
<div class="status-header-left">
<span>🔔</span>
<h3>현재 접수된 신고 현황</h3>
<span class="status-badge-count" id="statusCount">0</span>
</div>
<span class="status-toggle-icon open" id="statusToggleIcon"></span>
</div>
<div class="collapse-body" id="statusBody">
<div class="status-body" id="statusList"></div>
<div style="padding:12px 16px;background:#F4F7FB;border-top:1px solid var(--gray2);">
<p style="font-size:12px;color:var(--gray4);line-height:1.7">
📌 동일 고장이 이미 접수되어 처리 중인 경우 추가 신고는 필요 없습니다.<br>
신고가 미처리 상태이거나 다른 문제가 있다면 아래에서 새로 신고해 주세요.
</p>
</div>
</div>
</div>
<!-- 신고 없음 안내 -->
<div id="noReportNotice" style="display:none;background:#E8F8F2;border:1px solid var(--green);border-radius:10px;padding:12px 16px;margin-bottom:14px;font-size:13px;color:#00531A;">
✅ 이 충전기에 현재 접수된 신고가 없습니다. 고장이 확인되면 아래에서 신고해 주세요.
</div>
<!-- 신고 폼 -->
<div id="mainForm">
<div class="section">
<h3>📍 신고 위치</h3>
<div id="gpsStatus" class="alert alert-info">위치 정보 수집 중...</div>
<input type="hidden" id="gpsLat">
<input type="hidden" id="gpsLng">
</div>
<div class="section">
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
<div class="issue-grid" id="issueGrid"></div>
<div id="errorCodeWrap" style="margin-top:10px;display:none;">
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">
</div>
<div id="etcWrap" style="margin-top:10px;display:none;">
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
</div>
</div>
<div class="section">
<h3>🕐 문제 발생 시각</h3>
<input type="datetime-local" id="occurredAt">
<div style="font-size:11px;color:var(--gray4);margin-top:4px">언제부터 문제가 발생했나요? (선택)</div>
</div>
<div class="section">
<h3>📷 사진 첨부</h3>
<div class="form-group">
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
충전기 사진 <span style="color:var(--red)">*필수</span>
<span style="font-size:11px;color:var(--gray4);font-weight:400">(여러 장 선택 가능)</span>
</label>
<label class="upload-area" for="chargerPhoto">
📷 탭하여 촬영하거나 앨범에서 선택<br>
<span style="font-size:11px">여러 장 동시 선택 가능</span>
</label>
<input type="file" id="chargerPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="chargerPreview"></div>
<div class="photo-info" id="chargerInfo2" style="color:var(--gray4)"></div>
</div>
<div class="form-group" style="margin-top:14px">
<label style="font-size:13px;font-weight:600;color:var(--navy2);margin-bottom:6px;display:block">
차량 사진 <span style="font-size:11px;color:var(--gray4);font-weight:400">(선택 · 여러 장 가능)</span>
</label>
<label class="upload-area" for="carPhoto">📷 탭하여 촬영하거나 앨범에서 선택</label>
<input type="file" id="carPhoto" accept="image/*" multiple style="display:none">
<div class="photo-preview" id="carPreview"></div>
<div class="photo-info" id="carInfo" style="color:var(--gray4)"></div>
</div>
</div>
<div class="section">
<h3>📝 상세 설명 (선택)</h3>
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
</div>
<div class="section">
<h3>📞 연락처 (선택)</h3>
<input type="tel" id="contact" placeholder="010-0000-0000">
<div style="margin-top:10px;display:flex;align-items:flex-start;gap:8px;">
<input type="checkbox" id="consent" style="width:auto;margin-top:2px;accent-color:var(--accent)">
<label for="consent" style="font-size:12px;color:var(--gray4);cursor:pointer">
개인정보(연락처)를 AS 처리 목적으로 수집·이용하는 것에 동의합니다.
</label>
</div>
</div>
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">신고 접수하기</button>
</div>
<div id="resultBox">
<h2>✅ 신고 접수 완료</h2>
<p id="resultMsg"></p>
<p style="margin-top:12px;font-size:13px;color:rgba(255,255,255,.6)">빠른 시간 내에 처리하겠습니다.</p>
<button onclick="location.reload()"
style="margin-top:16px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.3);color:white;padding:8px 20px;border-radius:8px;font-size:13px;cursor:pointer;">
🔄 현황 다시 보기
</button>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/imageCompress.js"></script>
<script>
const ISSUES = [
{key:'충전불가', label:'⚡ 충전 불가'},
{key:'화면오류', label:'🖥 화면 오류'},
{key:'케이블불량',label:'🔌 케이블 불량'},
{key:'결제오류', label:'💳 결제 오류'},
{key:'외관손상', label:'🔨 외관 손상'},
{key:'에러발생', label:'⚠️ 에러 발생'},
{key:'기타', label:'📋 기타'},
];
const STATUS_ICON = {
pending_approval: '🕐',
pending: '📋',
in_progress: '🔧',
waiting: '⏳',
revisit: '🔄',
};
const PROGRESS_PCT = {
pending_approval: 10,
pending: 25,
in_progress: 65,
waiting: 50,
revisit: 80,
};
const selected = new Set();
const chargerId = location.pathname.split('/').pop();
let isStatusOpen = true;
// ── 충전기 정보 로드 ──
async function loadCharger() {
try {
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
document.getElementById('chargerInfo').innerHTML = `
<h2>⚡ ${c.name}</h2>
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
<div class="row"><span>종류</span><span>${c.charger_type || '-'}</span></div>
<div class="row"><span>CPO</span><span>${c.cpo_name || '-'}</span></div>
<div class="row"><span>설치일</span><span>${c.installed_at || '-'}</span></div>
`;
} catch {
document.getElementById('chargerInfo').innerHTML =
'<h2 style="color:#ff8888">충전기 정보를 불러올 수 없습니다.</h2>';
}
}
// ── 현재 접수 현황 로드 ──
async function loadStatus() {
try {
const reports = await fetch('/api/reports/public/' + chargerId).then(r => r.json());
if (!reports.length) {
document.getElementById('noReportNotice').style.display = 'block';
return;
}
document.getElementById('statusSection').style.display = 'block';
document.getElementById('statusCount').textContent = reports.length + '건';
const list = document.getElementById('statusList');
list.innerHTML = reports.map(r => {
const pct = PROGRESS_PCT[r.status] || 20;
const icon = STATUS_ICON[r.status] || '📋';
const dt = r.reported_at
? new Date(r.reported_at).toLocaleString('ko-KR',
{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})
: '';
const mechHtml = r.mechanic_name
? `<div class="rsc-mechanic">🔧 ${r.mechanic_name} 정비사가 처리 중입니다</div>`
: '';
const detailHtml = r.issue_detail
? `<div style="font-size:12px;color:var(--text2);margin-top:4px;padding:6px 8px;background:var(--gray1);border-radius:5px;">${r.issue_detail}</div>`
: '';
return `
<div class="report-status-card">
<div class="rsc-top">
<span class="rsc-num">#${r.id} · ${dt}</span>
<span class="rsc-badge ${r.status}">${icon} ${r.status_label}</span>
</div>
<div class="rsc-issues">
${(r.issue_types || []).map(t =>
`<span class="rsc-issue-tag">${t}</span>`).join('')}
</div>
${detailHtml}
${r.photo_count > 0
? `<div style="font-size:11px;color:var(--gray4);margin-top:4px;">📷 사진 ${r.photo_count}장 첨부됨</div>`
: ''}
${mechHtml}
<div class="rsc-progress-bar">
<div class="rsc-progress-fill" style="width:${pct}%"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--gray4);margin-top:4px;">
<span>접수</span><span>처리중</span><span>완료</span>
</div>
</div>`;
}).join('');
} catch(e) {
console.warn('현황 로드 실패:', e);
}
}
// ── 현황 접기/펼치기 ──
function toggleStatus() {
isStatusOpen = !isStatusOpen;
const body = document.getElementById('statusBody');
const icon = document.getElementById('statusToggleIcon');
if (isStatusOpen) {
body.style.maxHeight = body.scrollHeight + 'px';
icon.classList.add('open');
} else {
body.style.maxHeight = '0';
icon.classList.remove('open');
}
}
// 초기 펼침 높이 설정
function initCollapseHeight() {
const body = document.getElementById('statusBody');
if (body) body.style.maxHeight = body.scrollHeight + 'px';
}
// ── GPS ──
navigator.geolocation?.getCurrentPosition(
pos => {
document.getElementById('gpsLat').value = pos.coords.latitude;
document.getElementById('gpsLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').textContent =
`📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`;
document.getElementById('gpsStatus').className = 'alert alert-success';
},
() => {
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
document.getElementById('gpsStatus').className = 'alert alert-warn';
}
);
// ── 문제 유형 버튼 ──
const grid = document.getElementById('issueGrid');
ISSUES.forEach(issue => {
const btn = document.createElement('button');
btn.className = 'issue-btn';
btn.textContent = issue.label;
btn.type = 'button';
btn.onclick = () => {
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
else { selected.add(issue.key); btn.classList.add('sel'); }
document.getElementById('errorCodeWrap').style.display =
selected.has('에러발생') ? 'block' : 'none';
document.getElementById('etcWrap').style.display =
selected.has('기타') ? 'block' : 'none';
};
grid.appendChild(btn);
});
// ── 이미지 압축 + 다중 선택 ──
ImageCompressor.setupPreview('chargerPhoto', 'chargerPreview', 'chargerInfo2');
ImageCompressor.setupPreview('carPhoto', 'carPreview', 'carInfo');
// ── 신고 제출 ──
document.getElementById('submitBtn').addEventListener('click', async () => {
const issues = [...selected];
if (!issues.length) { showErr('문제 유형을 1개 이상 선택해 주세요.'); return; }
const chargerFiles = document.getElementById('chargerPhoto').files;
if (!chargerFiles.length) { showErr('충전기 사진을 1장 이상 첨부해 주세요.'); return; }
const contact = document.getElementById('contact').value.trim();
const consent = document.getElementById('consent').checked;
if (contact && !consent) {
showErr('연락처를 입력한 경우 개인정보 수집 동의가 필요합니다.'); return;
}
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...';
const fd = new FormData();
fd.append('charger_id', chargerId);
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('detail').value);
fd.append('error_code', document.getElementById('errorCode').value);
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
fd.append('contact', contact);
fd.append('consent', consent);
fd.append('gps_lat', document.getElementById('gpsLat').value || '');
fd.append('gps_lng', document.getElementById('gpsLng').value || '');
Array.from(chargerFiles).forEach(f => fd.append('photos', f));
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports', { method: 'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
document.getElementById('mainForm').style.display = 'none';
document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`;
// 현황 새로고침
document.getElementById('statusSection').style.display = 'none';
document.getElementById('noReportNotice').style.display = 'none';
await loadStatus();
initCollapseHeight();
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '신고 접수하기';
}
});
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
el.scrollIntoView({ behavior: 'smooth' });
}
// ── 초기 로드 ──
(async () => {
await Promise.all([loadCharger(), loadStatus()]);
initCollapseHeight();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,214 @@
<!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>
body{background:var(--gray1);}
.report-wrap{max-width:480px;margin:0 auto;padding:20px 16px 40px;}
.charger-info{background:var(--navy);color:white;border-radius:10px;padding:16px 18px;margin-bottom:18px;}
.charger-info h2{font-size:16px;margin-bottom:8px;color:var(--accent);}
.charger-info .row{display:flex;justify-content:space-between;font-size:12px;color:rgba(255,255,255,.75);margin-top:4px;}
.section{background:white;border-radius:10px;padding:18px;margin-bottom:14px;box-shadow:0 2px 6px rgba(0,0,0,.06);}
.section h3{font-size:14px;font-weight:700;color:var(--navy);border-left:3px solid var(--accent);padding-left:9px;margin-bottom:12px;}
.issue-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.issue-btn{padding:10px;border:1px solid var(--gray3);border-radius:7px;background:white;cursor:pointer;font-size:13px;text-align:center;transition:all .15s;}
.issue-btn.sel{background:#E3EDFF;border-color:var(--accent);font-weight:700;color:var(--blue);}
#submitBtn{margin-top:4px;}
#resultBox{background:var(--navy);color:white;border-radius:10px;padding:24px;text-align:center;display:none;}
#resultBox h2{color:var(--green);font-size:20px;margin-bottom:10px;}
</style>
</head>
<body>
<div class="report-wrap">
<div id="chargerInfo" class="charger-info">
<h2>⚡ 충전기 정보 로딩 중...</h2>
</div>
<div id="mainForm">
<div class="section">
<h3>📍 신고 위치</h3>
<div id="gpsStatus" class="alert alert-info">위치 정보 수집 중...</div>
<input type="hidden" id="gpsLat">
<input type="hidden" id="gpsLng">
</div>
<div class="section">
<h3>🔴 문제 유형 <span style="color:var(--red);font-size:11px">* 1개 이상 선택</span></h3>
<div class="issue-grid" id="issueGrid"></div>
<div id="errorCodeWrap" style="margin-top:10px;display:none;">
<input type="text" id="errorCode" placeholder="에러 코드 입력 (예: E001)">
</div>
<div id="etcWrap" style="margin-top:10px;display:none;">
<input type="text" id="etcText" placeholder="기타 문제 내용 입력">
</div>
</div>
<div class="section">
<h3>🕐 문제 발생 시각</h3>
<input type="datetime-local" id="occurredAt">
<div style="font-size:11px;color:var(--gray4);margin-top:4px">언제부터 문제가 발생했나요? (선택)</div>
</div>
<div class="section">
<h3>📷 사진 첨부</h3>
<div class="form-group">
<label>충전기 사진 <span style="color:var(--red)">*필수</span></label>
<input type="file" id="chargerPhoto" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="chargerPreview"></div>
</div>
<div class="form-group">
<label>차량 사진 (선택)</label>
<input type="file" id="carPhoto" accept="image/*" capture="environment" multiple>
<div class="photo-preview" id="carPreview"></div>
</div>
</div>
<div class="section">
<h3>📝 상세 설명 (선택)</h3>
<textarea id="detail" placeholder="문제 상황을 자세히 설명해 주세요." rows="3"></textarea>
</div>
<div class="section">
<h3>📞 연락처 (선택)</h3>
<input type="tel" id="contact" placeholder="010-0000-0000">
<div style="margin-top:10px;display:flex;align-items:flex-start;gap:8px;">
<input type="checkbox" id="consent" style="width:auto;margin-top:2px;accent-color:var(--accent)">
<label for="consent" style="font-size:12px;color:var(--gray4);cursor:pointer">
개인정보(연락처)를 AS 처리 목적으로 수집·이용하는 것에 동의합니다.
</label>
</div>
</div>
<div id="formErr" class="alert alert-danger" style="display:none"></div>
<button class="btn btn-primary btn-lg" id="submitBtn">신고 접수하기</button>
</div>
<div id="resultBox">
<h2>✅ 신고 접수 완료</h2>
<p id="resultMsg"></p>
<p style="margin-top:12px;font-size:13px;color:rgba(255,255,255,.6)">
빠른 시간 내에 처리하겠습니다.</p>
</div>
</div>
<script src="/js/api.js"></script>
<script>
const ISSUES = [
{key:'충전불가',label:'⚡ 충전 불가'},
{key:'화면오류',label:'🖥 화면 오류'},
{key:'케이블불량',label:'🔌 케이블 불량'},
{key:'결제오류',label:'💳 결제 오류'},
{key:'외관손상',label:'🔨 외관 손상'},
{key:'에러발생',label:'⚠️ 에러 발생'},
{key:'기타',label:'📋 기타'},
];
const selected = new Set();
const chargerId = location.pathname.split('/').pop();
// 충전기 정보 로드
async function loadCharger() {
try {
const c = await fetch('/api/chargers/' + chargerId).then(r => r.json());
document.getElementById('chargerInfo').innerHTML = `
<h2>⚡ ${c.name}</h2>
<div class="row"><span>충전소</span><span>${c.station_name}</span></div>
<div class="row"><span>종류</span><span>${c.charger_type || '-'}</span></div>
<div class="row"><span>CPO</span><span>${c.cpo_name || '-'}</span></div>
<div class="row"><span>설치일</span><span>${c.installed_at || '-'}</span></div>
`;
} catch { document.getElementById('chargerInfo').innerHTML = '<h2 style="color:#ff8888">충전기 정보를 불러올 수 없습니다.</h2>'; }
}
// GPS 수집
navigator.geolocation?.getCurrentPosition(pos => {
document.getElementById('gpsLat').value = pos.coords.latitude;
document.getElementById('gpsLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').textContent = `📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`;
document.getElementById('gpsStatus').className = 'alert alert-success';
}, () => {
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
document.getElementById('gpsStatus').className = 'alert alert-warn';
});
// 문제 유형 버튼
const grid = document.getElementById('issueGrid');
ISSUES.forEach(issue => {
const btn = document.createElement('button');
btn.className = 'issue-btn';
btn.textContent = issue.label;
btn.onclick = () => {
if (selected.has(issue.key)) { selected.delete(issue.key); btn.classList.remove('sel'); }
else { selected.add(issue.key); btn.classList.add('sel'); }
document.getElementById('errorCodeWrap').style.display = selected.has('에러발생') ? 'block' : 'none';
document.getElementById('etcWrap').style.display = selected.has('기타') ? 'block' : 'none';
};
grid.appendChild(btn);
});
// 사진 미리보기
function setupPreview(inputId, previewId) {
document.getElementById(inputId).addEventListener('change', function() {
const preview = document.getElementById(previewId);
preview.innerHTML = '';
Array.from(this.files).forEach(f => {
const img = document.createElement('img');
img.src = URL.createObjectURL(f);
preview.appendChild(img);
});
});
}
setupPreview('chargerPhoto', 'chargerPreview');
setupPreview('carPhoto', 'carPreview');
// 제출
document.getElementById('submitBtn').addEventListener('click', async () => {
const issues = [...selected];
if (issues.length === 0) { showErr('문제 유형을 1개 이상 선택해 주세요.'); return; }
const chargerPhotos = document.getElementById('chargerPhoto').files;
if (chargerPhotos.length === 0) { showErr('충전기 사진을 첨부해 주세요.'); return; }
const consent = document.getElementById('consent').checked;
const contact = document.getElementById('contact').value.trim();
if (contact && !consent) { showErr('연락처를 입력한 경우 개인정보 수집 동의가 필요합니다.'); return; }
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...';
const fd = new FormData();
fd.append('charger_id', chargerId);
fd.append('issue_types', JSON.stringify(issues));
fd.append('issue_detail', document.getElementById('detail').value);
fd.append('error_code', document.getElementById('errorCode').value);
fd.append('occurred_at', document.getElementById('occurredAt').value || '');
fd.append('contact', contact);
fd.append('consent', consent);
fd.append('gps_lat', document.getElementById('gpsLat').value || '');
fd.append('gps_lng', document.getElementById('gpsLng').value || '');
Array.from(chargerPhotos).forEach(f => fd.append('photos', f));
Array.from(document.getElementById('carPhoto').files).forEach(f => fd.append('photos', f));
try {
const res = await fetch('/api/reports', { method:'POST', body:fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
document.getElementById('mainForm').style.display = 'none';
document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultMsg').textContent = `접수번호: #${data.id}`;
} catch(e) {
showErr(e.message);
document.getElementById('submitBtn').disabled = false;
document.getElementById('submitBtn').textContent = '신고 접수하기';
}
});
function showErr(msg) {
const el = document.getElementById('formErr');
el.textContent = msg; el.style.display = 'block';
el.scrollIntoView({behavior:'smooth'});
}
loadCharger();
</script>
</body>
</html>

50
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,50 @@
# -------------------------------------------------------
# Orange Pi 5 전용 Nginx 설정 (HTTP only)
# SSL/도메인 처리는 상위 서버(5825u)에서 담당
# 외부 접근: 192.168.0.114:5700
# -------------------------------------------------------
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
client_max_body_size 20M;
sendfile on;
access_log off;
error_log /var/log/nginx/error.log warn;
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
# QR 접속: /report/{charger_id} → report.html
location ~ ^/report/(.+)$ {
try_files /pages/report.html =404;
}
# API 프록시
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# 업로드 파일 서빙
location /uploads/ {
alias /var/www/uploads/;
expires 7d;
}
# 정적 파일 (SPA 라우팅)
location / {
try_files $uri $uri/ /index.html;
}
}
}