diff --git a/backend/requirements.txt b/backend/requirements.txt index eaf968d..97f5484 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/repairs.py b/backend/routers/repairs.py index e41be62..ec42f5a 100644 --- a/backend/routers/repairs.py +++ b/backend/routers/repairs.py @@ -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] diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 323d493..37c53e1 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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 diff --git a/backend/utils.py b/backend/utils.py index 2c9fec3..ee77000 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -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: diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css index 6344374..e7ae685 100644 --- a/frontend/static/css/style.css +++ b/frontend/static/css/style.css @@ -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;} +} diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index cd51dd4..831df4a 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -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, }; })(); diff --git a/frontend/static/js/auth.js b/frontend/static/js/auth.js index e3557e1..dfdd892 100644 --- a/frontend/static/js/auth.js +++ b/frontend/static/js/auth.js @@ -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) { diff --git a/frontend/static/js/imageCompress.js b/frontend/static/js/imageCompress.js index afe5c30..b55c755 100755 --- a/frontend/static/js/imageCompress.js +++ b/frontend/static/js/imageCompress.js @@ -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,62 +32,101 @@ const ImageCompressor = (() => { * @returns {Promise} */ 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(); - // 긴 변이 maxPx 초과하면 비율 유지하며 축소 - if (width > maxPx || height > maxPx) { - if (width >= height) { - height = Math.round((height / width) * maxPx); - width = maxPx; - } else { - width = Math.round((width / height) * maxPx); - height = maxPx; - } + 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) { + if (width >= height) { + height = Math.round((height / width) * maxPx); + width = maxPx; + } else { + width = Math.round((width / height) * maxPx); + height = maxPx; } + } - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - canvas.getContext('2d').drawImage(img, 0, 0, width, height); + var canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; - canvas.toBlob( - (blob) => { - const compressed = new File( + var ctx = canvas.getContext('2d'); + if (!ctx) { resolve(file); return; } + img.onerror = null; // 이후 src 변경에 의한 onerror 재실행 방지 + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + function(blob) { + canvas.width = 0; canvas.height = 0; // canvas 메모리 해제 + + if (!blob || blob.size < 500) { resolve(file); return; } + + // blob이 실제로 렌더 가능한 이미지인지 검증 + // (OOM으로 drawImage가 빈 캔버스를 만들었을 경우를 잡기 위해) + var blobUrl = URL.createObjectURL(blob); + var check = new Image(); + check.onerror = function() { + URL.revokeObjectURL(blobUrl); + resolve(file); // 유효하지 않은 JPEG → 원본 사용 + }; + check.onload = function() { + URL.revokeObjectURL(blobUrl); + if (!check.naturalWidth || !check.naturalHeight) { + resolve(file); // 빈 이미지 → 원본 사용 + return; + } + resolve(new File( [blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: Date.now() } - ); - resolve(compressed); - }, - 'image/jpeg', - cfg.image_quality / 100 // 0~1 범위 - ); - }; - img.src = e.target.result; + )); + }; + check.src = blobUrl; + }, + 'image/jpeg', + cfg.image_quality / 100 // 0~1 범위 + ); }; - reader.readAsDataURL(file); + + img.src = objUrl; }); } /** - * FileList / File[] 전체를 압축 + * FileList / File[] 전체를 순차 압축 (병렬 처리 시 모바일 메모리 부족 방지) * @param {FileList|File[]} files * @returns {Promise} */ 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,52 +136,59 @@ 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; - input.addEventListener('change', async function () { + input.addEventListener('change', async function() { preview.innerHTML = ''; if (info) info.textContent = '압축 중...'; - const cfg = await loadConfig(); - const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0); + 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'); - 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); + 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; - // 삭제 버튼 - 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)); - input.files = dt2.files; - wrap.remove(); - updateInfo(input, info); - }; + (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,16 +228,16 @@ 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 () { + 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 재실행 + main.dispatchEvent(new Event('change')); // setupPreview 재실행 this.value = ''; }); } diff --git a/frontend/static/pages/admin/dashboard.html b/frontend/static/pages/admin/dashboard.html index 6a25a1e..cb1a385 100644 --- a/frontend/static/pages/admin/dashboard.html +++ b/frontend/static/pages/admin/dashboard.html @@ -390,7 +390,7 @@ - + - + - - + + - + + - - + + + - + - +