초기 커밋 - EV AS 관리 시스템

This commit is contained in:
root
2026-04-18 06:18:58 +09:00
commit 7a5c397983
52 changed files with 6044 additions and 0 deletions

72
frontend/static/js/api.js Normal file
View File

@@ -0,0 +1,72 @@
const API = (() => {
const BASE = '/api';
function token() { return localStorage.getItem('ev_token') || ''; }
async function req(method, path, body = null, isForm = false) {
const headers = {};
if (token()) headers['Authorization'] = 'Bearer ' + token();
let fetchBody = null;
if (body) {
if (isForm) {
fetchBody = body;
} else {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
}
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
if (res.status === 401) { Auth.logout(); return; }
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
throw new Error(err.detail || '오류');
}
const ct = res.headers.get('content-type') || '';
if (ct.includes('spreadsheet') || ct.includes('octet') || ct.includes('excel')) {
return res.blob();
}
return res.json().catch(() => ({}));
}
// 엑셀 다운로드 전용 함수 — 인증 토큰 포함, 에러 처리 강화
async function download(path, filename) {
try {
const headers = {};
if (token()) headers['Authorization'] = 'Bearer ' + token();
const res = await fetch(BASE + path, { method: 'GET', headers });
if (res.status === 401) { Auth.logout(); return; }
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '다운로드 오류' }));
throw new Error(err.detail || '다운로드 오류');
}
const blob = await res.blob();
if (!blob || blob.size === 0) {
alert('데이터가 없어 엑셀 파일을 생성할 수 없습니다.');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch(e) {
alert('엑셀 다운로드 실패: ' + e.message);
}
}
return {
get: (path) => req('GET', path),
post: (path, body) => req('POST', path, body, body instanceof FormData),
put: (path, body) => req('PUT', path, body, body instanceof FormData),
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
delete: (path) => req('DELETE', path),
download,
};
})();

View File

@@ -0,0 +1,42 @@
const API = (() => {
const BASE = '/api';
function token() { return localStorage.getItem('ev_token') || ''; }
async function req(method, path, body = null, isForm = false) {
const headers = {};
if (token()) headers['Authorization'] = 'Bearer ' + token();
let fetchBody = null;
if (body) {
if (isForm) {
fetchBody = body; // FormData
} else {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
}
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
if (res.status === 401) { Auth.logout(); return; }
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
throw new Error(err.detail || '오류');
}
const ct = res.headers.get('content-type') || '';
if (ct.includes('spreadsheet') || ct.includes('octet')) return res.blob();
return res.json().catch(() => ({}));
}
return {
get: (path) => req('GET', path),
post: (path, body) => req('POST', path, body, body instanceof FormData),
put: (path, body) => req('PUT', path, body, body instanceof FormData),
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
delete: (path) => req('DELETE', path),
download: async (path, filename) => {
const blob = await req('GET', path);
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename;
a.click(); URL.revokeObjectURL(url);
}
};
})();

View File

@@ -0,0 +1,63 @@
const Auth = (() => {
const KEY_TOKEN = 'ev_token';
const KEY_ROLE = 'ev_role';
const KEY_NAME = 'ev_name';
const KEY_ID = 'ev_uid';
function save(token, role, name, id) {
localStorage.setItem(KEY_TOKEN, token);
localStorage.setItem(KEY_ROLE, role);
localStorage.setItem(KEY_NAME, name);
localStorage.setItem(KEY_ID, id);
}
function token() { return localStorage.getItem(KEY_TOKEN); }
function role() { return localStorage.getItem(KEY_ROLE); }
function name() { return localStorage.getItem(KEY_NAME); }
function uid() { return localStorage.getItem(KEY_ID); }
function logout() {
[KEY_TOKEN, KEY_ROLE, KEY_NAME, KEY_ID].forEach(k => localStorage.removeItem(k));
location.href = '/pages/login.html';
}
function require(allowedRoles) {
if (!token()) { logout(); return false; }
if (allowedRoles && !allowedRoles.includes(role())) {
alert('접근 권한이 없습니다.');
history.back();
return false;
}
return true;
}
function renderNav(el) {
if (!el) return;
el.innerHTML = `
<span class="nav-user">
<span>${name()} <small style="color:var(--accent)">[${role()}]</small></span>
<a onclick="Auth.logout()">로그아웃</a>
</span>`;
}
function statusBadge(status) {
const map = {
pending_approval: '승인대기', pending: '접수', in_progress: '처리중',
done: '완료', waiting: '부품대기', revisit: '재방문',
registered: '등록', reviewing: '검토중', developing: '개발중',
deployed: '배포완료',
};
return `<span class="badge s-${status}">${map[status] || status}</span>`;
}
function costStatusBadge(s) {
const map = { pending:'미처리', billed:'청구완료', waived:'면제', settled:'정산완료' };
return `<span class="badge s-cost-${s}">${map[s] || s}</span>`;
}
function fmtDt(dt) {
if (!dt) return '-';
return new Date(dt).toLocaleString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
}
return { save, token, role, name, uid, logout, require, renderNav, statusBadge, costStatusBadge, fmtDt };
})();

View File

@@ -0,0 +1,174 @@
/**
* imageCompress.js
* 업로드 전 브라우저에서 이미지를 리사이즈 + JPEG 압축
* Canvas API 사용 — 외부 라이브러리 불필요
*/
const ImageCompressor = (() => {
// 서버에서 가져온 설정 캐시
let _cfg = null;
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */
async function loadConfig() {
if (_cfg) return _cfg;
try {
const res = await fetch('/api/settings/public');
_cfg = await res.json();
} catch {
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
}
return _cfg;
}
/**
* File 객체 하나를 압축해서 새 File 로 반환
* @param {File} file - 원본 이미지 파일
* @param {Object} cfg - { image_compress_enabled, image_max_px, image_quality }
* @returns {Promise<File>}
*/
function compressOne(file, cfg) {
return new Promise((resolve) => {
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
return resolve(file);
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const maxPx = cfg.image_max_px;
let { width, height } = img;
// 긴 변이 maxPx 초과하면 비율 유지하며 축소
if (width > maxPx || height > maxPx) {
if (width >= height) {
height = Math.round((height / width) * maxPx);
width = maxPx;
} else {
width = Math.round((width / height) * maxPx);
height = maxPx;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const compressed = new File(
[blob],
file.name.replace(/\.[^.]+$/, '') + '.jpg',
{ type: 'image/jpeg', lastModified: Date.now() }
);
resolve(compressed);
},
'image/jpeg',
cfg.image_quality / 100 // 0~1 범위
);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
/**
* FileList / File[] 전체를 압축
* @param {FileList|File[]} files
* @returns {Promise<File[]>}
*/
async function compressAll(files) {
const cfg = await loadConfig();
return Promise.all(Array.from(files).map(f => compressOne(f, cfg)));
}
/**
* input[type=file] + 미리보기 div 에 압축 프리뷰 설정
* @param {string} inputId - file input 요소 id
* @param {string} previewId - 미리보기 컨테이너 id
* @param {string} infoId - 용량 정보 표시 span id (선택)
*/
function setupPreview(inputId, previewId, infoId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
const info = infoId ? document.getElementById(infoId) : null;
if (!input || !preview) return;
input.addEventListener('change', async function () {
preview.innerHTML = '';
if (info) info.textContent = '압축 중...';
const cfg = await loadConfig();
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0);
const compressed = await compressAll(this.files);
const compBytes = compressed.reduce((s, f) => s + f.size, 0);
// DataTransfer 로 input.files 교체 (압축된 파일로)
const dt = new DataTransfer();
compressed.forEach(f => dt.items.add(f));
this.files = dt.files;
// 미리보기 렌더링
compressed.forEach((f, i) => {
const url = URL.createObjectURL(f);
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;display:inline-block;';
const img = document.createElement('img');
img.src = url;
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;';
img.onload = () => URL.revokeObjectURL(url);
// 삭제 버튼
const del = document.createElement('button');
del.textContent = '×';
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;';
del.onclick = () => {
// 해당 파일 제거
const cur = Array.from(input.files);
cur.splice(i, 1);
const dt2 = new DataTransfer();
cur.forEach(f2 => dt2.items.add(f2));
input.files = dt2.files;
wrap.remove();
updateInfo(input, info);
};
wrap.appendChild(img);
wrap.appendChild(del);
preview.appendChild(wrap);
});
// 용량 정보
if (info) {
const pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
if (cfg.image_compress_enabled && pct > 0) {
info.textContent = `${compressed.length}장 | ${fmt(origBytes)}${fmt(compBytes)} (${pct}% 절약) | 최대 ${cfg.image_max_px}px / 품질 ${cfg.image_quality}%`;
info.style.color = '#00875A';
} else {
info.textContent = `${compressed.length}장 | ${fmt(compBytes)} (압축 비활성)`;
info.style.color = '#8899BB';
}
}
});
}
function updateInfo(input, info) {
if (!info) return;
const bytes = Array.from(input.files).reduce((s, f) => s + f.size, 0);
info.textContent = `${input.files.length}장 | ${fmt(bytes)}`;
}
function fmt(bytes) {
return bytes < 1024 * 1024
? (bytes / 1024).toFixed(0) + ' KB'
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return { compressAll, setupPreview, loadConfig };
})();