기능 개선 — 사진 업로드, HEIC 지원, 재조치 흐름, 신고 순번, 모바일 UI

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byun
2026-06-02 05:38:33 +09:00
parent 5ebd0a6ae7
commit 9f0f4326fe
18 changed files with 436 additions and 255 deletions

View File

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

View File

@@ -112,6 +112,7 @@ def open_reports_for_charger(charger_id: str, db: Session = Depends(get_db),
"issue_detail": r.issue_detail, "status": r.status, "issue_detail": r.issue_detail, "status": r.status,
"reported_at": r.reported_at.isoformat(), "reported_at": r.reported_at.isoformat(),
"photos": [p.file_path for p in r.photos], "photos": [p.file_path for p in r.photos],
"re_dispatch_count": r.re_dispatch_count or 0,
} for r in reports] } for r in reports]

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import desc, text from sqlalchemy import desc, text, func
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
import os, uuid import os, uuid
@@ -185,17 +185,32 @@ def list_reports(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user) current_user: models.User = Depends(get_current_user)
): ):
q = db.query(models.Report).order_by(desc(models.Report.reported_at)) # 전체 신고 기준 순번 (삭제 gap 없이, 오래된 것=1)
seq_subq = db.query(
models.Report.id.label("rid"),
func.row_number().over(
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
).label("seq")
).subquery()
q = (db.query(models.Report, seq_subq.c.seq)
.join(seq_subq, models.Report.id == seq_subq.c.rid)
.order_by(desc(models.Report.reported_at)))
if status: if status:
q = q.filter(models.Report.status == status) q = q.filter(models.Report.status == status)
elif active_only: elif active_only:
q = q.filter(models.Report.status.in_( q = q.filter(models.Report.status.in_(
["pending", "pending_approval", "in_progress", "waiting", "revisit"])) ["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
if charger_id: q = q.filter(models.Report.charger_id == charger_id) if charger_id: q = q.filter(models.Report.charger_id == charger_id)
# 정비사는 공개된 것만 (승인 대기 제외)
if current_user.role == "mechanic": if current_user.role == "mechanic":
q = q.filter(models.Report.status != "pending_approval") q = q.filter(models.Report.status != "pending_approval")
return [_fmt_report(r, db) for r in q.all()]
result = []
for r, seq in q.all():
fmt = _fmt_report(r, db)
fmt["seq"] = seq
result.append(fmt)
return result
@router.get("/{report_id}") @router.get("/{report_id}")
def get_report(report_id: int, db: Session = Depends(get_db), def get_report(report_id: int, db: Session = Depends(get_db),
@@ -204,6 +219,15 @@ def get_report(report_id: int, db: Session = Depends(get_db),
if not r: raise HTTPException(404) if not r: raise HTTPException(404)
result = _fmt_report(r, db) result = _fmt_report(r, db)
result["re_dispatch_count"] = r.re_dispatch_count or 0 result["re_dispatch_count"] = r.re_dispatch_count or 0
# 전체 기준 순번 계산
seq_subq = db.query(
models.Report.id.label("rid"),
func.row_number().over(
order_by=[models.Report.reported_at.asc(), models.Report.id.asc()]
).label("seq")
).subquery()
row = db.query(seq_subq.c.seq).filter(seq_subq.c.rid == report_id).scalar()
result["seq"] = row
# 수리 정보 포함 — repair_links를 id 내림차순(최신 우선)으로 정렬 # 수리 정보 포함 — repair_links를 id 내림차순(최신 우선)으로 정렬
def _fmt_one_repair(repair, include_cost=True): def _fmt_one_repair(repair, include_cost=True):
cost = repair.cost cost = repair.cost

View File

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

View File

@@ -185,4 +185,8 @@ textarea{resize:vertical;min-height:80px;}
.mech-tab-bar{display:none;background:var(--navy2);position:sticky;top:54px;z-index:200;border-bottom:1px solid rgba(255,255,255,.15);} .mech-tab-bar{display:none;background:var(--navy2);position:sticky;top:54px;z-index:200;border-bottom:1px solid rgba(255,255,255,.15);}
.mech-tab-bar a{flex:1;display:flex;flex-direction:column;align-items:center;padding:8px 4px 7px;color:rgba(255,255,255,.6);text-decoration:none;font-size:11px;border-bottom:3px solid transparent;transition:all .15s;gap:1px;line-height:1.4;} .mech-tab-bar a{flex:1;display:flex;flex-direction:column;align-items:center;padding:8px 4px 7px;color:rgba(255,255,255,.6);text-decoration:none;font-size:11px;border-bottom:3px solid transparent;transition:all .15s;gap:1px;line-height:1.4;}
.mech-tab-bar a:hover,.mech-tab-bar a.active{color:white;border-bottom-color:var(--accent);background:rgba(255,255,255,.06);} .mech-tab-bar a:hover,.mech-tab-bar a.active{color:white;border-bottom-color:var(--accent);background:rgba(255,255,255,.06);}
@media(max-width:768px){.mech-tab-bar{display:flex;}} @media(max-width:768px){
.mech-tab-bar{display:flex;position:fixed;left:0;width:100%;box-sizing:border-box;}
.mech-tab-bar~.layout{margin-top:54px;}
.mech-tab-bar~.layout>.sidebar{top:108px;}
}

View File

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

View File

@@ -21,10 +21,16 @@ const Auth = (() => {
} }
function require(allowedRoles) { function require(allowedRoles) {
if (!token()) { logout(); return false; } if (!token()) {
// 로그인 후 원래 페이지로 돌아올 수 있도록 현재 URL 저장
if (location.pathname !== '/pages/login.html') {
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
}
logout(); return false;
}
if (allowedRoles && !allowedRoles.includes(role())) { if (allowedRoles && !allowedRoles.includes(role())) {
alert('접근 권한이 없습니다.'); alert('접근 권한이 없습니다. (현재 역할: ' + (role() || '없음') + ')');
history.back(); logout();
return false; return false;
} }
return true; return true;
@@ -72,12 +78,14 @@ const Auth = (() => {
if (!sidebar) return; if (!sidebar) return;
const opening = !sidebar.classList.contains('mobile-open'); const opening = !sidebar.classList.contains('mobile-open');
sidebar.classList.toggle('mobile-open', opening); sidebar.classList.toggle('mobile-open', opening);
overlay?.classList.toggle('show', opening); if (overlay) overlay.classList.toggle('show', opening);
} }
function closeMobileNav() { function closeMobileNav() {
document.querySelector('.sidebar')?.classList.remove('mobile-open'); var sb = document.querySelector('.sidebar');
document.getElementById('mobileNavOverlay')?.classList.remove('show'); if (sb) sb.classList.remove('mobile-open');
var ov = document.getElementById('mobileNavOverlay');
if (ov) ov.classList.remove('show');
} }
function statusBadge(status) { function statusBadge(status) {

View File

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

View File

@@ -390,7 +390,7 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script><script src="/js/imageCompress.js"></script> <script src="/js/api.js"></script><script src="/js/auth.js"></script><script src="/js/imageCompress.js?v=20260603"></script>
<script> <script>
Auth.require(['admin']); Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));

View File

@@ -147,7 +147,7 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script src="/js/imageCompress.js"></script> <script src="/js/imageCompress.js?v=20260603"></script>
<script> <script>
Auth.require(['admin']); Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));
@@ -208,7 +208,7 @@ async function load() {
const cost = repair?.cost; const cost = repair?.cost;
const prevRepairs = r.prev_repairs || []; const prevRepairs = r.prev_repairs || [];
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`; document.getElementById('pageTitle').textContent = `신고 #${r.seq} 상세`;
// ── 출장비 요약 HTML 생성 ── // ── 출장비 요약 HTML 생성 ──
let costHtml = ''; let costHtml = '';
@@ -522,10 +522,35 @@ async function load() {
</div> </div>
</div> </div>
<!-- 조치 정보 --> <!-- 조치 정보 — 이전 조치(순번카드) + 최신 조치 순서로 표시 -->
<div class="card"> ${prevRepairs.length ? (() => {
// prev_repairs는 최신순(내림차순)이므로 뒤집어서 오래된 것부터 번호 부여
const orderedPrev = prevRepairs.slice().reverse();
return orderedPrev.map((pr, idx) => `
<div class="card" style="border-left:4px solid #F59E0B;">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;"> <div class="card-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>🔧 조치 정보</span> <span>🔧 조치 #${idx + 1}${orderedPrev.length + 1 > 2 ? '' : ' (최초)'}</span>
<span style="font-size:12px;background:#FEF3C7;color:#92400E;padding:3px 12px;border-radius:10px;font-weight:700;">🔁 재조치 요청됨 · ${Auth.fmtDt(pr.re_dispatch_requested_at)}</span>
</div>
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${pr.mechanic_name||'-'}</td></tr>
<tr><td style="color:var(--gray4)">조치유형</td><td>${(pr.repair_types||[]).join(', ')||'-'}</td></tr>
<tr><td style="color:var(--gray4)">조치내용</td><td>${pr.description||'-'}</td></tr>
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(pr.completed_at)}</td></tr>
</table>
${(pr.photos_before||[]).length || (pr.photos_after||[]).length ? `
<div style="margin-top:10px;">
${(pr.photos_before||[]).length ? `<label style="font-size:12px;font-weight:700;color:var(--navy2)">조치 전 사진</label>
<div class="photo-preview">${(pr.photos_before||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
${(pr.photos_after||[]).length ? `<label style="font-size:12px;font-weight:700;color:var(--navy2);margin-top:8px;display:block">조치 후 사진</label>
<div class="photo-preview">${(pr.photos_after||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
</div>` : ''}
</div>`).join('');
})() : ''}
<div class="card" style="${prevRepairs.length ? 'border-left:4px solid var(--accent);' : ''}">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>🔧 조치 ${prevRepairs.length ? '#' + (prevRepairs.length + 1) + ' (최신)' : '정보'}</span>
${repair ? ` ${repair ? `
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
${repair.approved_at ${repair.approved_at
@@ -669,34 +694,6 @@ async function load() {
</div> </div>
</div> </div>
${prevRepairs.length ? `
<div class="card">
<div class="card-title">📋 이전 조치 이력 (재조치 전 기록 ${prevRepairs.length}건)</div>
${prevRepairs.map((pr, idx) => `
<details style="margin-bottom:10px;border:1px solid var(--gray2);border-radius:8px;overflow:hidden;">
<summary style="padding:10px 14px;cursor:pointer;font-size:13px;font-weight:700;color:var(--navy2);background:var(--gray1);list-style:none;display:flex;justify-content:space-between;align-items:center;">
<span>#${pr.id} · ${pr.mechanic_name||'?'} · ${Auth.fmtDt(pr.completed_at)}</span>
<span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:2px 8px;border-radius:8px;">🔁 재조치 요청됨</span>
</summary>
<div style="padding:12px 14px;font-size:13px;">
<table class="no-hover" style="font-size:13px;">
<tr><td style="color:var(--gray4);width:100px">정비사</td><td>${pr.mechanic_name||'-'} (${pr.mechanic_company||'-'})</td></tr>
<tr><td style="color:var(--gray4)">조치유형</td><td>${(pr.repair_types||[]).join(', ')||'-'}</td></tr>
<tr><td style="color:var(--gray4)">조치내용</td><td>${pr.description||'-'}</td></tr>
<tr><td style="color:var(--gray4)">완료시각</td><td>${Auth.fmtDt(pr.completed_at)}</td></tr>
<tr><td style="color:var(--gray4)">처리결과</td><td>${Auth.statusBadge(pr.result_status)}</td></tr>
<tr><td style="color:var(--gray4)">재조치요청</td><td style="color:#92400E;font-weight:700;">${Auth.fmtDt(pr.re_dispatch_requested_at)}</td></tr>
</table>
${(pr.photos_before||[]).length || (pr.photos_after||[]).length ? `
<div style="margin-top:10px;">
${(pr.photos_before||[]).length ? `<div style="font-size:11px;font-weight:700;color:var(--gray4);margin-bottom:4px;">조치 전</div>
<div class="photo-preview">${(pr.photos_before||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
${(pr.photos_after||[]).length ? `<div style="font-size:11px;font-weight:700;color:var(--gray4);margin:6px 0 4px;">조치 후</div>
<div class="photo-preview">${(pr.photos_after||[]).map(p=>`<img src="${p}" onclick="window.open('${p}')" style="cursor:zoom-in">`).join('')}</div>` : ''}
</div>` : ''}
</div>
</details>`).join('')}
</div>` : ''}
${costHtml} ${costHtml}
`; `;

View File

@@ -207,8 +207,7 @@ function renderTable() {
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()"> onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
</td> </td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"> <td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">
<span style="font-weight:700">${i+1}</span> <span style="font-weight:700">${r.seq}</span>
<span style="display:block;font-size:10px;color:var(--gray4);font-weight:400">#${r.id}</span>
</td> </td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"><strong>${r.charger_id}</strong></td> <td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer"><strong>${r.charger_id}</strong></td>
<td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.station_name||'-'}</td> <td onclick="location.href='/pages/admin/report-detail.html?id=${r.id}'" style="cursor:pointer">${r.station_name||'-'}</td>

View File

@@ -147,7 +147,11 @@ async function doLogin() {
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json(); const data = await res.json();
Auth.save(data.access_token, data.role, data.name, data.user_id); Auth.save(data.access_token, data.role, data.name, data.user_id);
if (data.role === 'admin') location.href = '/pages/admin/dashboard.html'; const redirect = sessionStorage.getItem('ev_redirect');
sessionStorage.removeItem('ev_redirect');
if (redirect && redirect !== '/pages/login.html') {
location.href = redirect;
} else if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html'; else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else if (data.role === 'observer') location.href = '/pages/observer/dashboard.html'; else if (data.role === 'observer') location.href = '/pages/observer/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html'; else location.href = '/pages/manufacturer/dashboard.html';

View File

@@ -106,8 +106,8 @@
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/api.js"></script> <script src="/js/api.js?v=20260603"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js?v=20260603"></script>
<script> <script>
Auth.require(['mechanic','admin']); Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));

View File

@@ -68,8 +68,8 @@
</div> </div>
</div> </div>
<script src="/js/api.js"></script> <script src="/js/api.js?v=20260603"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js?v=20260603"></script>
<script> <script>
Auth.require(['mechanic','admin']); Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));

View File

@@ -109,41 +109,18 @@
<div id="formErr" class="alert alert-danger" style="display:none"></div> <div id="formErr" class="alert alert-danger" style="display:none"></div>
<!-- 저장 버튼 영역 --> <!-- 저장 버튼 영역 -->
<div style="background:var(--gray1);border:1px solid var(--gray2);border-radius:10px;padding:16px;margin-top:4px;"> <button class="btn btn-primary btn-lg" id="doneBtn" style="width:100%;margin-top:4px;" onclick="submitForm(true)">
<div style="font-size:12px;font-weight:700;color:var(--navy2);margin-bottom:10px;">💾 저장 방식 선택</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="btn btn-outline btn-lg" id="saveBtn" style="flex:1;min-width:140px;" onclick="submitForm(false)">
💾 상태 저장
</button>
<button class="btn btn-primary btn-lg" id="doneBtn" style="flex:1;min-width:140px;" onclick="submitForm(true)">
✅ 조치 완료 저장 ✅ 조치 완료 저장
</button> </button>
</div> <input type="hidden" id="resultStatus" value="done">
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:8px;">
<div style="flex:1;min-width:140px;">
<label style="font-size:11px;color:var(--gray4)">저장 상태 선택</label>
<select id="resultStatus" style="width:100%;margin-top:4px;font-size:13px;">
<option value="in_progress">🔧 계속 진행 중</option>
<option value="waiting">⏳ 부품 대기</option>
<option value="revisit">🔄 재방문 필요</option>
</select>
</div>
<div style="flex:1;min-width:140px;display:flex;align-items:flex-end;">
<div style="font-size:11px;color:var(--gray4);padding-bottom:6px;line-height:1.6;">
<strong>조치 완료 저장</strong>은 항상 완료로 저장됩니다.<br>
💾 <strong>상태 저장</strong>은 왼쪽 선택 상태로 저장됩니다.
</div>
</div>
</div>
</div>
</div> </div>
</div><!-- max-width wrapper --> </div><!-- max-width wrapper -->
</div><!-- .main --> </div><!-- .main -->
</div><!-- .layout --> </div><!-- .layout -->
<script src="/js/api.js"></script> <script src="/js/api.js?v=20260603"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js?v=20260603"></script>
<script src="/js/imageCompress.js"></script> <script src="/js/imageCompress.js?v=20260603"></script>
<script> <script>
Auth.require(['mechanic','admin']); Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));
@@ -164,35 +141,62 @@ const startTime = new Date();
document.getElementById('startedAt').value = toLocalDtInput(startTime); document.getElementById('startedAt').value = toLocalDtInput(startTime);
document.getElementById('completedAt').value = toLocalDtInput(startTime); document.getElementById('completedAt').value = toLocalDtInput(startTime);
// 조치 시작시각 변경 시 완료시각이 시작보다 이전이면 자동 보정
document.getElementById('startedAt').addEventListener('change', function () {
const started = document.getElementById('startedAt').value;
const completed = document.getElementById('completedAt').value;
if (started && completed && completed < started) {
document.getElementById('completedAt').value = started;
}
});
const selectedReports = new Set(); const selectedReports = new Set();
if (initReportId) selectedReports.add(parseInt(initReportId)); if (initReportId) selectedReports.add(parseInt(initReportId));
// ── 신규 모드 ── // ── 신규 모드 ──
async function loadCreate() { async function loadCreate() {
try {
const charger = await API.get('/chargers/' + chargerId); const charger = await API.get('/chargers/' + chargerId);
document.getElementById('chargerCard').innerHTML = ` if (!charger) return; // 401 → 로그아웃 리다이렉트 진행 중
<div class="card-title">⚡ 충전기 정보</div> document.getElementById('chargerCard').innerHTML =
<div class="form-row"> '<div class="card-title">⚡ 충전기 정보</div>' +
<div><label style="font-size:11px;color:var(--gray4)">충전기ID</label><strong>${charger.id}</strong></div> '<div class="form-row">' +
<div><label style="font-size:11px;color:var(--gray4)">충전기</label><strong>${charger.name}</strong></div> '<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.station_name}</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)">CPO</label><strong>${charger.cpo_name||'-'}</strong></div> '<div><label style="font-size:11px;color:var(--gray4)">충전소</label><strong>' + charger.station_name + '</strong></div>' +
</div>`; '<div><label style="font-size:11px;color:var(--gray4)">CPO</label><strong>' + (charger.cpo_name || '-') + '</strong></div>' +
'</div>';
const reports = await API.get('/repairs/charger/' + chargerId + '/open'); const reports = await API.get('/repairs/charger/' + chargerId + '/open');
const list = document.getElementById('reportList'); const list = document.getElementById('reportList');
if (!reports.length) { list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>'; return; } if (!reports || !reports.length) {
list.innerHTML = reports.map(r => ` list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
<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'}"> return;
<input type="checkbox" ${selectedReports.has(r.id)?'checked':''} value="${r.id}" }
style="accent-color:var(--accent);margin-top:2px;flex-shrink:0" list.innerHTML = reports.map(function(r) {
onchange="toggleReport(${r.id},this.checked,this.closest('label'))"> var bg = selectedReports.has(r.id) ? '#E3EDFF' : 'white';
<div> var checked = selectedReports.has(r.id) ? 'checked' : '';
<div><strong>#${r.id}</strong> ${Auth.statusBadge(r.status)}</div> var photoHtml = r.photos && r.photos.length
<div style="font-size:12px;color:var(--text2);margin-top:2px">${(r.issue_types||[]).join(', ')}</div> ? '<div class="photo-preview">' + r.photos.map(function(p) { return '<img src="' + p + '">'; }).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>` : ''} return '<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:' + bg + '">' +
</div> '<input type="checkbox" ' + checked + ' value="' + r.id + '"' +
</label>`).join(''); ' style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"' +
' onchange="toggleReport(' + r.id + ',this.checked,this.closest(\'label\'))">' +
'<div>' +
'<div><strong>#' + r.id + '</strong> ' + Auth.statusBadge(r.status) +
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 재조치 ' + r.re_dispatch_count + '회</span>' : '') + '</div>' +
'<div style="font-size:12px;color:var(--text2);margin-top:2px">' + ((r.issue_types || []).join(', ')) + '</div>' +
'<div style="font-size:11px;color:var(--gray4)">' + Auth.fmtDt(r.reported_at) + '</div>' +
photoHtml +
'</div>' +
'</label>';
}).join('');
} catch(e) {
document.getElementById('chargerCard').innerHTML =
'<div class="alert alert-danger">충전기 정보를 불러오지 못했습니다.<br><small style="opacity:.8">' + e.message + '</small></div>';
document.getElementById('reportList').innerHTML = '';
}
} }
function toggleReport(id, checked, label) { function toggleReport(id, checked, label) {
@@ -205,9 +209,11 @@ async function loadEdit() {
let repair; let repair;
try { repair = await API.get('/repairs/' + repairId); } try { repair = await API.get('/repairs/' + repairId); }
catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; } catch(e) { alert('조치 정보를 불러올 수 없습니다.'); return; }
if (!repair) return; // 401 → 로그아웃 리다이렉트 진행 중
// 헤더 업데이트 // 헤더 업데이트
document.querySelector('h2, .main h2') && (document.querySelector('.main > div > h2') || document.querySelector('h2'))?.remove?.(); var h2el = document.querySelector('.main > div > h2') || document.querySelector('h2');
if (h2el) h2el.parentNode.removeChild(h2el);
document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend', document.querySelector('a.btn-outline.btn-sm').insertAdjacentHTML('afterend',
`<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`); `<span style="font-size:17px;font-weight:700;color:var(--navy)">조치 수정 #${repair.id}</span>`);
@@ -246,15 +252,6 @@ async function loadEdit() {
document.getElementById('description').value = repair.description || ''; document.getElementById('description').value = repair.description || '';
if (repair.started_at) document.getElementById('startedAt').value = toLocalDtInput(repair.started_at); if (repair.started_at) document.getElementById('startedAt').value = toLocalDtInput(repair.started_at);
if (repair.completed_at) document.getElementById('completedAt').value = toLocalDtInput(repair.completed_at); if (repair.completed_at) document.getElementById('completedAt').value = toLocalDtInput(repair.completed_at);
const sel = document.getElementById('resultStatus');
if (repair.result_status === 'done') {
const opt = document.createElement('option');
opt.value = 'done'; opt.textContent = '✅ 완료';
sel.insertBefore(opt, sel.firstChild);
sel.value = 'done';
} else if (sel.querySelector(`option[value="${repair.result_status}"]`)) {
sel.value = repair.result_status;
}
// 기존 사진 표시 // 기존 사진 표시
renderExistingPhotos(repair); renderExistingPhotos(repair);
@@ -292,8 +289,8 @@ function renderExistingPhotos(repair) {
}; };
const bWrap = document.getElementById('previewBefore'); const bWrap = document.getElementById('previewBefore');
const aWrap = document.getElementById('previewAfter'); const aWrap = document.getElementById('previewAfter');
if (repair.photos_before?.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before')); if (repair.photos_before && repair.photos_before.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
if (repair.photos_after?.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after')); if (repair.photos_after && repair.photos_after.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
} }
async function deleteRepairPhoto(rId, pId) { async function deleteRepairPhoto(rId, pId) {
@@ -304,21 +301,47 @@ async function deleteRepairPhoto(rId, pId) {
} catch(e) { alert(e.message); } } catch(e) { alert(e.message); }
} }
// GPS 수집 // GPS 수집 — 1단계: 저정밀(WiFi/셀) 즉시, 2단계: 고정밀(GPS) 백그라운드
navigator.geolocation?.getCurrentPosition( (function acquireGPS() {
pos => { if (!navigator.geolocation) {
document.getElementById('gpsStatus').className = 'alert alert-warn';
document.getElementById('gpsStatus').textContent = '⚠️ 이 기기는 위치 정보를 지원하지 않습니다.';
return;
}
function applyPos(pos, label) {
document.getElementById('mechanicLat').value = pos.coords.latitude; document.getElementById('mechanicLat').value = pos.coords.latitude;
document.getElementById('mechanicLng').value = pos.coords.longitude; document.getElementById('mechanicLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').className = 'alert alert-success'; document.getElementById('gpsStatus').className = 'alert alert-success';
document.getElementById('gpsStatus').innerHTML = document.getElementById('gpsStatus').innerHTML =
`📍 위치 수집 완료 <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`; `📍 위치 수집 완료${label} <span style="font-size:11px;font-weight:400">(${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})</span>`;
}
function failGPS() {
if (document.getElementById('mechanicLat').value) return; // 이미 성공
document.getElementById('gpsStatus').className = 'alert alert-warn';
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다. (저장은 가능)';
}
// 1단계: 캐시 허용 + 저정밀 → 5초 내 응답 (WiFi/셀 기반, 실내에서도 동작)
navigator.geolocation.getCurrentPosition(
pos => {
applyPos(pos, '');
// 2단계: 고정밀 GPS 백그라운드로 시도해 더 정확한 좌표로 업데이트
navigator.geolocation.getCurrentPosition(
pos2 => applyPos(pos2, ' (고정밀)'),
() => {}, // 실패해도 1단계 좌표 유지
{ enableHighAccuracy: true, timeout: 30000 }
);
}, },
() => { () => {
document.getElementById('gpsStatus').className = 'alert alert-warn'; // 저정밀도 실패 시 고정밀 한 번 더 시도
document.getElementById('gpsStatus').textContent = '⚠️ 위치 정보를 가져올 수 없습니다.'; navigator.geolocation.getCurrentPosition(
}, pos => applyPos(pos, ''),
{ enableHighAccuracy: true, timeout: 10000 } failGPS,
{ enableHighAccuracy: true, timeout: 30000 }
); );
},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
);
})();
// 이미지 압축 + 다중 선택 프리뷰 // 이미지 압축 + 다중 선택 프리뷰
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore'); ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
@@ -337,12 +360,11 @@ async function submitForm(isDone) {
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; } if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
} }
const saveBtn = document.getElementById('saveBtn');
const doneBtn = document.getElementById('doneBtn'); const doneBtn = document.getElementById('doneBtn');
saveBtn.disabled = doneBtn.disabled = true; doneBtn.disabled = true;
(isDone ? doneBtn : saveBtn).textContent = '저장 중...'; doneBtn.textContent = '저장 중...';
const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value; const resultStatus = 'done';
const lat = document.getElementById('mechanicLat').value; const lat = document.getElementById('mechanicLat').value;
const lng = document.getElementById('mechanicLng').value; const lng = document.getElementById('mechanicLng').value;
@@ -362,18 +384,17 @@ async function submitForm(isDone) {
try { try {
if (isEditMode) { if (isEditMode) {
await API.put('/repairs/' + repairId, fd); await API.put('/repairs/' + repairId, fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.'); alert('✅ 조치 완료로 저장되었습니다.');
location.href = '/pages/mechanic/history.html'; location.href = '/pages/mechanic/history.html';
} else { } else {
fd.append('report_ids', JSON.stringify([...selectedReports])); fd.append('report_ids', JSON.stringify([...selectedReports]));
await API.post('/repairs', fd); await API.post('/repairs', fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.'); alert('✅ 조치 완료로 저장되었습니다.');
location.href = '/pages/mechanic/history.html'; location.href = '/pages/mechanic/history.html';
} }
} catch(e) { } catch(e) {
showErr(e.message); showErr(e.message);
saveBtn.disabled = doneBtn.disabled = false; doneBtn.disabled = false;
saveBtn.textContent = '💾 상태 저장';
doneBtn.textContent = '✅ 조치 완료 저장'; doneBtn.textContent = '✅ 조치 완료 저장';
} }
} }
@@ -383,18 +404,39 @@ function showErr(msg) {
el.textContent = msg; el.style.display = 'block'; el.textContent = msg; el.style.display = 'block';
} }
async function loadRepairTypes(preChecked = []) { const DEFAULT_REPAIR_TYPES = [
try { {key:'부품교체',label:'🔩 부품 교체'},
const types = await API.get('/settings/repair-types'); {key:'재시작', label:'🔄 재시작'},
document.getElementById('repairTypes').innerHTML = types.map(t => ` {key:'설정변경',label:'⚙️ 설정 변경'},
{key:'청소', label:'🧹 청소'},
{key:'배선정리',label:'🔌 배선 정리'},
{key:'펌웨어', label:'💾 펌웨어 업데이트'},
{key:'기타', label:'📋 기타'},
];
function renderRepairTypeList(types, preChecked) {
const el = document.getElementById('repairTypes');
if (!el) return;
el.innerHTML = types.map(t => `
<label class="check-item"> <label class="check-item">
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}> <input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
${t.label} ${t.label}
</label>`).join(''); </label>`).join('');
} catch(e) {
document.getElementById('repairTypes').innerHTML =
'<div class="alert alert-danger" style="margin:0">조치유형을 불러오지 못했습니다.</div>';
} }
async function loadRepairTypes(preChecked = []) {
// 기본값 즉시 표시 — 네트워크 대기 없이 바로 사용 가능
renderRepairTypeList(DEFAULT_REPAIR_TYPES, preChecked);
// API에서 커스텀 유형 로드해 덮어쓰기 (실패해도 기본값 유지)
try {
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), 8000);
const res = await fetch('/api/settings/repair-types', { signal: controller.signal });
clearTimeout(tid);
if (!res.ok) return;
const types = await res.json();
if (Array.isArray(types) && types.length) renderRepairTypeList(types, preChecked);
} catch(_) { /* 기본값 유지 */ }
} }
if (isEditMode) { if (isEditMode) {

View File

@@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<script src="https://unpkg.com/html5-qrcode/minified/html5-qrcode.min.js"></script> <script src="https://unpkg.com/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script src="/js/api.js"></script><script src="/js/auth.js"></script> <script src="/js/api.js?v=20260603"></script><script src="/js/auth.js?v=20260603"></script>
<script> <script>
Auth.require(['mechanic','admin']); Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser')); Auth.renderNav(document.getElementById('navUser'));

View File

@@ -316,7 +316,7 @@ body { background: var(--gray1); }
</div> </div>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/imageCompress.js"></script> <script src="/js/imageCompress.js?v=20260603"></script>
<script> <script>
let ISSUES = [ let ISSUES = [
{key:'충전불가', label:'⚡ 충전 불가'}, {key:'충전불가', label:'⚡ 충전 불가'},
@@ -450,19 +450,21 @@ function initCollapseHeight() {
} }
// ── GPS ── // ── GPS ──
navigator.geolocation?.getCurrentPosition( if (navigator.geolocation) {
pos => { navigator.geolocation.getCurrentPosition(
function(pos) {
document.getElementById('gpsLat').value = pos.coords.latitude; document.getElementById('gpsLat').value = pos.coords.latitude;
document.getElementById('gpsLng').value = pos.coords.longitude; document.getElementById('gpsLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').textContent = document.getElementById('gpsStatus').textContent =
`📍 위치 수집 완료 (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`; '📍 위치 수집 완료 (' + pos.coords.latitude.toFixed(5) + ', ' + pos.coords.longitude.toFixed(5) + ')';
document.getElementById('gpsStatus').className = 'alert alert-success'; document.getElementById('gpsStatus').className = 'alert alert-success';
}, },
() => { function() {
document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)'; document.getElementById('gpsStatus').textContent = '위치 정보를 가져올 수 없습니다. (수동 신고로 진행)';
document.getElementById('gpsStatus').className = 'alert alert-warn'; document.getElementById('gpsStatus').className = 'alert alert-warn';
} }
); );
}
// ── 에러코드 UI 갱신 ── // ── 에러코드 UI 갱신 ──
function updateErrorCodeUI() { function updateErrorCodeUI() {
@@ -494,7 +496,7 @@ function getErrorCodeValue() {
const sel = document.getElementById('errorCode'); const sel = document.getElementById('errorCode');
if (!sel) return ''; if (!sel) return '';
if (sel.tagName === 'SELECT') { if (sel.tagName === 'SELECT') {
if (sel.value === '__other__') return document.getElementById('errorCodeCustom')?.value || ''; if (sel.value === '__other__') { var ecEl = document.getElementById('errorCodeCustom'); return ecEl ? ecEl.value : ''; }
return sel.value; return sel.value;
} }
return sel.value; return sel.value;
@@ -547,7 +549,8 @@ document.getElementById('submitBtn').addEventListener('click', async () => {
document.getElementById('submitBtn').disabled = true; document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').textContent = '접수 중...'; document.getElementById('submitBtn').textContent = '접수 중...';
const scope = document.querySelector('input[name="scope"]:checked')?.value || 'single'; var scopeEl = document.querySelector('input[name="scope"]:checked');
var scope = scopeEl ? scopeEl.value : 'single';
const fd = new FormData(); const fd = new FormData();
fd.append('charger_id', chargerId); fd.append('charger_id', chargerId);

View File

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