기능 개선 — 사진 업로드, 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:
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user