document.addEventListener('DOMContentLoaded', () => { const APP_BASE_PATH = (window.APP_BASE_PATH || '').replace(/\/$/, ''); const appUrl = (path) => `${APP_BASE_PATH}${path.startsWith('/') ? path : `/${path}`}`; // --- Elements --- // loginScreen / user-name 표시는 더 이상 존재하지 않음. // (서버에서 미인증 사용자를 dbx-main 으로 리다이렉트하고, 이름 표시는 제거됨) 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, '''); } 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 (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 files = e.target.files; if (!files || files.length === 0) return; logUiEvent('파일 업로드 파일 선택', { count: files.length, filenames: Array.from(files).map(f => f.name) }); const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const formData = new FormData(); for (let i = 0; i < files.length; i++) { formData.append('files', files[i]); } 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 = ' 검색중'; 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 = ' 검색'; 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 = ' 생성 중'; 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 = `검색 결과가 없습니다.`; 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 = ``; 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 `${escapeHtml(num)}`; }); // Join multiple links with a visual separator, e.g. ' / ' or '
' trackingCell = `${links.join(' / ')}`; } const orderNote = order.order_note || ''; const orderNoteIcon = orderNote ? `📝` : ''; const orderNoLabel = `${orderNoteIcon}${escapeHtml(order.order_no_mall || '')}`; let orderNoCell = `${orderNoLabel}`; 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 = `${orderNoteIcon}${escapeHtml(order.order_no_mall)}`; } 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 = `${orderNoteIcon}${escapeHtml(order.order_no_mall)}`; } const vendorText = order.vendor || ''; const normalizedVendor = vendorText.replace(/\s/g, ''); let vendorCell = `${escapeHtml(vendorText)}`; if (normalizedVendor === '자사몰' || normalizedVendor === '가사몰') { vendorCell = '자사몰'; } else if (normalizedVendor === '스마트스토어') { vendorCell = '스마트스토어'; } 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 `${numericCount}`; }; 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 ? `📝` : ''; tr.innerHTML = ` ${escapeHtml(formattedDate)} ${noteIcon}${escapeHtml(order.recipient_name || '')} ${escapeHtml(pCode)} ${escapeHtml(pName)} ${escapeHtml(order.order_quantity || '')} ${escapeHtml(addressToCopy)}${addressCountBadge} ${escapeHtml(phoneToCopy)}${phoneCountBadge} ${trackingCell} ${vendorCell} ${orderNoCell} ${escapeHtml(order.order_list_1 || '')} `; 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 = ' 전송 중...'; 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 = ' 저장 중...'; 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 = ' 삭제 중...'; 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 = ' 최적화 중...'; 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 = ' 데이터베이스 최적화 (용량 줄이기)'; 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 = ' 백업 생성 중...'; 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 = ' 로그 생성 중...'; 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 = ' 파일 생성 중...'; 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 = ' 복구 중...'; } 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 = `DB가 복구되었습니다. 다시 검색해주세요.`; 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 = ' 범위 삭제 중...'; 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 = `${startDate} ~ ${endDate} 주문 데이터가 삭제되었습니다. 다시 검색해 주세요.`; 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 = ' 전체 삭제 중...'; 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 = `전체 주문 데이터가 삭제되었습니다.`; 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 = ' 파일 생성 및 다운로드 중...'; 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 = ` ${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' ? '' : ''; toast.innerHTML = `${icon} ${message}`; 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(); });