931ad173ab
- read_root() 가 세션 미인증 시 dbx-main /login?next=/orderlist/ 로 302. OMS 는 더 이상 자체 로그인 UI 를 가지지 않는다. - index.html: #login-screen 블록 제거, dashboard 만 렌더. - 우측 상단 프로필 영역을 메인 페이지 스타일로 개편: 이름 + 이메일 + 아바타(Google picture URL, 폴백 아이콘) + 로그아웃 텍스트 버튼. - main.py 가 세션의 user_name/user_email/user_picture 를 HTML placeholder 로 치환. - app.js checkAuth() 단순화: 화면 토글 제거, 401 시 dbx-main 로그인으로 자동 이동. - style.css 에 새 프로필 영역 CSS 추가. Google 아바타가 보이려면 dbx-main 도 같이 user_picture 를 세션 top-level 에 저장하도록 수정됨 (dbx-main 별도 커밋).
1581 lines
71 KiB
JavaScript
1581 lines
71 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const APP_BASE_PATH = (window.APP_BASE_PATH || '').replace(/\/$/, '');
|
|
const appUrl = (path) => `${APP_BASE_PATH}${path.startsWith('/') ? path : `/${path}`}`;
|
|
|
|
// --- Elements ---
|
|
// loginScreen 은 더 이상 존재하지 않음 (서버에서 미인증 사용자를 dbx-main 으로 리다이렉트)
|
|
const userNameEl = document.getElementById('user-name');
|
|
|
|
const searchInputs = {
|
|
name: document.getElementById('search-name'),
|
|
address: document.getElementById('search-address'),
|
|
phone: document.getElementById('search-phone'),
|
|
tracking: document.getElementById('search-tracking')
|
|
};
|
|
const searchStartDate = document.getElementById('search-start-date');
|
|
const searchEndDate = document.getElementById('search-end-date');
|
|
const btnSearch = document.getElementById('btn-search');
|
|
const btnResetSearch = document.getElementById('btn-reset-search');
|
|
const btnDownloadSearchResults = document.getElementById('btn-download-search-results');
|
|
const btnDataManagement = document.getElementById('btn-data-management');
|
|
const fileUpload = document.getElementById('file-upload');
|
|
const resultsBody = document.getElementById('results-body');
|
|
const resultCount = document.getElementById('result-count');
|
|
|
|
const dataManagementModal = document.getElementById('data-management-modal');
|
|
const dbSizeBadge = document.getElementById('db-size-badge');
|
|
const orderEditModal = document.getElementById('order-edit-modal');
|
|
const returnRequestModal = document.getElementById('return-request-modal');
|
|
const uploadProgressModal = document.getElementById('upload-progress-modal');
|
|
const btnCloseDataManagement = document.getElementById('data-management-close');
|
|
const btnCloseOrderEdit = document.getElementById('order-edit-close');
|
|
const btnCancelOrderEdit = document.getElementById('btn-cancel-order-edit');
|
|
const btnSaveOrderEdit = document.getElementById('btn-save-order-edit');
|
|
const btnDeleteOrderEdit = document.getElementById('btn-delete-order-edit');
|
|
const btnOpenReturnRequest = document.getElementById('btn-open-return-request');
|
|
const btnCloseReturnRequest = document.getElementById('return-request-close');
|
|
const btnCancelReturnRequest = document.getElementById('btn-cancel-return-request');
|
|
const btnSendReturnRequest = document.getElementById('btn-send-return-request');
|
|
const btnCloseUploadProgress = document.getElementById('btn-close-upload-progress');
|
|
|
|
const editRecipientName = document.getElementById('edit-recipient-name');
|
|
const editAddress = document.getElementById('edit-address');
|
|
const editRecipientMobile = document.getElementById('edit-recipient-mobile');
|
|
const editCustomerNote = document.getElementById('edit-customer-note');
|
|
const editOrderNote = document.getElementById('edit-order-note');
|
|
const returnRequestDate = document.getElementById('return-request-date');
|
|
const returnRequestType = document.getElementById('return-request-type');
|
|
const returnRequestTracking = document.getElementById('return-request-tracking');
|
|
const returnRequestRemarks = document.getElementById('return-request-remarks');
|
|
|
|
const uploadModalMessage = document.getElementById('upload-modal-message');
|
|
const uploadDataProgressText = document.getElementById('upload-data-progress-text');
|
|
const uploadDataProgressFill = document.getElementById('upload-data-progress-fill');
|
|
const uploadDataProgressCount = document.getElementById('upload-data-progress-count');
|
|
const uploadIndexProgressText = document.getElementById('upload-index-progress-text');
|
|
const uploadIndexProgressFill = document.getElementById('upload-index-progress-fill');
|
|
const uploadIndexProgressCount = document.getElementById('upload-index-progress-count');
|
|
|
|
const btnOptimizeDb = document.getElementById('btn-optimize-db');
|
|
const dbProgressContainer = document.getElementById('optimize-progress-container');
|
|
const dbProgressText = document.getElementById('optimize-progress-text');
|
|
const dbProgressFill = document.getElementById('optimize-progress-fill');
|
|
|
|
// DB Export Elements
|
|
const btnExportDb = document.getElementById('btn-export-db');
|
|
const exportStartDate = document.getElementById('export-start-date');
|
|
const exportEndDate = document.getElementById('export-end-date');
|
|
const exportAllDatesChk = document.getElementById('export-all-dates-chk');
|
|
const exportDateRangeContainer = document.getElementById('export-date-range-container');
|
|
const exportProgressContainer = document.getElementById('export-progress-container');
|
|
const exportProgressMessage = document.getElementById('export-progress-message');
|
|
const exportProgressText = document.getElementById('export-progress-text');
|
|
const exportProgressFill = document.getElementById('export-progress-fill');
|
|
const exportProgressCount = document.getElementById('export-progress-count');
|
|
const btnBackupDb = document.getElementById('btn-backup-db');
|
|
const btnDownloadEventLog = document.getElementById('btn-download-event-log');
|
|
const restoreDbFile = document.getElementById('restore-db-file');
|
|
const repeatStartDate = document.getElementById('repeat-start-date');
|
|
const repeatEndDate = document.getElementById('repeat-end-date');
|
|
const repeatMinCount = document.getElementById('repeat-min-count');
|
|
const btnDownloadRepeatPurchase = document.getElementById('btn-download-repeat-purchase');
|
|
const deleteStartDate = document.getElementById('delete-start-date');
|
|
const deleteEndDate = document.getElementById('delete-end-date');
|
|
const btnDeleteDateRangeOrders = document.getElementById('btn-delete-date-range-orders');
|
|
const deleteDateProgressContainer = document.getElementById('delete-date-progress-container');
|
|
const deleteDateProgressMessage = document.getElementById('delete-date-progress-message');
|
|
const deleteDateProgressText = document.getElementById('delete-date-progress-text');
|
|
const deleteDateProgressFill = document.getElementById('delete-date-progress-fill');
|
|
const deleteDateProgressCount = document.getElementById('delete-date-progress-count');
|
|
const deleteCleanupProgressMessage = document.getElementById('delete-cleanup-progress-message');
|
|
const deleteCleanupProgressText = document.getElementById('delete-cleanup-progress-text');
|
|
const deleteCleanupProgressFill = document.getElementById('delete-cleanup-progress-fill');
|
|
const btnDeleteAllOrders = document.getElementById('btn-delete-all-orders');
|
|
|
|
// State
|
|
let currentUser = null;
|
|
let latestUploadProgress = null;
|
|
let currentSearchPage = 1;
|
|
let editingOrderId = null;
|
|
let editingOrderData = null;
|
|
let highlightDateGroups = false;
|
|
let latestSearchParams = null;
|
|
let latestSearchTotal = 0;
|
|
|
|
// --- Initialization ---
|
|
checkAuth();
|
|
|
|
function logUiEvent(action, details = {}) {
|
|
fetch(appUrl('/api/settings/log-ui-event'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action, details })
|
|
}).catch(() => {});
|
|
}
|
|
|
|
async function loadDatabaseSize() {
|
|
if (!dbSizeBadge) return;
|
|
|
|
dbSizeBadge.textContent = '용량 확인 중';
|
|
try {
|
|
const response = await fetch(appUrl('/api/settings/database-size'));
|
|
if (!response.ok) {
|
|
throw new Error('DB 용량 조회 실패');
|
|
}
|
|
const data = await response.json();
|
|
dbSizeBadge.textContent = `DB ${data.formatted || '-'}`;
|
|
dbSizeBadge.title = data.database ? `DB 종류: ${data.database}` : 'DB 용량';
|
|
} catch (error) {
|
|
dbSizeBadge.textContent = '용량 확인 실패';
|
|
dbSizeBadge.title = error.message || 'DB 용량 조회 실패';
|
|
}
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function todayKstDateString() {
|
|
try {
|
|
return new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone: 'Asia/Seoul',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(new Date());
|
|
} catch (error) {
|
|
const now = new Date();
|
|
const localDate = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
|
return localDate.toISOString().slice(0, 10);
|
|
}
|
|
}
|
|
|
|
function trackingNumberOptions(value) {
|
|
const trackingText = String(value || '').trim();
|
|
if (!trackingText) return [];
|
|
const parts = trackingText
|
|
.split(/[\/,\n\r]+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
return [...new Set(parts)];
|
|
}
|
|
|
|
async function copyTextToClipboard(text) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.setAttribute('readonly', '');
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.left = '-9999px';
|
|
textarea.style.top = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
}
|
|
|
|
// --- Authentication ---
|
|
// 미인증 사용자는 서버(read_root) 가 이미 dbx-main 로그인으로 리다이렉트하므로
|
|
// 여기서는 is_admin 확인 + 세션 만료 시 다시 dbx-main 으로 보내는 역할만 한다.
|
|
async function checkAuth() {
|
|
try {
|
|
const res = await fetch(appUrl('/api/users/me'));
|
|
if (res.ok) {
|
|
currentUser = await res.json();
|
|
if (userNameEl && currentUser.name) {
|
|
userNameEl.textContent = currentUser.name;
|
|
}
|
|
if (btnDataManagement) {
|
|
btnDataManagement.style.display = currentUser.is_admin ? 'inline-flex' : 'none';
|
|
}
|
|
} else if (res.status === 401) {
|
|
// 세션 만료 — dbx-main 로그인으로 위임 (next 로 현재 위치 보존)
|
|
const next = encodeURIComponent((APP_BASE_PATH || '') + '/');
|
|
window.location.href = appUrl('/login') + '?next=' + next;
|
|
}
|
|
} catch (error) {
|
|
console.error('Auth check error', error);
|
|
}
|
|
}
|
|
|
|
// --- Upload Data ---
|
|
function resetUploadProgressModal() {
|
|
latestUploadProgress = null;
|
|
uploadModalMessage.textContent = '업로드를 준비하는 중입니다...';
|
|
uploadModalMessage.classList.remove('error', 'success');
|
|
uploadDataProgressFill.style.width = '0%';
|
|
uploadIndexProgressFill.style.width = '0%';
|
|
uploadDataProgressText.textContent = '0%';
|
|
uploadIndexProgressText.textContent = '0%';
|
|
uploadDataProgressCount.textContent = '0 / 0건';
|
|
uploadIndexProgressCount.textContent = '0 / 0건';
|
|
btnCloseUploadProgress.disabled = true;
|
|
}
|
|
|
|
function setUploadProgress(data) {
|
|
latestUploadProgress = {
|
|
...(latestUploadProgress || {}),
|
|
...data
|
|
};
|
|
data = latestUploadProgress;
|
|
|
|
const uploadPercent = Math.max(0, Math.min(100, Number(data.upload_progress || 0)));
|
|
const indexPercent = Math.max(0, Math.min(100, Number(data.index_progress || 0)));
|
|
const totalRows = Number(data.total_rows || 0);
|
|
const processedRows = Number(data.processed_rows || 0);
|
|
const indexedRows = Number(data.indexed_rows || 0);
|
|
|
|
uploadDataProgressFill.style.width = `${uploadPercent}%`;
|
|
uploadIndexProgressFill.style.width = `${indexPercent}%`;
|
|
uploadDataProgressText.textContent = `${Math.round(uploadPercent)}%`;
|
|
uploadIndexProgressText.textContent = `${Math.round(indexPercent)}%`;
|
|
uploadDataProgressCount.textContent = `${processedRows.toLocaleString()} / ${totalRows.toLocaleString()}건`;
|
|
uploadIndexProgressCount.textContent = `${indexedRows.toLocaleString()} / ${totalRows.toLocaleString()}건`;
|
|
uploadModalMessage.textContent = data.message || '처리 중입니다...';
|
|
|
|
if (data.status === 'completed') {
|
|
uploadModalMessage.classList.add('success');
|
|
uploadModalMessage.classList.remove('error');
|
|
btnCloseUploadProgress.disabled = false;
|
|
} else if (data.status === 'error') {
|
|
uploadModalMessage.classList.add('error');
|
|
uploadModalMessage.classList.remove('success');
|
|
btnCloseUploadProgress.disabled = false;
|
|
} else {
|
|
uploadModalMessage.classList.remove('error', 'success');
|
|
}
|
|
}
|
|
|
|
btnCloseUploadProgress.addEventListener('click', () => {
|
|
logUiEvent('업로드 진행 창 닫기', {
|
|
status: latestUploadProgress?.status || '',
|
|
total_rows: latestUploadProgress?.total_rows || 0
|
|
});
|
|
uploadProgressModal.style.display = 'none';
|
|
});
|
|
|
|
fileUpload.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
logUiEvent('파일 업로드 파일 선택', {
|
|
filename: file.name,
|
|
size: file.size,
|
|
type: file.type
|
|
});
|
|
|
|
const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('upload_token', uploadToken);
|
|
|
|
resetUploadProgressModal();
|
|
uploadProgressModal.style.display = 'flex';
|
|
fileUpload.disabled = true;
|
|
|
|
const eventSource = new EventSource(appUrl(`/api/orders/progress?upload_token=${encodeURIComponent(uploadToken)}`));
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.status !== 'idle') {
|
|
setUploadProgress(data);
|
|
}
|
|
if (data.status === 'completed' || data.status === 'error') {
|
|
eventSource.close();
|
|
fileUpload.disabled = false;
|
|
fileUpload.value = '';
|
|
if (data.status === 'completed') {
|
|
showToast(data.message || '업로드와 검색 인덱스 반영이 완료되었습니다.', 'success');
|
|
} else {
|
|
showToast(data.message || '업로드 실패', 'error');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Upload progress parse error', err);
|
|
}
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(appUrl('/api/orders/upload'), {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
if (!['completed', 'error'].includes(latestUploadProgress?.status)) {
|
|
setUploadProgress({
|
|
...(latestUploadProgress || {}),
|
|
status: data.status || 'queued',
|
|
message: data.message || '업로드 작업이 시작되었습니다.'
|
|
});
|
|
}
|
|
} else {
|
|
showToast(data.detail || 'Upload failed', 'error');
|
|
setUploadProgress({
|
|
status: 'error',
|
|
message: data.detail || '업로드 실패'
|
|
});
|
|
eventSource.close();
|
|
fileUpload.disabled = false;
|
|
fileUpload.value = '';
|
|
}
|
|
} catch (error) {
|
|
showToast('네트워크 오류가 발생했습니다.', 'error');
|
|
setUploadProgress({
|
|
status: 'error',
|
|
message: '네트워크 오류가 발생했습니다.'
|
|
});
|
|
eventSource.close();
|
|
fileUpload.disabled = false;
|
|
fileUpload.value = '';
|
|
}
|
|
});
|
|
|
|
// --- Search ---
|
|
btnSearch.addEventListener('click', () => {
|
|
highlightDateGroups = false;
|
|
performSearch(1);
|
|
});
|
|
btnResetSearch.addEventListener('click', () => {
|
|
highlightDateGroups = false;
|
|
Object.values(searchInputs).forEach(input => {
|
|
input.value = '';
|
|
});
|
|
if (searchStartDate) searchStartDate.value = '';
|
|
if (searchEndDate) searchEndDate.value = '';
|
|
latestSearchParams = null;
|
|
latestSearchTotal = 0;
|
|
if (btnDownloadSearchResults) {
|
|
btnDownloadSearchResults.disabled = true;
|
|
}
|
|
});
|
|
|
|
// Allow enter key to search
|
|
Object.values(searchInputs).forEach(input => {
|
|
input.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
highlightDateGroups = false;
|
|
performSearch();
|
|
}
|
|
});
|
|
});
|
|
|
|
function getCurrentSearchParams() {
|
|
const params = new URLSearchParams();
|
|
const nameVal = searchInputs.name.value.trim();
|
|
const addressVal = searchInputs.address.value.trim();
|
|
const phoneVal = searchInputs.phone.value.trim();
|
|
const trackingVal = searchInputs.tracking.value.trim();
|
|
const startVal = searchStartDate ? searchStartDate.value : "";
|
|
const endVal = searchEndDate ? searchEndDate.value : "";
|
|
|
|
if (nameVal) params.append('name', nameVal);
|
|
if (addressVal) params.append('address', addressVal);
|
|
if (phoneVal) params.append('phone', phoneVal);
|
|
if (trackingVal) params.append('tracking', trackingVal);
|
|
if (startVal) params.append('order_start_date', startVal);
|
|
if (endVal) params.append('order_end_date', endVal);
|
|
|
|
return {
|
|
params,
|
|
hasFilters: Boolean(nameVal || addressVal || phoneVal || trackingVal || startVal || endVal)
|
|
};
|
|
}
|
|
|
|
async function performSearch(page = 1) {
|
|
currentSearchPage = page;
|
|
const searchState = getCurrentSearchParams();
|
|
|
|
if (!searchState.hasFilters) {
|
|
showToast('검색 조건을 하나 이상 입력해주세요.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
btnSearch.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 검색중';
|
|
btnSearch.disabled = true;
|
|
|
|
const params = new URLSearchParams(searchState.params);
|
|
params.set('page', page);
|
|
params.set('size', 27);
|
|
const queryUrl = appUrl(`/api/orders/search?${params.toString()}`);
|
|
|
|
const res = await fetch(queryUrl);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
latestSearchParams = new URLSearchParams(searchState.params);
|
|
latestSearchTotal = Number(data.total || 0);
|
|
if (btnDownloadSearchResults) {
|
|
btnDownloadSearchResults.disabled = latestSearchTotal === 0;
|
|
}
|
|
renderTable(data.items, data.total);
|
|
renderPagination(data.page, data.total_pages);
|
|
if (page === 1) {
|
|
showToast(`${data.total} 건이 검색되었습니다.`, 'success');
|
|
}
|
|
} else {
|
|
showToast('검색 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('검색 중 오류 발생', 'error');
|
|
} finally {
|
|
btnSearch.innerHTML = '<i class="fa-solid fa-magnifying-glass"></i> 검색';
|
|
btnSearch.disabled = false;
|
|
}
|
|
}
|
|
|
|
if (btnDownloadSearchResults) {
|
|
btnDownloadSearchResults.addEventListener('click', async () => {
|
|
if (!latestSearchParams || latestSearchTotal <= 0) {
|
|
showToast('다운로드할 검색 결과가 없습니다.', 'error');
|
|
return;
|
|
}
|
|
|
|
const originalText = btnDownloadSearchResults.innerHTML;
|
|
btnDownloadSearchResults.disabled = true;
|
|
btnDownloadSearchResults.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 생성 중';
|
|
|
|
try {
|
|
const res = await fetch(appUrl(`/api/orders/export-search?${latestSearchParams.toString()}`));
|
|
if (res.ok) {
|
|
await downloadFileFromResponse(res, '검색결과.xlsx');
|
|
showToast('검색 결과 다운로드가 완료되었습니다.', 'success');
|
|
} else {
|
|
const data = await res.json();
|
|
showToast(data.detail || '검색 결과 다운로드 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('검색 결과 다운로드 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
btnDownloadSearchResults.disabled = latestSearchTotal === 0;
|
|
btnDownloadSearchResults.innerHTML = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderTable(orders, totalCount) {
|
|
resultCount.textContent = totalCount || orders.length;
|
|
|
|
if (orders.length === 0) {
|
|
resultsBody.innerHTML = `<tr><td colspan="11" class="empty-state">검색 결과가 없습니다.</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
resultsBody.innerHTML = '';
|
|
let previousDateKey = null;
|
|
let useAlternateDateShade = false;
|
|
orders.forEach(order => {
|
|
const tr = document.createElement('tr');
|
|
|
|
const pCode = order.product_code || '';
|
|
const pName = order.product_name || '';
|
|
|
|
// Date formatting logic "yy년 mm월 dd일"
|
|
let formattedDate = order.order_date || '';
|
|
if (formattedDate) {
|
|
// Try parsing YYYY-MM-DD
|
|
const parts = formattedDate.split('-');
|
|
if (parts.length >= 3) {
|
|
const year = parts[0].substring(2); // yy
|
|
const month = parts[1];
|
|
const day = parts[2].substring(0, 2); // in case there's time component
|
|
formattedDate = `${year}년 ${month}월 ${day}일`;
|
|
}
|
|
}
|
|
const dateKey = (order.order_date || '').slice(0, 10);
|
|
if (highlightDateGroups && dateKey !== previousDateKey) {
|
|
useAlternateDateShade = previousDateKey === null ? false : !useAlternateDateShade;
|
|
previousDateKey = dateKey;
|
|
}
|
|
if (highlightDateGroups && useAlternateDateShade) {
|
|
tr.classList.add('date-group-alt');
|
|
}
|
|
|
|
// Create a clickable tracking link if tracking number exists
|
|
let trackingCell = `<td></td>`;
|
|
if (order.tracking_number) {
|
|
// Handle multiple tracking numbers separated by '/'
|
|
const trackingNumbers = order.tracking_number.split('/').map(t => t.trim()).filter(t => t);
|
|
const links = trackingNumbers.map(num => {
|
|
const trackingUrl = `https://www.cjlogistics.com/ko/tool/parcel/tracking?gnbInvcNo=${encodeURIComponent(num)}`;
|
|
return `<a href="${trackingUrl}" target="_blank" class="tracking-link" style="color: var(--primary-color); text-decoration: underline; cursor: pointer;">${escapeHtml(num)}</a>`;
|
|
});
|
|
|
|
// Join multiple links with a visual separator, e.g. ' / ' or '<br>'
|
|
trackingCell = `<td>${links.join(' / ')}</td>`;
|
|
}
|
|
|
|
const orderNote = order.order_note || '';
|
|
const orderNoteIcon = orderNote
|
|
? `<span class="order-note-icon" title="${escapeHtml(orderNote)}" aria-label="주문 메모 있음">📝</span>`
|
|
: '';
|
|
const orderNoLabel = `<span class="order-no-cell">${orderNoteIcon}<span>${escapeHtml(order.order_no_mall || '')}</span></span>`;
|
|
let orderNoCell = `<td>${orderNoLabel}</td>`;
|
|
if (order.vendor === '자사몰' && order.order_no_mall) {
|
|
const url = `https://miraskitchen.cafe24.com/admin/php/shop1/s_new/order_detail.php?order_id=${encodeURIComponent(order.order_no_mall)}`;
|
|
orderNoCell = `<td><span class="order-no-cell">${orderNoteIcon}<a href="${url}" target="_blank" style="color: var(--primary-color); text-decoration: underline; cursor: pointer;">${escapeHtml(order.order_no_mall)}</a></span></td>`;
|
|
} else if (order.vendor === '스마트스토어' && order.order_no_mall) {
|
|
const url = `https://sell.smartstore.naver.com/#/naverpay/manage/order?detailSearch.type=ORDER_NO&detailSearch.keyword=${encodeURIComponent(order.order_no_mall)}`;
|
|
orderNoCell = `<td><span class="order-no-cell">${orderNoteIcon}<a href="${url}" target="_blank" style="color: var(--primary-color); text-decoration: underline; cursor: pointer;">${escapeHtml(order.order_no_mall)}</a></span></td>`;
|
|
}
|
|
const vendorText = order.vendor || '';
|
|
const normalizedVendor = vendorText.replace(/\s/g, '');
|
|
let vendorCell = `<td>${escapeHtml(vendorText)}</td>`;
|
|
if (normalizedVendor === '자사몰' || normalizedVendor === '가사몰') {
|
|
vendorCell = '<td><span class="vendor-badge vendor-badge-own">자사몰</span></td>';
|
|
} else if (normalizedVendor === '스마트스토어') {
|
|
vendorCell = '<td><span class="vendor-badge vendor-badge-smartstore">스마트스토어</span></td>';
|
|
}
|
|
const countBadge = (count, title, searchType) => {
|
|
const numericCount = Number(count || 0);
|
|
if (numericCount <= 1) return '';
|
|
let levelClass = 'count-badge-neutral';
|
|
if (numericCount >= 10) {
|
|
levelClass = 'count-badge-danger';
|
|
} else if (numericCount >= 5) {
|
|
levelClass = 'count-badge-warning';
|
|
}
|
|
return `<span class="count-badge ${levelClass}" data-search-type="${searchType}" title="${title} ${numericCount}건">${numericCount}</span>`;
|
|
};
|
|
const addressToCopy = order.address || '';
|
|
const addressToSearch = addressToCopy.trim();
|
|
const phoneToCopy = order.recipient_mobile || order.recipient_phone || '';
|
|
const phoneToSearch = phoneToCopy.trim();
|
|
const addressCountBadge = countBadge(
|
|
order.address_order_count,
|
|
'전체 데이터에서 같은 주소의 주문날짜',
|
|
'address'
|
|
);
|
|
const phoneCountBadge = countBadge(
|
|
order.phone_order_count,
|
|
'전체 데이터에서 같은 전화번호의 주문날짜',
|
|
'phone'
|
|
);
|
|
const customerNote = order.customer_note || '';
|
|
const noteIcon = customerNote
|
|
? `<span class="customer-note-icon" title="${escapeHtml(customerNote)}" aria-label="고객 메모 있음">📝</span>`
|
|
: '';
|
|
|
|
tr.innerHTML = `
|
|
<td>${escapeHtml(formattedDate)}</td>
|
|
<td class="copyable editable-contact" data-edit-field="name" title="클릭하여 복사 / 더블클릭하여 수정" style="cursor: pointer;"><span class="customer-name-cell">${noteIcon}<strong>${escapeHtml(order.recipient_name || '')}</strong></span></td>
|
|
<td>${escapeHtml(pCode)}</td>
|
|
<td title="${escapeHtml(pName)}">${escapeHtml(pName)}</td>
|
|
<td>${escapeHtml(order.order_quantity || '')}</td>
|
|
<td class="cell-address copyable editable-contact" data-edit-field="address" title="클릭하여 복사 / 더블클릭하여 수정" style="cursor: pointer;"><span class="address-cell-inner"><span class="address-text">${escapeHtml(addressToCopy)}</span>${addressCountBadge}</span></td>
|
|
<td class="cell-phone copyable editable-contact" data-edit-field="phone" title="클릭하여 복사 / 더블클릭하여 수정" style="cursor: pointer;"><span class="phone-cell-inner"><span class="phone-text">${escapeHtml(phoneToCopy)}</span>${phoneCountBadge}</span></td>
|
|
${trackingCell}
|
|
${vendorCell}
|
|
${orderNoCell}
|
|
<td title="${escapeHtml(order.order_list_1 || '')}">${escapeHtml(order.order_list_1 || '')}</td>
|
|
`;
|
|
tr.querySelector('[data-edit-field="name"]').dataset.copy = order.recipient_name || '';
|
|
tr.querySelector('[data-edit-field="address"]').dataset.copy = addressToCopy;
|
|
tr.querySelector('[data-edit-field="phone"]').dataset.copy = phoneToCopy;
|
|
|
|
let copyTimer = null;
|
|
|
|
tr.addEventListener('dblclick', (e) => {
|
|
const editableCell = e.target.closest('.editable-contact');
|
|
if (!editableCell) return;
|
|
if (copyTimer) {
|
|
clearTimeout(copyTimer);
|
|
copyTimer = null;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openOrderEditModal(order, editableCell.dataset.editField);
|
|
});
|
|
|
|
tr.addEventListener('click', (e) => {
|
|
if (e.target.tagName === 'A') return;
|
|
const countBadgeEl = e.target.closest('.count-badge');
|
|
if (countBadgeEl) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
searchInputs.tracking.value = '';
|
|
if (searchStartDate) searchStartDate.value = '';
|
|
if (searchEndDate) searchEndDate.value = '';
|
|
|
|
if (countBadgeEl.dataset.searchType === 'address') {
|
|
searchInputs.name.value = order.recipient_name || '';
|
|
searchInputs.address.value = addressToSearch;
|
|
searchInputs.phone.value = '';
|
|
} else if (countBadgeEl.dataset.searchType === 'phone') {
|
|
searchInputs.name.value = '';
|
|
searchInputs.phone.value = phoneToSearch;
|
|
searchInputs.address.value = '';
|
|
}
|
|
|
|
highlightDateGroups = true;
|
|
performSearch(1);
|
|
return;
|
|
}
|
|
const copyableCell = e.target.closest('.copyable');
|
|
if (copyableCell) {
|
|
const textToCopy = copyableCell.getAttribute('data-copy');
|
|
if (textToCopy) {
|
|
if (copyTimer) clearTimeout(copyTimer);
|
|
copyTimer = setTimeout(() => {
|
|
copyTextToClipboard(textToCopy).then(() => {
|
|
showToast('클립보드에 복사되었습니다.', 'success');
|
|
}).catch(err => {
|
|
console.error('복사 실패:', err);
|
|
showToast('클립보드 복사에 실패했습니다.', 'error');
|
|
});
|
|
copyTimer = null;
|
|
}, 220);
|
|
}
|
|
}
|
|
});
|
|
|
|
resultsBody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function closeOrderEditModal() {
|
|
if (!orderEditModal) return;
|
|
orderEditModal.classList.remove('show');
|
|
if (returnRequestModal) returnRequestModal.classList.remove('show');
|
|
editingOrderId = null;
|
|
editingOrderData = null;
|
|
}
|
|
|
|
function closeReturnRequestModal() {
|
|
if (!returnRequestModal) return;
|
|
returnRequestModal.classList.remove('show');
|
|
}
|
|
|
|
function openOrderEditModal(order, focusField) {
|
|
if (!orderEditModal) return;
|
|
editingOrderId = order.id;
|
|
editingOrderData = order;
|
|
editRecipientName.value = order.recipient_name || '';
|
|
editAddress.value = order.address || '';
|
|
editRecipientMobile.value = order.recipient_mobile || order.recipient_phone || '';
|
|
if (editCustomerNote) {
|
|
editCustomerNote.value = order.customer_note || '';
|
|
}
|
|
if (editOrderNote) {
|
|
editOrderNote.value = order.order_note || '';
|
|
}
|
|
orderEditModal.classList.add('show');
|
|
|
|
const focusTarget = {
|
|
name: editRecipientName,
|
|
address: editAddress,
|
|
phone: editRecipientMobile,
|
|
}[focusField] || editRecipientName;
|
|
setTimeout(() => {
|
|
focusTarget.focus();
|
|
focusTarget.select();
|
|
}, 0);
|
|
}
|
|
|
|
function openReturnRequestModal() {
|
|
if (!returnRequestModal || !editingOrderId) return;
|
|
if (returnRequestDate) returnRequestDate.value = todayKstDateString();
|
|
if (returnRequestType) returnRequestType.value = '단순변심';
|
|
if (returnRequestTracking) {
|
|
const trackingOptions = trackingNumberOptions(editingOrderData ? editingOrderData.tracking_number : '');
|
|
returnRequestTracking.innerHTML = '';
|
|
if (trackingOptions.length) {
|
|
trackingOptions.forEach((trackingNo) => {
|
|
const option = document.createElement('option');
|
|
option.value = trackingNo;
|
|
option.textContent = trackingNo;
|
|
returnRequestTracking.appendChild(option);
|
|
});
|
|
returnRequestTracking.disabled = trackingOptions.length <= 1;
|
|
} else {
|
|
const option = document.createElement('option');
|
|
option.value = '';
|
|
option.textContent = '송장번호 없음';
|
|
returnRequestTracking.appendChild(option);
|
|
returnRequestTracking.disabled = true;
|
|
}
|
|
}
|
|
if (returnRequestRemarks) returnRequestRemarks.value = '';
|
|
returnRequestModal.classList.add('show');
|
|
setTimeout(() => {
|
|
if (returnRequestRemarks) returnRequestRemarks.focus();
|
|
}, 0);
|
|
logUiEvent('반품 신청 팝업 열기', { order_id: editingOrderId });
|
|
}
|
|
|
|
async function sendReturnRequest() {
|
|
if (!editingOrderId || !btnSendReturnRequest) return;
|
|
|
|
const originalText = btnSendReturnRequest.innerHTML;
|
|
btnSendReturnRequest.disabled = true;
|
|
btnSendReturnRequest.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 전송 중...';
|
|
|
|
const payload = {
|
|
request_date: returnRequestDate ? returnRequestDate.value : '',
|
|
request_type: returnRequestType ? returnRequestType.value : '단순변심',
|
|
receiver_name: editRecipientName ? editRecipientName.value : '',
|
|
address: editAddress ? editAddress.value : '',
|
|
phone: editRecipientMobile ? editRecipientMobile.value : '',
|
|
tracking_no: returnRequestTracking ? returnRequestTracking.value : '',
|
|
remarks: returnRequestRemarks ? returnRequestRemarks.value : '',
|
|
};
|
|
|
|
logUiEvent('반품 신청 전송 버튼 클릭', {
|
|
order_id: editingOrderId,
|
|
request_date: payload.request_date,
|
|
request_type: payload.request_type,
|
|
has_remarks: !!payload.remarks,
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(appUrl(`/api/orders/${editingOrderId}/return-request`), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showToast(data.message || '반품 신청 데이터가 전송되었습니다.', 'success');
|
|
closeOrderEditModal();
|
|
} else {
|
|
showToast(data.detail || '반품 신청 전송 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('반품 신청 전송 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
btnSendReturnRequest.disabled = false;
|
|
btnSendReturnRequest.innerHTML = originalText;
|
|
}
|
|
}
|
|
|
|
async function saveOrderEdit() {
|
|
if (!editingOrderId) return;
|
|
|
|
const originalText = btnSaveOrderEdit.innerHTML;
|
|
btnSaveOrderEdit.disabled = true;
|
|
btnSaveOrderEdit.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 저장 중...';
|
|
|
|
try {
|
|
const res = await fetch(appUrl(`/api/orders/${editingOrderId}/contact`), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
recipient_name: editRecipientName.value,
|
|
address: editAddress.value,
|
|
recipient_mobile: editRecipientMobile.value,
|
|
customer_note: editCustomerNote ? editCustomerNote.value : '',
|
|
order_note: editOrderNote ? editOrderNote.value : ''
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showToast(data.message || '주문 정보가 수정되었습니다.', 'success');
|
|
closeOrderEditModal();
|
|
await performSearch(currentSearchPage);
|
|
} else {
|
|
showToast(data.detail || '주문 정보 수정 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('주문 정보 수정 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
btnSaveOrderEdit.disabled = false;
|
|
btnSaveOrderEdit.innerHTML = originalText;
|
|
}
|
|
}
|
|
|
|
async function deleteCurrentOrder() {
|
|
if (!editingOrderId || !btnDeleteOrderEdit) return;
|
|
|
|
const deletePassword = prompt('이 주문을 삭제하려면 비밀번호를 입력하세요.');
|
|
if (deletePassword === null) {
|
|
logUiEvent('주문 단건 삭제 취소', { order_id: editingOrderId, step: 'password_prompt' });
|
|
return;
|
|
}
|
|
if (deletePassword !== '1225') {
|
|
logUiEvent('주문 단건 삭제 암호 오류', { order_id: editingOrderId });
|
|
showToast('비밀번호가 올바르지 않습니다.', 'error');
|
|
return;
|
|
}
|
|
if (!confirm('이 주문을 삭제하시겠습니까? 삭제 후 되돌릴 수 없습니다.')) {
|
|
logUiEvent('주문 단건 삭제 취소', { order_id: editingOrderId, step: 'confirm' });
|
|
return;
|
|
}
|
|
|
|
const orderIdToDelete = editingOrderId;
|
|
const originalText = btnDeleteOrderEdit.innerHTML;
|
|
btnDeleteOrderEdit.disabled = true;
|
|
btnDeleteOrderEdit.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 삭제 중...';
|
|
logUiEvent('주문 단건 삭제 요청', { order_id: orderIdToDelete });
|
|
|
|
try {
|
|
const res = await fetch(appUrl(`/api/orders/${orderIdToDelete}/delete`), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: deletePassword })
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showToast(data.message || '주문이 삭제되었습니다.', 'success');
|
|
closeOrderEditModal();
|
|
await performSearch(currentSearchPage);
|
|
} else {
|
|
showToast(data.detail || '주문 삭제 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('주문 삭제 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
btnDeleteOrderEdit.disabled = false;
|
|
btnDeleteOrderEdit.innerHTML = originalText;
|
|
}
|
|
}
|
|
|
|
function renderPagination(currentPage, totalPages) {
|
|
const container = document.getElementById('pagination-container');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
if (totalPages <= 1) return;
|
|
|
|
const pageGroupSize = 50;
|
|
const groupStart = Math.floor((currentPage - 1) / pageGroupSize) * pageGroupSize + 1;
|
|
const groupEnd = Math.min(groupStart + pageGroupSize - 1, totalPages);
|
|
|
|
if (groupStart > 1) {
|
|
const prevBtn = document.createElement('button');
|
|
prevBtn.className = 'btn-page btn-page-nav';
|
|
prevBtn.textContent = '<<이전 페이지';
|
|
prevBtn.addEventListener('click', () => {
|
|
performSearch(groupStart - pageGroupSize);
|
|
});
|
|
container.appendChild(prevBtn);
|
|
}
|
|
|
|
for (let i = groupStart; i <= groupEnd; i++) {
|
|
const btn = document.createElement('button');
|
|
btn.className = `btn-page ${i === currentPage ? 'active' : ''}`;
|
|
btn.textContent = i;
|
|
btn.addEventListener('click', () => {
|
|
performSearch(i);
|
|
});
|
|
container.appendChild(btn);
|
|
}
|
|
|
|
if (groupEnd < totalPages) {
|
|
const nextBtn = document.createElement('button');
|
|
nextBtn.className = 'btn-page btn-page-nav';
|
|
nextBtn.textContent = '다음 페이지>>';
|
|
nextBtn.addEventListener('click', () => {
|
|
performSearch(groupEnd + 1);
|
|
});
|
|
container.appendChild(nextBtn);
|
|
}
|
|
}
|
|
|
|
if (btnDataManagement) {
|
|
btnDataManagement.addEventListener('click', () => {
|
|
dataManagementModal.classList.add('show');
|
|
loadDatabaseSize();
|
|
logUiEvent('데이터 관리 탭 열기');
|
|
});
|
|
}
|
|
|
|
if (btnCloseDataManagement) {
|
|
btnCloseDataManagement.addEventListener('click', () => {
|
|
dataManagementModal.classList.remove('show');
|
|
logUiEvent('데이터 관리 탭 닫기', { close_type: 'button' });
|
|
});
|
|
}
|
|
|
|
if (btnCloseOrderEdit) {
|
|
btnCloseOrderEdit.addEventListener('click', closeOrderEditModal);
|
|
}
|
|
|
|
if (btnCancelOrderEdit) {
|
|
btnCancelOrderEdit.addEventListener('click', closeOrderEditModal);
|
|
}
|
|
|
|
if (btnSaveOrderEdit) {
|
|
btnSaveOrderEdit.addEventListener('click', saveOrderEdit);
|
|
}
|
|
|
|
if (btnDeleteOrderEdit) {
|
|
btnDeleteOrderEdit.addEventListener('click', deleteCurrentOrder);
|
|
}
|
|
|
|
if (btnOpenReturnRequest) {
|
|
btnOpenReturnRequest.addEventListener('click', openReturnRequestModal);
|
|
}
|
|
|
|
if (btnCloseReturnRequest) {
|
|
btnCloseReturnRequest.addEventListener('click', closeReturnRequestModal);
|
|
}
|
|
|
|
if (btnCancelReturnRequest) {
|
|
btnCancelReturnRequest.addEventListener('click', closeReturnRequestModal);
|
|
}
|
|
|
|
if (btnSendReturnRequest) {
|
|
btnSendReturnRequest.addEventListener('click', sendReturnRequest);
|
|
}
|
|
|
|
window.addEventListener('click', (e) => {
|
|
if (e.target === dataManagementModal) {
|
|
dataManagementModal.classList.remove('show');
|
|
logUiEvent('데이터 관리 탭 닫기', { close_type: 'backdrop' });
|
|
}
|
|
if (e.target === orderEditModal) {
|
|
closeOrderEditModal();
|
|
}
|
|
if (e.target === returnRequestModal) {
|
|
closeReturnRequestModal();
|
|
}
|
|
});
|
|
|
|
if (btnOptimizeDb) {
|
|
btnOptimizeDb.addEventListener('click', async () => {
|
|
if (!confirm('데이터베이스 최적화를 시작하시겠습니까?\n이 작업은 데이터 용량에 따라 몇 초에서 수십 초가 소요될 수 있습니다.')) return;
|
|
logUiEvent('데이터베이스 최적화 요청');
|
|
|
|
btnOptimizeDb.disabled = true;
|
|
btnOptimizeDb.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 최적화 중...';
|
|
dbProgressContainer.style.display = 'block';
|
|
dbProgressFill.style.width = '0%';
|
|
dbProgressText.textContent = '0%';
|
|
|
|
let progress = 0;
|
|
// Fake progress that approaches 95%
|
|
const progressInterval = setInterval(() => {
|
|
progress += (95 - progress) * 0.15;
|
|
dbProgressFill.style.width = `${progress}%`;
|
|
dbProgressText.textContent = `${Math.round(progress)}%`;
|
|
}, 500);
|
|
|
|
try {
|
|
const res = await fetch(appUrl('/api/settings/optimize'), {
|
|
method: 'POST'
|
|
});
|
|
const data = await res.json();
|
|
|
|
clearInterval(progressInterval);
|
|
|
|
if (res.ok) {
|
|
dbProgressFill.style.width = '100%';
|
|
dbProgressText.textContent = '100%';
|
|
showToast(data.message || '최적화가 완료되었습니다.', 'success');
|
|
loadDatabaseSize();
|
|
} else {
|
|
showToast(data.detail || '최적화 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
clearInterval(progressInterval);
|
|
showToast('네트워크 오류', 'error');
|
|
} finally {
|
|
btnOptimizeDb.disabled = false;
|
|
btnOptimizeDb.innerHTML = '<i class="fa-solid fa-broom"></i> 데이터베이스 최적화 (용량 줄이기)';
|
|
setTimeout(() => {
|
|
dbProgressContainer.style.display = 'none';
|
|
dbProgressFill.style.width = '0%';
|
|
dbProgressText.textContent = '0%';
|
|
}, 3000);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function downloadFileFromResponse(res, fallbackFilename) {
|
|
let filename = fallbackFilename;
|
|
const disposition = res.headers.get('Content-Disposition');
|
|
if (disposition && disposition.indexOf('attachment') !== -1) {
|
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
|
const plainMatch = disposition.match(/filename="?([^";]+)"?/);
|
|
if (utf8Match && utf8Match[1]) {
|
|
filename = decodeURIComponent(utf8Match[1]);
|
|
} else if (plainMatch && plainMatch[1]) {
|
|
filename = plainMatch[1];
|
|
}
|
|
}
|
|
|
|
const blob = await res.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(() => {
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
}, 1000);
|
|
}
|
|
|
|
async function getErrorMessageFromResponse(res, fallbackMessage) {
|
|
try {
|
|
const contentType = res.headers.get('Content-Type') || '';
|
|
if (contentType.includes('application/json')) {
|
|
const data = await res.json();
|
|
return data.detail || data.message || fallbackMessage;
|
|
}
|
|
const text = await res.text();
|
|
return text || fallbackMessage;
|
|
} catch (error) {
|
|
return fallbackMessage;
|
|
}
|
|
}
|
|
|
|
if (btnBackupDb) {
|
|
btnBackupDb.addEventListener('click', async () => {
|
|
logUiEvent('DB 백업 다운로드 요청');
|
|
const originalText = btnBackupDb.innerHTML;
|
|
btnBackupDb.disabled = true;
|
|
btnBackupDb.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 백업 생성 중...';
|
|
|
|
try {
|
|
const res = await fetch(appUrl('/api/settings/backup-db'));
|
|
if (res.ok) {
|
|
await downloadFileFromResponse(res, 'OMS_DB_backup.db');
|
|
showToast('DB 백업 다운로드가 완료되었습니다.', 'success');
|
|
} else {
|
|
const data = await res.json();
|
|
showToast(data.detail || 'DB 백업 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('DB 백업 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
btnBackupDb.disabled = false;
|
|
btnBackupDb.innerHTML = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (btnDownloadEventLog) {
|
|
btnDownloadEventLog.addEventListener('click', async () => {
|
|
logUiEvent('이벤트 로그 다운로드 요청');
|
|
const originalText = btnDownloadEventLog.innerHTML;
|
|
btnDownloadEventLog.disabled = true;
|
|
btnDownloadEventLog.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 로그 생성 중...';
|
|
|
|
try {
|
|
const res = await fetch(appUrl('/api/settings/download-log'));
|
|
if (res.ok) {
|
|
await downloadFileFromResponse(res, 'OMS_event_log.csv');
|
|
showToast('이벤트 로그 다운로드가 완료되었습니다.', 'success');
|
|
} else {
|
|
const message = await getErrorMessageFromResponse(res, '로그 다운로드 실패');
|
|
showToast(message, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(`로그 다운로드 오류: ${error.message || '응답 처리 실패'}`, 'error');
|
|
} finally {
|
|
btnDownloadEventLog.disabled = false;
|
|
btnDownloadEventLog.innerHTML = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (btnDownloadRepeatPurchase) {
|
|
btnDownloadRepeatPurchase.addEventListener('click', async () => {
|
|
const startDate = repeatStartDate ? repeatStartDate.value : '';
|
|
const endDate = repeatEndDate ? repeatEndDate.value : '';
|
|
const minCount = Number(repeatMinCount ? repeatMinCount.value : 3);
|
|
|
|
if (!startDate || !endDate) {
|
|
showToast('반복 구매 조회 시작일과 종료일을 모두 선택해 주세요.', 'error');
|
|
return;
|
|
}
|
|
if (startDate > endDate) {
|
|
showToast('시작일이 종료일보다 늦을 수 없습니다.', 'error');
|
|
return;
|
|
}
|
|
if (!Number.isInteger(minCount) || minCount < 1) {
|
|
showToast('구매 회수는 1 이상의 숫자로 입력해 주세요.', 'error');
|
|
return;
|
|
}
|
|
logUiEvent('반복 구매 리스트 다운로드 요청', {
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
min_count: minCount
|
|
});
|
|
|
|
const originalText = btnDownloadRepeatPurchase.innerHTML;
|
|
btnDownloadRepeatPurchase.disabled = true;
|
|
btnDownloadRepeatPurchase.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 파일 생성 중...';
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
min_count: String(minCount)
|
|
});
|
|
const res = await fetch(appUrl(`/api/settings/repeat-purchase-export?${params.toString()}`));
|
|
if (res.ok) {
|
|
await downloadFileFromResponse(res, '반복구매.xlsx');
|
|
showToast('반복 구매 리스트 다운로드가 완료되었습니다.', 'success');
|
|
} else {
|
|
const data = await res.json();
|
|
showToast(data.detail || '반복 구매 리스트 다운로드 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('반복 구매 리스트 다운로드 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
btnDownloadRepeatPurchase.disabled = false;
|
|
btnDownloadRepeatPurchase.innerHTML = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (restoreDbFile) {
|
|
restoreDbFile.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
logUiEvent('DB 복구 파일 선택', {
|
|
filename: file.name,
|
|
size: file.size
|
|
});
|
|
|
|
if (!confirm(`백업 파일 "${file.name}"로 DB를 복구하시겠습니까?\n현재 DB는 자동 안전백업 후 교체됩니다.`)) {
|
|
logUiEvent('DB 복구 취소', { filename: file.name, step: 'first_confirm' });
|
|
restoreDbFile.value = '';
|
|
return;
|
|
}
|
|
if (!confirm('복구가 완료되면 현재 검색 결과는 초기화됩니다. 계속하시겠습니까?')) {
|
|
logUiEvent('DB 복구 취소', { filename: file.name, step: 'second_confirm' });
|
|
restoreDbFile.value = '';
|
|
return;
|
|
}
|
|
logUiEvent('DB 복구 요청', { filename: file.name });
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const restoreLabel = document.querySelector('label[for="restore-db-file"]');
|
|
const originalText = restoreLabel ? restoreLabel.innerHTML : '';
|
|
restoreDbFile.disabled = true;
|
|
if (restoreLabel) {
|
|
restoreLabel.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 복구 중...';
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(appUrl('/api/settings/restore-db'), {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showToast(`${data.restored_count}건의 주문 데이터가 복구되었습니다.`, 'success');
|
|
resultsBody.innerHTML = `<tr><td colspan="11" class="empty-state">DB가 복구되었습니다. 다시 검색해주세요.</td></tr>`;
|
|
resultCount.textContent = '0';
|
|
dataManagementModal.classList.remove('show');
|
|
} else {
|
|
showToast(data.detail || 'DB 복구 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('DB 복구 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
restoreDbFile.disabled = false;
|
|
restoreDbFile.value = '';
|
|
if (restoreLabel) {
|
|
restoreLabel.innerHTML = originalText;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function resetDeleteDateProgress() {
|
|
if (!deleteDateProgressContainer) return;
|
|
deleteDateProgressContainer.style.display = 'block';
|
|
deleteDateProgressMessage.textContent = '삭제 준비 중...';
|
|
deleteDateProgressText.textContent = '0%';
|
|
deleteDateProgressFill.style.width = '0%';
|
|
deleteDateProgressCount.textContent = '0 / 0건';
|
|
if (deleteCleanupProgressMessage) deleteCleanupProgressMessage.textContent = '정리 대기';
|
|
if (deleteCleanupProgressText) deleteCleanupProgressText.textContent = '0%';
|
|
if (deleteCleanupProgressFill) deleteCleanupProgressFill.style.width = '0%';
|
|
}
|
|
|
|
function setDeleteDateProgress(data) {
|
|
if (!deleteDateProgressContainer) return;
|
|
const progress = Math.max(0, Math.min(100, Number(data.progress || 0)));
|
|
const cleanupProgress = Math.max(0, Math.min(100, Number(data.cleanup_progress || 0)));
|
|
const totalRows = Number(data.total_rows || 0);
|
|
const deletedRows = Number(data.deleted_rows || 0);
|
|
|
|
deleteDateProgressContainer.style.display = 'block';
|
|
deleteDateProgressMessage.textContent = progress < 100 ? (data.message || '삭제 중입니다...') : '삭제 완료';
|
|
deleteDateProgressText.textContent = `${Math.round(progress)}%`;
|
|
deleteDateProgressFill.style.width = `${progress}%`;
|
|
deleteDateProgressCount.textContent = `${deletedRows.toLocaleString()} / ${totalRows.toLocaleString()}건`;
|
|
if (deleteCleanupProgressMessage) {
|
|
deleteCleanupProgressMessage.textContent = data.cleanup_step || (cleanupProgress > 0 ? data.message : '정리 대기');
|
|
}
|
|
if (deleteCleanupProgressText) {
|
|
deleteCleanupProgressText.textContent = `${Math.round(cleanupProgress)}%`;
|
|
}
|
|
if (deleteCleanupProgressFill) {
|
|
deleteCleanupProgressFill.style.width = `${cleanupProgress}%`;
|
|
}
|
|
}
|
|
|
|
if (btnDeleteDateRangeOrders) {
|
|
btnDeleteDateRangeOrders.addEventListener('click', async () => {
|
|
const startDate = deleteStartDate ? deleteStartDate.value : '';
|
|
const endDate = deleteEndDate ? deleteEndDate.value : '';
|
|
|
|
if (!startDate || !endDate) {
|
|
showToast('삭제할 시작일과 종료일을 모두 선택해 주세요.', 'error');
|
|
return;
|
|
}
|
|
if (startDate > endDate) {
|
|
showToast('시작일이 종료일보다 늦을 수 없습니다.', 'error');
|
|
return;
|
|
}
|
|
|
|
const deletePassword = prompt('지정한 날짜 범위의 주문 데이터를 삭제하려면 비밀번호를 입력하세요.');
|
|
if (deletePassword === null) return;
|
|
if (deletePassword !== '1225') {
|
|
showToast('비밀번호가 올바르지 않습니다.', 'error');
|
|
logUiEvent('날짜 범위 주문 데이터 삭제 비밀번호 오류', {
|
|
start_date: startDate,
|
|
end_date: endDate
|
|
});
|
|
return;
|
|
}
|
|
if (!confirm(`비밀번호가 확인되었습니다.\n${startDate} ~ ${endDate} 주문 데이터를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) {
|
|
logUiEvent('날짜 범위 주문 데이터 삭제 취소', {
|
|
start_date: startDate,
|
|
end_date: endDate
|
|
});
|
|
return;
|
|
}
|
|
logUiEvent('날짜 범위 주문 데이터 삭제 요청', {
|
|
start_date: startDate,
|
|
end_date: endDate
|
|
});
|
|
|
|
const originalText = btnDeleteDateRangeOrders.innerHTML;
|
|
const deleteToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
let deleteEventSource = null;
|
|
|
|
resetDeleteDateProgress();
|
|
btnDeleteDateRangeOrders.disabled = true;
|
|
btnDeleteDateRangeOrders.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 범위 삭제 중...';
|
|
|
|
try {
|
|
deleteEventSource = new EventSource(appUrl(`/api/settings/delete-progress?delete_token=${encodeURIComponent(deleteToken)}`));
|
|
deleteEventSource.onmessage = (event) => {
|
|
try {
|
|
const progressData = JSON.parse(event.data);
|
|
if (progressData.status !== 'idle') {
|
|
setDeleteDateProgress(progressData);
|
|
}
|
|
if (['completed', 'error'].includes(progressData.status)) {
|
|
deleteEventSource.close();
|
|
}
|
|
} catch (err) {
|
|
console.error('Delete progress parse error', err);
|
|
}
|
|
};
|
|
deleteEventSource.onerror = () => {
|
|
if (deleteEventSource) deleteEventSource.close();
|
|
};
|
|
|
|
const res = await fetch(appUrl('/api/settings/delete-orders-by-date-range'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
password: deletePassword,
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
delete_token: deleteToken
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
setDeleteDateProgress({
|
|
status: 'completed',
|
|
message: '삭제가 완료되었습니다.',
|
|
progress: 100,
|
|
cleanup_progress: 100,
|
|
total_rows: data.deleted_count,
|
|
deleted_rows: data.deleted_count,
|
|
cleanup_step: '정리 완료'
|
|
});
|
|
showToast(`${data.deleted_count}건의 주문 데이터가 삭제되었습니다.`, 'success');
|
|
resultsBody.innerHTML = `<tr><td colspan="11" class="empty-state">${startDate} ~ ${endDate} 주문 데이터가 삭제되었습니다. 다시 검색해 주세요.</td></tr>`;
|
|
resultCount.textContent = '0';
|
|
dataManagementModal.classList.remove('show');
|
|
} else {
|
|
showToast(data.detail || '날짜 범위 삭제 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('날짜 범위 삭제 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
if (deleteEventSource) deleteEventSource.close();
|
|
btnDeleteDateRangeOrders.disabled = false;
|
|
btnDeleteDateRangeOrders.innerHTML = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (btnDeleteAllOrders) {
|
|
btnDeleteAllOrders.addEventListener('click', async () => {
|
|
const deletePassword = prompt('전체 주문 데이터를 삭제하려면 비밀번호를 입력하세요.');
|
|
if (deletePassword === null) return;
|
|
if (deletePassword !== '1225') {
|
|
showToast('비밀번호가 올바르지 않습니다.', 'error');
|
|
logUiEvent('전체 주문 데이터 삭제 비밀번호 오류');
|
|
return;
|
|
}
|
|
if (!confirm('비밀번호가 확인되었습니다.\n전체 주문 데이터를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')) {
|
|
logUiEvent('전체 주문 데이터 삭제 취소');
|
|
return;
|
|
}
|
|
logUiEvent('전체 주문 데이터 삭제 요청');
|
|
|
|
const originalText = btnDeleteAllOrders.innerHTML;
|
|
btnDeleteAllOrders.disabled = true;
|
|
btnDeleteAllOrders.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 전체 삭제 중...';
|
|
|
|
try {
|
|
const res = await fetch(appUrl('/api/settings/delete-all-orders'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: deletePassword })
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showToast(`${data.deleted_count}건의 전체 주문 데이터가 삭제되었습니다.`, 'success');
|
|
resultsBody.innerHTML = `<tr><td colspan="11" class="empty-state">전체 주문 데이터가 삭제되었습니다.</td></tr>`;
|
|
resultCount.textContent = '0';
|
|
dataManagementModal.classList.remove('show');
|
|
} else {
|
|
showToast(data.detail || '전체 삭제 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('전체 삭제 중 네트워크 오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
btnDeleteAllOrders.disabled = false;
|
|
btnDeleteAllOrders.innerHTML = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (exportAllDatesChk) {
|
|
exportAllDatesChk.addEventListener('change', (e) => {
|
|
logUiEvent('DB 데이터 엑셀 다운로드 전체 날짜 옵션 변경', {
|
|
checked: e.target.checked
|
|
});
|
|
if (e.target.checked) {
|
|
exportDateRangeContainer.style.opacity = '0.4';
|
|
exportStartDate.disabled = true;
|
|
exportEndDate.disabled = true;
|
|
} else {
|
|
exportDateRangeContainer.style.opacity = '1';
|
|
exportStartDate.disabled = false;
|
|
exportEndDate.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function resetExportProgress() {
|
|
if (!exportProgressContainer) return;
|
|
exportProgressContainer.style.display = 'block';
|
|
exportProgressMessage.textContent = '파일 생성 준비 중...';
|
|
exportProgressText.textContent = '0%';
|
|
exportProgressFill.style.width = '0%';
|
|
exportProgressCount.textContent = '0 / 0건';
|
|
}
|
|
|
|
function setExportProgress(data) {
|
|
if (!exportProgressContainer) return;
|
|
const processedRows = Number(data.processed_rows || 0);
|
|
const totalRows = Number(data.total_rows || 0);
|
|
const progress = Math.max(0, Math.min(100, Number(data.progress || 0)));
|
|
|
|
exportProgressContainer.style.display = 'block';
|
|
exportProgressMessage.textContent = data.message || '파일 생성 중...';
|
|
exportProgressText.textContent = `${Math.round(progress)}%`;
|
|
exportProgressFill.style.width = `${progress}%`;
|
|
exportProgressCount.textContent = `${processedRows.toLocaleString()} / ${totalRows.toLocaleString()}건`;
|
|
}
|
|
|
|
if (btnExportDb) {
|
|
btnExportDb.addEventListener('click', async () => {
|
|
const isAllDates = exportAllDatesChk.checked;
|
|
const startDate = exportStartDate.value;
|
|
const endDate = exportEndDate.value;
|
|
|
|
if (!isAllDates && (!startDate || !endDate)) {
|
|
showToast('시작일과 종료일을 모두 선택해주세요.', 'error');
|
|
return;
|
|
}
|
|
if (!isAllDates && startDate > endDate) {
|
|
showToast('시작일이 종료일보다 클 수 없습니다.', 'error');
|
|
return;
|
|
}
|
|
logUiEvent('DB 데이터 엑셀 다운로드 요청', {
|
|
all_dates: isAllDates,
|
|
start_date: isAllDates ? '' : startDate,
|
|
end_date: isAllDates ? '' : endDate
|
|
});
|
|
|
|
btnExportDb.disabled = true;
|
|
const originalText = btnExportDb.innerHTML;
|
|
btnExportDb.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 파일 생성 및 다운로드 중...';
|
|
resetExportProgress();
|
|
|
|
try {
|
|
// Determine query parameters
|
|
const params = new URLSearchParams();
|
|
if (!isAllDates) {
|
|
params.append('start_date', startDate);
|
|
params.append('end_date', endDate);
|
|
}
|
|
|
|
const startRes = await fetch(appUrl(`/api/settings/export-db/start?${params.toString()}`), {
|
|
method: 'POST'
|
|
});
|
|
if (!startRes.ok) {
|
|
const message = await getErrorMessageFromResponse(startRes, '엑셀 다운로드 작업 시작 실패');
|
|
showToast(message, 'error');
|
|
return;
|
|
}
|
|
|
|
const startData = await startRes.json();
|
|
const exportToken = startData.export_token;
|
|
if (!exportToken) {
|
|
throw new Error('다운로드 작업 번호를 받지 못했습니다.');
|
|
}
|
|
|
|
let statusData = startData;
|
|
for (;;) {
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
const statusRes = await fetch(appUrl(`/api/settings/export-db/status?export_token=${encodeURIComponent(exportToken)}`));
|
|
if (!statusRes.ok) {
|
|
const message = await getErrorMessageFromResponse(statusRes, '엑셀 다운로드 상태 확인 실패');
|
|
showToast(message, 'error');
|
|
return;
|
|
}
|
|
|
|
statusData = await statusRes.json();
|
|
setExportProgress(statusData);
|
|
const processedRows = Number(statusData.processed_rows || 0);
|
|
const processedText = processedRows > 0 ? ` (${processedRows.toLocaleString()}건)` : '';
|
|
btnExportDb.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> ${statusData.message || '파일 생성 중...'}${processedText}`;
|
|
|
|
if (statusData.status === 'completed') {
|
|
break;
|
|
}
|
|
if (statusData.status === 'error') {
|
|
showToast(statusData.message || '엑셀 파일 생성 실패', 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const downloadRes = await fetch(appUrl(`/api/settings/export-db/download?export_token=${encodeURIComponent(exportToken)}`));
|
|
if (downloadRes.ok) {
|
|
await downloadFileFromResponse(downloadRes, 'DB_Export.xlsx');
|
|
setExportProgress({
|
|
...statusData,
|
|
status: 'completed',
|
|
message: '다운로드가 완료되었습니다.',
|
|
progress: 100
|
|
});
|
|
showToast('데이터베이스 다운로드가 완료되었습니다.', 'success');
|
|
} else {
|
|
const message = await getErrorMessageFromResponse(downloadRes, '엑셀 파일 다운로드 실패');
|
|
showToast(message, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
showToast(`엑셀 다운로드 오류: ${error.message || '응답 처리 실패'}`, 'error');
|
|
} finally {
|
|
btnExportDb.disabled = false;
|
|
btnExportDb.innerHTML = originalText;
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Toast Notifications ---
|
|
function showToast(message, type = 'success') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
|
|
let icon = type === 'success' ? '<i class="fa-solid fa-circle-check" style="color:var(--success-color)"></i>' : '<i class="fa-solid fa-circle-exclamation" style="color:#ef4444"></i>';
|
|
|
|
toast.innerHTML = `${icon} <span>${message}</span>`;
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
toast.style.transform = 'translateX(100%)';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
// --- Column Resizing ---
|
|
function initResizers() {
|
|
const table = document.getElementById('results-table');
|
|
const cols = table.querySelectorAll('th');
|
|
[].forEach.call(cols, function (th) {
|
|
const resizer = th.querySelector('.resizer');
|
|
if (!resizer) return;
|
|
|
|
// Make the th position sticky if needed, and relative for its children
|
|
// The CSS already handles position: sticky and position: relative
|
|
|
|
let x = 0;
|
|
let w = 0;
|
|
|
|
const mouseDownHandler = function (e) {
|
|
// Get the current mouse position
|
|
x = e.clientX;
|
|
|
|
// Calculate the current width of column
|
|
const styles = window.getComputedStyle(th);
|
|
w = parseInt(styles.width, 10);
|
|
|
|
// Attach listeners for document's events
|
|
document.addEventListener('mousemove', mouseMoveHandler);
|
|
document.addEventListener('mouseup', mouseUpHandler);
|
|
|
|
resizer.classList.add('resizing');
|
|
};
|
|
|
|
const mouseMoveHandler = function (e) {
|
|
// Determine how far the mouse has been moved
|
|
const dx = e.clientX - x;
|
|
|
|
// Update the width of column
|
|
th.style.width = `${w + dx}px`;
|
|
th.style.minWidth = `${w + dx}px`;
|
|
th.style.maxWidth = `${w + dx}px`;
|
|
};
|
|
|
|
const mouseUpHandler = function () {
|
|
// Remove the handlers of `mousemove` and `mouseup`
|
|
document.removeEventListener('mousemove', mouseMoveHandler);
|
|
document.removeEventListener('mouseup', mouseUpHandler);
|
|
|
|
resizer.classList.remove('resizing');
|
|
};
|
|
|
|
resizer.addEventListener('mousedown', mouseDownHandler);
|
|
});
|
|
}
|
|
|
|
// Initialize resizers once when DOM is loaded
|
|
initResizers();
|
|
});
|