기능 개선 — 사진 업로드, 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
python-multipart==0.0.9
qrcode[pil]==7.4.2
Pillow==10.3.0
Pillow==12.2.0
pillow-heif==1.3.0
openpyxl==3.1.2
python-dotenv==1.0.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,
"reported_at": r.reported_at.isoformat(),
"photos": [p.file_path for p in r.photos],
"re_dispatch_count": r.re_dispatch_count or 0,
} for r in reports]

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
from sqlalchemy.orm import Session
from sqlalchemy import desc, text
from sqlalchemy import desc, text, func
from typing import List, Optional
from datetime import datetime
import os, uuid
@@ -185,17 +185,32 @@ def list_reports(
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))
# 전체 신고 기준 순번 (삭제 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:
q = q.filter(models.Report.status == status)
elif active_only:
q = q.filter(models.Report.status.in_(
["pending", "pending_approval", "in_progress", "waiting", "revisit"]))
if charger_id: q = q.filter(models.Report.charger_id == charger_id)
# 정비사는 공개된 것만 (승인 대기 제외)
if current_user.role == "mechanic":
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}")
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)
result = _fmt_report(r, db)
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 내림차순(최신 우선)으로 정렬
def _fmt_one_repair(repair, include_cost=True):
cost = repair.cost

View File

@@ -1,18 +1,47 @@
import os, uuid, qrcode
import os, uuid, io, qrcode
from PIL import Image
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")
# 브라우저에서 표시 불가능한 포맷 → 서버에서 JPEG 변환
_CONVERT_EXTS = {".heic", ".heif", ".avif"}
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"
folder = os.path.join(UPLOAD_DIR, sub_dir)
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}"
filepath = os.path.join(folder, filename)
with open(filepath, "wb") as f:
f.write(file.file.read())
f.write(raw)
return f"/uploads/{sub_dir}/{filename}"
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 a{flex:1;display:flex;flex-direction:column;align-items:center;padding:8px 4px 7px;color:rgba(255,255,255,.6);text-decoration:none;font-size:11px;border-bottom:3px solid transparent;transition:all .15s;gap:1px;line-height:1.4;}
.mech-tab-bar a:hover,.mech-tab-bar a.active{color:white;border-bottom-color:var(--accent);background:rgba(255,255,255,.06);}
@media(max-width:768px){.mech-tab-bar{display:flex;}}
@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 });
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) {
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
throw new Error(err.detail || '오류');
@@ -36,7 +41,12 @@ const API = (() => {
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) {
const err = await res.json().catch(() => ({ detail: '다운로드 오류' }));
throw new Error(err.detail || '다운로드 오류');
@@ -66,7 +76,7 @@ const API = (() => {
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, body) => req('DELETE', path, body ?? null),
delete: (path, body) => req('DELETE', path, body !== undefined ? body : null),
download,
};
})();

View File

@@ -21,10 +21,16 @@ const Auth = (() => {
}
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())) {
alert('접근 권한이 없습니다.');
history.back();
alert('접근 권한이 없습니다. (현재 역할: ' + (role() || '없음') + ')');
logout();
return false;
}
return true;
@@ -72,12 +78,14 @@ const Auth = (() => {
if (!sidebar) return;
const opening = !sidebar.classList.contains('mobile-open');
sidebar.classList.toggle('mobile-open', opening);
overlay?.classList.toggle('show', opening);
if (overlay) overlay.classList.toggle('show', opening);
}
function closeMobileNav() {
document.querySelector('.sidebar')?.classList.remove('mobile-open');
document.getElementById('mobileNavOverlay')?.classList.remove('show');
var sb = document.querySelector('.sidebar');
if (sb) sb.classList.remove('mobile-open');
var ov = document.getElementById('mobileNavOverlay');
if (ov) ov.classList.remove('show');
}
function statusBadge(status) {

View File

@@ -6,16 +6,21 @@
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() {
if (_cfg) return _cfg;
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();
} catch {
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
} catch (e) {
_cfg = DEFAULT_CFG;
}
return _cfg;
}
@@ -27,18 +32,30 @@ const ImageCompressor = (() => {
* @returns {Promise<File>}
*/
function compressOne(file, cfg) {
return new Promise((resolve) => {
return new Promise(function(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;
// createObjectURL 사용: readAsDataURL 대비 메모리 1/4 이하
// (base64 변환 없이 브라우저가 파일을 직접 디코딩)
var objUrl = URL.createObjectURL(file);
var img = new Image();
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 초과하면 비율 유지하며 축소
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.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(
(blob) => {
const compressed = new File(
function(blob) {
canvas.width = 0; canvas.height = 0; // canvas 메모리 해제
if (!blob || blob.size < 500) { resolve(file); return; }
// blob이 실제로 렌더 가능한 이미지인지 검증
// (OOM으로 drawImage가 빈 캔버스를 만들었을 경우를 잡기 위해)
var blobUrl = URL.createObjectURL(blob);
var check = new Image();
check.onerror = function() {
URL.revokeObjectURL(blobUrl);
resolve(file); // 유효하지 않은 JPEG → 원본 사용
};
check.onload = function() {
URL.revokeObjectURL(blobUrl);
if (!check.naturalWidth || !check.naturalHeight) {
resolve(file); // 빈 이미지 → 원본 사용
return;
}
resolve(new File(
[blob],
file.name.replace(/\.[^.]+$/, '') + '.jpg',
{ type: 'image/jpeg', lastModified: Date.now() }
);
resolve(compressed);
));
};
check.src = blobUrl;
},
'image/jpeg',
cfg.image_quality / 100 // 0~1 범위
);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
img.src = objUrl;
});
}
/**
* FileList / File[] 전체를 압축
* FileList / File[] 전체를 순차 압축 (병렬 처리 시 모바일 메모리 부족 방지)
* @param {FileList|File[]} files
* @returns {Promise<File[]>}
*/
async function compressAll(files) {
const cfg = await loadConfig();
return Promise.all(Array.from(files).map(f => compressOne(f, cfg)));
var cfg = await loadConfig();
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 (선택)
*/
function setupPreview(inputId, previewId, infoId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
const info = infoId ? document.getElementById(infoId) : null;
var input = document.getElementById(inputId);
var preview = document.getElementById(previewId);
var info = infoId ? document.getElementById(infoId) : null;
if (!input || !preview) return;
@@ -102,42 +146,49 @@ const ImageCompressor = (() => {
preview.innerHTML = '';
if (info) info.textContent = '압축 중...';
const cfg = await loadConfig();
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0);
var cfg = await loadConfig();
var origBytes = Array.from(this.files).reduce(function(s, f) { return s + f.size; }, 0);
const compressed = await compressAll(this.files);
const compBytes = compressed.reduce((s, f) => s + f.size, 0);
var compressed = await compressAll(this.files);
var compBytes = compressed.reduce(function(s, f) { return s + f.size; }, 0);
// DataTransfer 로 input.files 교체 (압축된 파일로)
const dt = new DataTransfer();
compressed.forEach(f => dt.items.add(f));
var dt = new DataTransfer();
compressed.forEach(function(f) { dt.items.add(f); });
this.files = dt.files;
// 미리보기 렌더링
compressed.forEach((f, i) => {
const url = URL.createObjectURL(f);
const wrap = document.createElement('div');
compressed.forEach(function(f, i) {
var url = URL.createObjectURL(f);
var wrap = document.createElement('div');
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.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');
// 삭제 버튼 — 클로저로 이 파일(f)을 직접 참조
var 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));
(function(targetFile) {
del.onclick = function() {
var remaining = Array.from(input.files).filter(function(f2) { return f2.name !== targetFile.name || f2.size !== targetFile.size; });
var dt2 = new DataTransfer();
remaining.forEach(function(f2) { dt2.items.add(f2); });
input.files = dt2.files;
wrap.remove();
updateInfo(input, info);
};
})(f);
wrap.appendChild(img);
wrap.appendChild(del);
@@ -146,12 +197,12 @@ const ImageCompressor = (() => {
// 용량 정보
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) {
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';
} else {
info.textContent = `${compressed.length}장 | ${fmt(compBytes)} (압축 비활성)`;
info.textContent = compressed.length + '장 | ' + fmt(compBytes) + ' (압축 비활성)';
info.style.color = '#8899BB';
}
}
@@ -160,13 +211,13 @@ const ImageCompressor = (() => {
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)}`;
var bytes = Array.from(input.files).reduce(function(s, f) { return s + f.size; }, 0);
info.textContent = input.files.length + '장 | ' + fmt(bytes);
}
function fmt(bytes) {
return bytes < 1024 * 1024
? (bytes / 1024).toFixed(0) + ' KB'
? Math.round(bytes / 1024) + ' KB'
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
@@ -177,14 +228,14 @@ const ImageCompressor = (() => {
* @param {string} galleryId - 기존 multiple input id (setupPreview 대상)
*/
function setupCameraAppend(cameraId, galleryId) {
const cam = document.getElementById(cameraId);
const main = document.getElementById(galleryId);
var cam = document.getElementById(cameraId);
var main = document.getElementById(galleryId);
if (!cam || !main) return;
cam.addEventListener('change', function() {
if (!this.files.length) return;
const dt = new DataTransfer();
Array.from(main.files).forEach(f => dt.items.add(f)); // 기존 파일 유지
Array.from(this.files).forEach(f => dt.items.add(f)); // 새 사진 추가
var dt = new DataTransfer();
Array.from(main.files).forEach(function(f) { dt.items.add(f); }); // 기존 파일 유지
Array.from(this.files).forEach(function(f) { dt.items.add(f); }); // 새 사진 추가
main.files = dt.files;
main.dispatchEvent(new Event('change')); // setupPreview 재실행
this.value = '';

View File

@@ -390,7 +390,7 @@
<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="/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>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));

View File

@@ -147,7 +147,7 @@
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/imageCompress.js"></script>
<script src="/js/imageCompress.js?v=20260603"></script>
<script>
Auth.require(['admin']);
Auth.renderNav(document.getElementById('navUser'));
@@ -208,7 +208,7 @@ async function load() {
const cost = repair?.cost;
const prevRepairs = r.prev_repairs || [];
document.getElementById('pageTitle').textContent = `신고 #${r.id} 상세`;
document.getElementById('pageTitle').textContent = `신고 #${r.seq} 상세`;
// ── 출장비 요약 HTML 생성 ──
let costHtml = '';
@@ -522,10 +522,35 @@ async function load() {
</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;">
<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 ? `
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
${repair.approved_at
@@ -669,34 +694,6 @@ async function load() {
</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}
`;

View File

@@ -207,8 +207,7 @@ function renderTable() {
onchange="this.closest('tr').classList.toggle('selected',this.checked);updateDeleteBtn()">
</td>
<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="display:block;font-size:10px;color:var(--gray4);font-weight:400">#${r.id}</span>
<span style="font-weight:700">${r.seq}</span>
</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>

View File

@@ -147,7 +147,11 @@ async function doLogin() {
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';
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 === 'observer') location.href = '/pages/observer/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html';

View File

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

View File

@@ -68,8 +68,8 @@
</div>
</div>
<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>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));

View File

@@ -109,41 +109,18 @@
<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;">
<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 class="btn btn-primary btn-lg" id="doneBtn" style="width:100%;margin-top:4px;" onclick="submitForm(true)">
✅ 조치 완료 저장
</button>
</div>
<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>
<input type="hidden" id="resultStatus" value="done">
</div>
</div><!-- max-width wrapper -->
</div><!-- .main -->
</div><!-- .layout -->
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/imageCompress.js"></script>
<script src="/js/api.js?v=20260603"></script>
<script src="/js/auth.js?v=20260603"></script>
<script src="/js/imageCompress.js?v=20260603"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));
@@ -164,35 +141,62 @@ const startTime = new Date();
document.getElementById('startedAt').value = toLocalDtInput(startTime);
document.getElementById('completedAt').value = toLocalDtInput(startTime);
// 조치 시작시각 변경 시 완료시각이 시작보다 이전이면 자동 보정
document.getElementById('startedAt').addEventListener('change', function () {
const started = document.getElementById('startedAt').value;
const completed = document.getElementById('completedAt').value;
if (started && completed && completed < started) {
document.getElementById('completedAt').value = started;
}
});
const selectedReports = new Set();
if (initReportId) selectedReports.add(parseInt(initReportId));
// ── 신규 모드 ──
async function loadCreate() {
try {
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>`;
if (!charger) return; // 401 → 로그아웃 리다이렉트 진행 중
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('');
if (!reports || !reports.length) {
list.innerHTML = '<div class="alert alert-info">미처리 신고가 없습니다.</div>';
return;
}
list.innerHTML = reports.map(function(r) {
var bg = selectedReports.has(r.id) ? '#E3EDFF' : 'white';
var checked = selectedReports.has(r.id) ? 'checked' : '';
var photoHtml = r.photos && r.photos.length
? '<div class="photo-preview">' + r.photos.map(function(p) { return '<img src="' + p + '">'; }).join('') + '</div>'
: '';
return '<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--gray2);border-radius:7px;margin-bottom:8px;cursor:pointer;background:' + bg + '">' +
'<input type="checkbox" ' + checked + ' value="' + r.id + '"' +
' style="accent-color:var(--accent);margin-top:2px;flex-shrink:0"' +
' onchange="toggleReport(' + r.id + ',this.checked,this.closest(\'label\'))">' +
'<div>' +
'<div><strong>#' + r.id + '</strong> ' + Auth.statusBadge(r.status) +
(r.re_dispatch_count > 0 ? ' <span style="font-size:11px;background:#FEF3C7;color:#92400E;padding:1px 7px;border-radius:8px;font-weight:700;">🔁 재조치 ' + r.re_dispatch_count + '회</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) {
@@ -205,9 +209,11 @@ async function loadEdit() {
let repair;
try { repair = await API.get('/repairs/' + repairId); }
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',
`<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 || '';
if (repair.started_at) document.getElementById('startedAt').value = toLocalDtInput(repair.started_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);
@@ -292,8 +289,8 @@ function renderExistingPhotos(repair) {
};
const bWrap = document.getElementById('previewBefore');
const aWrap = document.getElementById('previewAfter');
if (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_before && repair.photos_before.length) bWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_before,'before'));
if (repair.photos_after && repair.photos_after.length) aWrap.insertAdjacentHTML('beforebegin', mkGrid(repair.photos_after,'after'));
}
async function deleteRepairPhoto(rId, pId) {
@@ -304,21 +301,47 @@ async function deleteRepairPhoto(rId, pId) {
} catch(e) { alert(e.message); }
}
// GPS 수집
navigator.geolocation?.getCurrentPosition(
pos => {
// GPS 수집 — 1단계: 저정밀(WiFi/셀) 즉시, 2단계: 고정밀(GPS) 백그라운드
(function acquireGPS() {
if (!navigator.geolocation) {
document.getElementById('gpsStatus').className = 'alert alert-warn';
document.getElementById('gpsStatus').textContent = '⚠️ 이 기기는 위치 정보를 지원하지 않습니다.';
return;
}
function applyPos(pos, label) {
document.getElementById('mechanicLat').value = pos.coords.latitude;
document.getElementById('mechanicLng').value = pos.coords.longitude;
document.getElementById('gpsStatus').className = 'alert alert-success';
document.getElementById('gpsStatus').innerHTML =
`📍 위치 수집 완료 <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 = '⚠️ 위치 정보를 가져올 수 없습니다.';
},
{ enableHighAccuracy: true, timeout: 10000 }
// 저정밀도 실패 시 고정밀 한 번 더 시도
navigator.geolocation.getCurrentPosition(
pos => applyPos(pos, ''),
failGPS,
{ enableHighAccuracy: true, timeout: 30000 }
);
},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
);
})();
// 이미지 압축 + 다중 선택 프리뷰
ImageCompressor.setupPreview('photosBefore', 'previewBefore', 'infoBefore');
@@ -337,12 +360,11 @@ async function submitForm(isDone) {
if (!rids.length) { showErr('신고를 1건 이상 선택해 주세요.'); return; }
}
const saveBtn = document.getElementById('saveBtn');
const doneBtn = document.getElementById('doneBtn');
saveBtn.disabled = doneBtn.disabled = true;
(isDone ? doneBtn : saveBtn).textContent = '저장 중...';
doneBtn.disabled = true;
doneBtn.textContent = '저장 중...';
const resultStatus = isDone ? 'done' : document.getElementById('resultStatus').value;
const resultStatus = 'done';
const lat = document.getElementById('mechanicLat').value;
const lng = document.getElementById('mechanicLng').value;
@@ -362,18 +384,17 @@ async function submitForm(isDone) {
try {
if (isEditMode) {
await API.put('/repairs/' + repairId, fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
alert('✅ 조치 완료로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
} else {
fd.append('report_ids', JSON.stringify([...selectedReports]));
await API.post('/repairs', fd);
alert(isDone ? '✅ 조치 완료로 저장되었습니다.' : '💾 현재 상태로 저장되었습니다.');
alert('✅ 조치 완료로 저장되었습니다.');
location.href = '/pages/mechanic/history.html';
}
} catch(e) {
showErr(e.message);
saveBtn.disabled = doneBtn.disabled = false;
saveBtn.textContent = '💾 상태 저장';
doneBtn.disabled = false;
doneBtn.textContent = '✅ 조치 완료 저장';
}
}
@@ -383,18 +404,39 @@ function showErr(msg) {
el.textContent = msg; el.style.display = 'block';
}
async function loadRepairTypes(preChecked = []) {
try {
const types = await API.get('/settings/repair-types');
document.getElementById('repairTypes').innerHTML = types.map(t => `
const DEFAULT_REPAIR_TYPES = [
{key:'부품교체',label:'🔩 부품 교체'},
{key:'재시작', label:'🔄 재시작'},
{key:'설정변경',label:'⚙️ 설정 변경'},
{key:'청소', label:'🧹 청소'},
{key:'배선정리',label:'🔌 배선 정리'},
{key:'펌웨어', label:'💾 펌웨어 업데이트'},
{key:'기타', label:'📋 기타'},
];
function renderRepairTypeList(types, preChecked) {
const el = document.getElementById('repairTypes');
if (!el) return;
el.innerHTML = types.map(t => `
<label class="check-item">
<input type="checkbox" value="${t.key}" ${preChecked.includes(t.key) ? 'checked' : ''}>
${t.label}
</label>`).join('');
} 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) {

View File

@@ -44,7 +44,7 @@
</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 src="/js/api.js?v=20260603"></script><script src="/js/auth.js?v=20260603"></script>
<script>
Auth.require(['mechanic','admin']);
Auth.renderNav(document.getElementById('navUser'));

View File

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

View File

@@ -42,6 +42,14 @@ http {
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 라우팅)
location / {
try_files $uri $uri/ /index.html;