초기 커밋 - EV AS 관리 시스템
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
uploads/
|
||||
certbot/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
postgres_data/
|
||||
196
README.md
Normal file
196
README.md
Normal 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
14
backend/Dockerfile
Normal 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
70
backend/auth.py
Normal 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
18
backend/database.py
Normal 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
169
backend/init_db.sql
Normal 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
53
backend/main.py
Normal 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
168
backend/models.py
Normal 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
13
backend/requirements.txt
Normal 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
|
||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
77
backend/routers/accounts.py
Normal file
77
backend/routers/accounts.py
Normal 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}
|
||||
35
backend/routers/auth_router.py
Normal file
35
backend/routers/auth_router.py
Normal 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
126
backend/routers/chargers.py
Normal 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
97
backend/routers/costs.py
Normal 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
245
backend/routers/export.py
Normal 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, "개선항목목록")
|
||||
224
backend/routers/export.py.bak
Normal file
224
backend/routers/export.py.bak
Normal 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}"})
|
||||
108
backend/routers/improvements.py
Normal file
108
backend/routers/improvements.py
Normal 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
118
backend/routers/repairs.py
Normal 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
183
backend/routers/reports.py
Normal 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
|
||||
136
backend/routers/reports.py.bak
Normal file
136
backend/routers/reports.py.bak
Normal 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}
|
||||
69
backend/routers/settings.py
Normal file
69
backend/routers/settings.py
Normal 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}
|
||||
29
backend/routers/settings.py.bak
Normal file
29
backend/routers/settings.py.bak
Normal 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
29
backend/utils.py
Normal 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
56
docker-compose.yml
Normal 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:
|
||||
148
frontend/static/css/style.css
Normal file
148
frontend/static/css/style.css
Normal 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);}
|
||||
}
|
||||
18
frontend/static/index.html
Normal file
18
frontend/static/index.html
Normal 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
72
frontend/static/js/api.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
42
frontend/static/js/api.js.bak
Normal file
42
frontend/static/js/api.js.bak
Normal 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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
63
frontend/static/js/auth.js
Normal file
63
frontend/static/js/auth.js
Normal 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 };
|
||||
})();
|
||||
174
frontend/static/js/imageCompress.js
Executable file
174
frontend/static/js/imageCompress.js
Executable 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 };
|
||||
})();
|
||||
122
frontend/static/pages/admin/accounts.html
Normal file
122
frontend/static/pages/admin/accounts.html
Normal 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>
|
||||
180
frontend/static/pages/admin/charger-types.html
Normal file
180
frontend/static/pages/admin/charger-types.html
Normal 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, '"');
|
||||
}
|
||||
|
||||
/* ── 수정 모드 진입 ── */
|
||||
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>
|
||||
55
frontend/static/pages/admin/charger-types.html.bak
Normal file
55
frontend/static/pages/admin/charger-types.html.bak
Normal 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>
|
||||
131
frontend/static/pages/admin/chargers.html
Normal file
131
frontend/static/pages/admin/chargers.html
Normal 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>
|
||||
86
frontend/static/pages/admin/costs.html
Normal file
86
frontend/static/pages/admin/costs.html
Normal 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>
|
||||
93
frontend/static/pages/admin/dashboard.html
Normal file
93
frontend/static/pages/admin/dashboard.html
Normal 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>
|
||||
102
frontend/static/pages/admin/improvement-detail.html
Normal file
102
frontend/static/pages/admin/improvement-detail.html
Normal 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>
|
||||
164
frontend/static/pages/admin/improvements.html
Normal file
164
frontend/static/pages/admin/improvements.html
Normal 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>
|
||||
77
frontend/static/pages/admin/qr.html
Normal file
77
frontend/static/pages/admin/qr.html
Normal 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>
|
||||
438
frontend/static/pages/admin/report-detail.html
Normal file
438
frontend/static/pages/admin/report-detail.html
Normal 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>
|
||||
80
frontend/static/pages/admin/reports.html
Normal file
80
frontend/static/pages/admin/reports.html
Normal 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>
|
||||
269
frontend/static/pages/admin/settings.html
Normal file
269
frontend/static/pages/admin/settings.html
Normal 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>
|
||||
62
frontend/static/pages/login.html
Normal file
62
frontend/static/pages/login.html
Normal 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>
|
||||
59
frontend/static/pages/manufacturer/dashboard.html
Normal file
59
frontend/static/pages/manufacturer/dashboard.html
Normal 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>
|
||||
102
frontend/static/pages/manufacturer/improvement.html
Normal file
102
frontend/static/pages/manufacturer/improvement.html
Normal 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>
|
||||
70
frontend/static/pages/mechanic/dashboard.html
Normal file
70
frontend/static/pages/mechanic/dashboard.html
Normal 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>
|
||||
184
frontend/static/pages/mechanic/repair.html
Normal file
184
frontend/static/pages/mechanic/repair.html
Normal 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>
|
||||
160
frontend/static/pages/mechanic/repair.html.bak
Normal file
160
frontend/static/pages/mechanic/repair.html.bak
Normal 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>
|
||||
66
frontend/static/pages/mechanic/scan.html
Normal file
66
frontend/static/pages/mechanic/scan.html
Normal 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>
|
||||
522
frontend/static/pages/report.html
Normal file
522
frontend/static/pages/report.html
Normal 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>
|
||||
214
frontend/static/pages/report.html.bak
Normal file
214
frontend/static/pages/report.html.bak
Normal 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
50
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user