065e81bf97
CS 통합 프로그램.ZIP(로컬 개발본 v9.0)의 기능 소스를 반영. DB 스키마는 기존과 동일해 마이그레이션 없이 그대로 사용한다. 가져온 것 - main.py / cafe24_api.py / templates / static: v9.0 기능 코드 - routers/coupang_milkrun.py, routers/mall_event.py (신규) - static/js/mall_event.js, static/js/milkrun_gsheet.js (신규) 운영 설정은 기존 것을 유지·재적용 - DB/카페24/네이버 접속정보를 하드코딩 대신 환경변수 기반으로 복원 - SSO(AuthGuardMiddleware, SessionMiddleware), /login, /logout, /health/db 복원 - APP_ROOT_PATH 서브경로 호스팅(root_path 템플릿 변수, app.js fetch 래퍼) 복원 - 구글시트 설정 인메모리 캐시(TTL 10분)와 /api/config/refresh 복원 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4223 lines
207 KiB
JavaScript
4223 lines
207 KiB
JavaScript
// ============================================================
|
||
// 서브경로 호스팅 — fetch wrapper
|
||
// 모든 /api/* 호출에 APP_BASE_PATH 를 prepend, 401 응답 시 dbx-main 로그인으로 이동.
|
||
// ============================================================
|
||
(function () {
|
||
const APP_BASE_PATH = (window.APP_BASE_PATH || '').replace(/\/$/, '');
|
||
const origFetch = window.fetch.bind(window);
|
||
window.fetch = async function (input, init) {
|
||
if (typeof input === 'string' && APP_BASE_PATH && input.startsWith('/api/')) {
|
||
input = APP_BASE_PATH + input;
|
||
}
|
||
const response = await origFetch(input, init);
|
||
if (response.status === 401) {
|
||
// 세션 만료 — dbx-main 로그인으로 위임 (next 로 현재 위치 보존)
|
||
const next = encodeURIComponent((APP_BASE_PATH || '') + window.location.pathname + window.location.search);
|
||
window.location.href = (APP_BASE_PATH || '') + '/login?next=' + next;
|
||
}
|
||
return response;
|
||
};
|
||
})();
|
||
|
||
function showToast(message) {
|
||
const toast = document.createElement('div');
|
||
toast.textContent = message;
|
||
toast.style.position = 'fixed';
|
||
toast.style.bottom = '20px';
|
||
toast.style.left = '50%';
|
||
toast.style.transform = 'translateX(-50%)';
|
||
toast.style.background = 'rgba(0, 0, 0, 0.7)';
|
||
toast.style.color = '#fff';
|
||
toast.style.padding = '8px 16px';
|
||
toast.style.borderRadius = '4px';
|
||
toast.style.zIndex = '9999';
|
||
toast.style.fontSize = '0.9rem';
|
||
toast.style.transition = 'opacity 0.3s ease';
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 1500);
|
||
}
|
||
async function copyTextToClipboard(text) {
|
||
if (!text) return false;
|
||
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
} catch (err) {
|
||
console.warn('navigator.clipboard failed, trying fallback:', err);
|
||
}
|
||
}
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.setAttribute('readonly', '');
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.left = '-9999px';
|
||
textarea.style.top = '-9999px';
|
||
document.body.appendChild(textarea);
|
||
textarea.focus();
|
||
textarea.select();
|
||
|
||
try {
|
||
return document.execCommand('copy');
|
||
} catch (err) {
|
||
console.error('Clipboard fallback failed:', err);
|
||
return false;
|
||
} finally {
|
||
textarea.remove();
|
||
}
|
||
}
|
||
|
||
function formatShortDate(dateStr) {
|
||
if (!dateStr) return "";
|
||
const d = new Date(dateStr);
|
||
if (isNaN(d)) return dateStr;
|
||
const days = ["일", "월", "화", "수", "목", "금", "토"];
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
const dow = days[d.getDay()];
|
||
return `${m}월 ${day}일(${dow})`;
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const ORDER_TYPE_LABELS = { noolak: '누락', pason: '파손', bullyang: '불량', misdelivery: '오배송', normal: '수동발주', ellen: '엘렌' };
|
||
const SPECIAL_ORDER_TYPES = new Set(['noolak', 'pason', 'bullyang', 'misdelivery']);
|
||
|
||
// Tab switching logic
|
||
const navLinks = document.querySelectorAll('.nav-links li');
|
||
const tabContents = document.querySelectorAll('.tab-content');
|
||
|
||
function setOrderView(view) {
|
||
const orderTab = document.getElementById('order-tab');
|
||
const smsOptions = document.querySelector('.sms-options');
|
||
const targetSlot = document.getElementById(view === 'new' ? 'new-cs-options-slot' : 'classic-options-slot');
|
||
if (!orderTab || !smsOptions || !targetSlot) return;
|
||
orderTab.classList.toggle('order-view-new', view === 'new');
|
||
orderTab.classList.toggle('order-view-classic', view !== 'new');
|
||
targetSlot.appendChild(smsOptions);
|
||
}
|
||
|
||
navLinks.forEach(link => {
|
||
link.addEventListener('click', () => {
|
||
navLinks.forEach(l => l.classList.remove('active'));
|
||
tabContents.forEach(c => c.classList.remove('active'));
|
||
|
||
link.classList.add('active');
|
||
const target = link.getAttribute('data-tab');
|
||
document.getElementById(target).classList.add('active');
|
||
|
||
if (target === 'order-tab') {
|
||
setOrderView(link.dataset.orderView || 'new');
|
||
const estExtra = document.getElementById('setting-ship-extra');
|
||
const lblObj = document.getElementById('lbl-ship-extra');
|
||
if (estExtra && lblObj) {
|
||
let valStr = estExtra.value.replace(/,/g, '');
|
||
let val = valStr ? parseInt(valStr, 10) : 0;
|
||
if (!isNaN(val)) {
|
||
lblObj.textContent = '추가배송비 ' + val.toLocaleString() + '원';
|
||
}
|
||
}
|
||
} else if (target === 'code-tab') {
|
||
if (typeof window.reRenderSetItemsForWrap === 'function') {
|
||
window.reRenderSetItemsForWrap();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
setOrderView(document.querySelector('.nav-links li.active[data-order-view]')?.dataset.orderView || 'new');
|
||
|
||
// ---- 카페24 자동발주 ----
|
||
(function initCafe24AutoOrder() {
|
||
const authBtn = document.getElementById('cafe24AuthBtn');
|
||
const downloadBtn = document.getElementById('cafe24DownloadBtn');
|
||
const progressWrapper = document.getElementById('cafe24ProgressWrapper');
|
||
const progressText = document.getElementById('cafe24ProgressText');
|
||
const progressPercent = document.getElementById('cafe24ProgressPercent');
|
||
const progressBar = document.getElementById('cafe24ProgressBar');
|
||
const uploadForm = document.getElementById('cafe24UploadForm');
|
||
const uploadBtn = document.getElementById('cafe24UploadBtn');
|
||
const uploadSpinner = document.getElementById('cafe24UploadSpinner');
|
||
const fileDropArea = document.getElementById('cafe24FileDropArea');
|
||
const fileInput = document.getElementById('cafe24InvoiceFile');
|
||
const fileMessage = document.getElementById('cafe24FileMsg');
|
||
const resultBox = document.getElementById('cafe24ResultBox');
|
||
const resultTitle = document.getElementById('cafe24ResultTitle');
|
||
const resultText = document.getElementById('cafe24ResultText');
|
||
|
||
if (!authBtn || !downloadBtn) return;
|
||
|
||
const sleep = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||
|
||
async function responseMessage(response, fallback) {
|
||
try {
|
||
const data = await response.json();
|
||
return data.message || data.detail || data.error || fallback;
|
||
} catch (_) {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function showResult(success, title, message) {
|
||
if (!resultBox || !resultTitle || !resultText) return;
|
||
resultBox.classList.remove('hidden');
|
||
resultBox.style.backgroundColor = success ? 'rgba(47, 133, 90, 0.85)' : 'rgba(197, 48, 48, 0.85)';
|
||
resultBox.style.border = success ? '1px solid #68d391' : '1px solid #fc8181';
|
||
resultTitle.textContent = title;
|
||
resultText.textContent = message;
|
||
}
|
||
|
||
function hideResult() {
|
||
if (resultBox) resultBox.classList.add('hidden');
|
||
}
|
||
|
||
function setAuthState(authenticated, message) {
|
||
authBtn.textContent = authenticated ? '연동됨' : '재연동 필요';
|
||
authBtn.style.backgroundColor = authenticated ? '#2f855a' : '#c05621';
|
||
authBtn.title = message || '';
|
||
}
|
||
|
||
async function refreshAuthState() {
|
||
try {
|
||
const response = await fetch('/api/cafe24/status', { cache: 'no-store' });
|
||
if (!response.ok) throw new Error('status request failed');
|
||
const data = await response.json();
|
||
setAuthState(Boolean(data.authenticated), data.message);
|
||
} catch (_) {
|
||
setAuthState(false, '카페24 인증 상태를 확인할 수 없습니다.');
|
||
}
|
||
}
|
||
|
||
function setDownloadBusy(isBusy) {
|
||
downloadBtn.disabled = isBusy;
|
||
downloadBtn.style.opacity = isBusy ? '0.65' : '1';
|
||
if (progressWrapper) progressWrapper.classList.toggle('hidden', !isBusy);
|
||
if (!isBusy) return;
|
||
if (progressText) progressText.textContent = '주문 조회 준비 중...';
|
||
if (progressPercent) progressPercent.textContent = '0%';
|
||
if (progressBar) progressBar.style.width = '0%';
|
||
}
|
||
|
||
function updateProgress(current, total) {
|
||
const hasTotal = Number(total) > 0;
|
||
const percent = hasTotal ? Math.min(100, Math.round((Number(current) / Number(total)) * 100)) : 0;
|
||
if (progressText) {
|
||
progressText.textContent = hasTotal
|
||
? `주문 수집 중... ${Number(current).toLocaleString()} / ${Number(total).toLocaleString()}건`
|
||
: '배송준비중 주문을 조회하고 있습니다...';
|
||
}
|
||
if (progressPercent) progressPercent.textContent = hasTotal ? `${percent}%` : '조회 중';
|
||
if (progressBar) progressBar.style.width = hasTotal ? `${percent}%` : '12%';
|
||
}
|
||
|
||
function downloadResponseBlob(response, blob) {
|
||
const disposition = response.headers.get('Content-Disposition') || '';
|
||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||
const basicMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||
let filename = '카페24_발주.xlsx';
|
||
try {
|
||
if (utf8Match) filename = decodeURIComponent(utf8Match[1]);
|
||
else if (basicMatch) filename = basicMatch[1];
|
||
} catch (_) {}
|
||
|
||
const url = window.URL.createObjectURL(blob);
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.download = filename;
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
anchor.remove();
|
||
window.URL.revokeObjectURL(url);
|
||
}
|
||
|
||
authBtn.addEventListener('click', () => {
|
||
window.location.href = (window.APP_BASE_PATH || '') + '/api/cafe24/login';
|
||
});
|
||
|
||
downloadBtn.addEventListener('click', async () => {
|
||
hideResult();
|
||
setDownloadBusy(true);
|
||
try {
|
||
const startResponse = await fetch('/api/cafe24/start_download', { method: 'POST' });
|
||
if (!startResponse.ok) {
|
||
throw new Error(await responseMessage(startResponse, '카페24 주문 조회를 시작하지 못했습니다.'));
|
||
}
|
||
const startData = await startResponse.json();
|
||
if (!startData.task_id) throw new Error('카페24 작업 번호를 받지 못했습니다.');
|
||
|
||
while (true) {
|
||
await sleep(700);
|
||
const progressResponse = await fetch(`/api/tasks/progress/${encodeURIComponent(startData.task_id)}`, { cache: 'no-store' });
|
||
if (!progressResponse.ok) {
|
||
const error = new Error(await responseMessage(progressResponse, '카페24 주문 조회에 실패했습니다.'));
|
||
error.isAuthError = progressResponse.status === 401;
|
||
throw error;
|
||
}
|
||
|
||
const progressData = await progressResponse.json();
|
||
updateProgress(progressData.current, progressData.total);
|
||
if (progressData.status !== 'completed') continue;
|
||
|
||
if (progressText) progressText.textContent = '엑셀 파일 생성 완료';
|
||
if (progressPercent) progressPercent.textContent = '100%';
|
||
if (progressBar) progressBar.style.width = '100%';
|
||
|
||
const fileResponse = await fetch(`/api/tasks/download_file/${encodeURIComponent(startData.task_id)}`);
|
||
if (!fileResponse.ok) {
|
||
throw new Error(await responseMessage(fileResponse, '완성된 엑셀 파일을 내려받지 못했습니다.'));
|
||
}
|
||
downloadResponseBlob(fileResponse, await fileResponse.blob());
|
||
showResult(true, '다운로드 완료', '카페24 발주 엑셀 파일을 저장했습니다.');
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
if (error.isAuthError || /인증|연동|authenticate|expired/i.test(error.message || '')) {
|
||
setAuthState(false, error.message);
|
||
}
|
||
showResult(false, '카페24 주문 다운로드 실패', error.message || '처리 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setDownloadBusy(false);
|
||
}
|
||
});
|
||
|
||
function updateSelectedFile() {
|
||
const file = fileInput && fileInput.files ? fileInput.files[0] : null;
|
||
if (fileMessage) {
|
||
fileMessage.textContent = file
|
||
? `선택 파일: ${file.name}`
|
||
: '엑셀 파일을 이곳에 드래그하거나 클릭하여 업로드하세요. (.xls, .xlsx)';
|
||
}
|
||
if (uploadBtn) uploadBtn.disabled = !file;
|
||
}
|
||
|
||
if (fileDropArea && fileInput) {
|
||
fileDropArea.addEventListener('click', event => {
|
||
if (event.target !== fileInput) fileInput.click();
|
||
});
|
||
fileInput.addEventListener('change', updateSelectedFile);
|
||
['dragenter', 'dragover'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, event => {
|
||
event.preventDefault();
|
||
fileDropArea.style.borderColor = '#63b3ed';
|
||
fileDropArea.style.background = 'rgba(66, 153, 225, 0.15)';
|
||
});
|
||
});
|
||
['dragleave', 'drop'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, event => {
|
||
event.preventDefault();
|
||
fileDropArea.style.borderColor = '#718096';
|
||
fileDropArea.style.background = 'rgba(255, 255, 255, 0.05)';
|
||
});
|
||
});
|
||
fileDropArea.addEventListener('drop', event => {
|
||
if (!event.dataTransfer || !event.dataTransfer.files.length) return;
|
||
fileInput.files = event.dataTransfer.files;
|
||
updateSelectedFile();
|
||
});
|
||
}
|
||
|
||
if (uploadForm && fileInput && uploadBtn) {
|
||
uploadForm.addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
if (!fileInput.files || !fileInput.files[0]) return;
|
||
|
||
hideResult();
|
||
uploadBtn.disabled = true;
|
||
uploadBtn.textContent = '송장 등록 중...';
|
||
if (uploadSpinner) uploadSpinner.classList.remove('hidden');
|
||
|
||
try {
|
||
const formData = new FormData(uploadForm);
|
||
const response = await fetch('/api/cafe24/upload_invoices', { method: 'POST', body: formData });
|
||
if (!response.ok) {
|
||
const error = new Error(await responseMessage(response, '송장 등록에 실패했습니다.'));
|
||
error.isAuthError = response.status === 401;
|
||
throw error;
|
||
}
|
||
|
||
const data = await response.json();
|
||
const successCount = Number(data.success_count || 0);
|
||
const failCount = Number(data.fail_count || 0);
|
||
showResult(
|
||
failCount === 0,
|
||
failCount === 0 ? '송장 등록 완료' : '송장 등록 결과',
|
||
`성공 ${successCount.toLocaleString()}건 / 실패 ${failCount.toLocaleString()}건`
|
||
);
|
||
uploadForm.reset();
|
||
updateSelectedFile();
|
||
} catch (error) {
|
||
if (error.isAuthError || /인증|연동|authenticate|expired/i.test(error.message || '')) {
|
||
setAuthState(false, error.message);
|
||
}
|
||
showResult(false, '송장 등록 실패', error.message || '처리 중 오류가 발생했습니다.');
|
||
} finally {
|
||
uploadBtn.textContent = '🚀 송장 일괄 발송처리';
|
||
if (uploadSpinner) uploadSpinner.classList.add('hidden');
|
||
updateSelectedFile();
|
||
}
|
||
});
|
||
}
|
||
|
||
updateSelectedFile();
|
||
refreshAuthState();
|
||
})();
|
||
|
||
// ---- 쿠팡 밀크런 낱개 분해 ----
|
||
(function initCoupangMilkrun() {
|
||
const input = document.getElementById('milkrun-input');
|
||
const analyzeBtn = document.getElementById('btn-milkrun-analyze');
|
||
const clearBtn = document.getElementById('btn-milkrun-clear');
|
||
const downloadBtn = document.getElementById('btn-milkrun-download');
|
||
const linesTbody = document.getElementById('milkrun-lines-tbody');
|
||
const linesInfo = document.getElementById('milkrun-lines-info');
|
||
const totalsTbody = document.getElementById('milkrun-totals-tbody');
|
||
const unresolvedBox = document.getElementById('milkrun-unresolved-box');
|
||
const summaryInfo = document.getElementById('milkrun-summary-info');
|
||
const loadingOverlay = document.getElementById('milkrun-loading-overlay');
|
||
const loadingText = document.getElementById('milkrun-loading-text');
|
||
if (!input || !analyzeBtn || !totalsTbody) return;
|
||
|
||
const LOADING_MESSAGES = [
|
||
{ delay: 0, text: '계산 중...' },
|
||
{ delay: 4000, text: '조금만 더 기다려줘' },
|
||
{ delay: 8000, text: '이제 거의 다 되었어' },
|
||
{ delay: 12000, text: '다 끝나가는 중' }
|
||
];
|
||
let loadingTimers = [];
|
||
|
||
function setMilkrunLoading(isLoading) {
|
||
if (loadingOverlay) loadingOverlay.style.display = isLoading ? 'flex' : 'none';
|
||
analyzeBtn.disabled = isLoading;
|
||
|
||
loadingTimers.forEach(t => clearTimeout(t));
|
||
loadingTimers = [];
|
||
|
||
if (isLoading && loadingText) {
|
||
LOADING_MESSAGES.forEach(m => {
|
||
if (m.delay === 0) {
|
||
loadingText.textContent = m.text;
|
||
} else {
|
||
loadingTimers.push(setTimeout(() => { loadingText.textContent = m.text; }, m.delay));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str == null ? '' : str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function resetMilkrunResult() {
|
||
if (linesTbody) linesTbody.innerHTML = '';
|
||
if (linesInfo) linesInfo.textContent = '';
|
||
totalsTbody.innerHTML = '';
|
||
unresolvedBox.style.display = 'none';
|
||
unresolvedBox.innerHTML = '';
|
||
summaryInfo.textContent = '';
|
||
}
|
||
|
||
function renderLineRow(line) {
|
||
const hasMultiplier = line.multiplier && line.multiplier !== 1;
|
||
const codeCell = hasMultiplier
|
||
? '<div>' + escapeHtml(line.code_raw) + '</div><div style="color:#a0aec0; font-size:0.75rem;">→ ' + escapeHtml(line.base_code) + ' × ' + line.multiplier + '</div>'
|
||
: escapeHtml(line.code_raw);
|
||
const qtyCell = hasMultiplier
|
||
? line.qty.toLocaleString() + ' → <strong>' + line.effective_qty.toLocaleString() + '</strong>'
|
||
: line.qty.toLocaleString();
|
||
|
||
let breakdownCell;
|
||
let rowStyle = '';
|
||
if (line.resolved && line.type === 'set') {
|
||
const badge = '<span style="font-size:0.7rem; color:#c53030; border:1px solid #feb2b2; border-radius:3px; padding:0 4px; margin-right:6px;">세트</span>';
|
||
const breakdown = line.breakdown || [];
|
||
const compositionParts = breakdown.map(function (b) {
|
||
return escapeHtml(b.single_code) + ' × ' + (b.per_unit_qty != null ? b.per_unit_qty.toLocaleString() : '?');
|
||
}).join(', ');
|
||
const resultParts = breakdown.map(function (b) {
|
||
return escapeHtml(b.single_code) + ' × ' + b.qty.toLocaleString();
|
||
}).join(', ');
|
||
breakdownCell =
|
||
'<div><span style="color:#2f855a; margin-right:4px;">✓</span>' + badge +
|
||
'<span style="color:#718096;">구성(1세트): ' + compositionParts + '</span></div>' +
|
||
'<div style="margin-top:2px; color:#c53030; font-weight:600;">→ ' + line.effective_qty.toLocaleString() + '개 → ' + resultParts + '</div>';
|
||
} else if (line.resolved) {
|
||
const badge = '<span style="font-size:0.7rem; color:#3182ce; border:1px solid #bee3f8; border-radius:3px; padding:0 4px; margin-right:6px;">단품</span>';
|
||
const parts = (line.breakdown || []).map(function (b) {
|
||
return escapeHtml(b.single_code) + ' × ' + b.qty.toLocaleString();
|
||
}).join(', ');
|
||
breakdownCell = '<span style="color:#2f855a; margin-right:4px;">✓</span>' + badge +
|
||
'<span style="color:#2b6cb0;">' + parts + '</span>';
|
||
} else {
|
||
rowStyle = 'background:#000000; color:#f6e05e;';
|
||
breakdownCell = '<span style="color:#f6e05e;">⚠ 미등록 코드 — 낱개 합계 계산에서 제외됨</span>';
|
||
}
|
||
|
||
return '<tr style="' + rowStyle + '">' +
|
||
'<td style="padding: 5px 8px; border-top: 1px solid #edf2f7;">' + codeCell + '</td>' +
|
||
'<td style="padding: 5px 8px; border-top: 1px solid #edf2f7; text-align: right; white-space: nowrap;">' + qtyCell + '</td>' +
|
||
'<td style="padding: 5px 8px; border-top: 1px solid #edf2f7;">' + breakdownCell + '</td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
async function runMilkrunAnalysis() {
|
||
const text = input.value;
|
||
if (!text || !text.trim()) {
|
||
resetMilkrunResult();
|
||
return;
|
||
}
|
||
setMilkrunLoading(true);
|
||
try {
|
||
const res = await fetch('/api/coupang-milkrun/analyze', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text })
|
||
});
|
||
const data = await res.json();
|
||
if (data.status !== 'success') {
|
||
alert('분석 중 오류가 발생했습니다.');
|
||
return;
|
||
}
|
||
|
||
if (linesTbody) {
|
||
if (!data.lines.length) {
|
||
linesTbody.innerHTML = '<tr><td colspan="3" style="padding: 16px; text-align: center; color: #a0aec0;">인식된 줄이 없습니다.</td></tr>';
|
||
} else {
|
||
linesTbody.innerHTML = data.lines.map(renderLineRow).join('');
|
||
}
|
||
}
|
||
if (linesInfo) {
|
||
const failCount = data.lines.filter(l => !l.resolved).length;
|
||
linesInfo.textContent = failCount > 0
|
||
? `총 ${data.lines.length}줄 · 미등록 ${failCount}줄`
|
||
: `총 ${data.lines.length}줄 · 전부 인식됨`;
|
||
}
|
||
|
||
if (!data.totals.length) {
|
||
totalsTbody.innerHTML = '<tr><td colspan="4" style="padding: 16px; text-align: center; color: #a0aec0;">인식된 항목이 없습니다. 붙여넣은 형식을 확인해주세요.</td></tr>';
|
||
} else {
|
||
totalsTbody.innerHTML = data.totals.map(t => `
|
||
<tr>
|
||
<td style="padding: 6px 8px; border-top: 1px solid #edf2f7; font-weight: 600; white-space: nowrap;">${t.sabangnet_code || ''}</td>
|
||
<td style="padding: 6px 8px; border-top: 1px solid #edf2f7; white-space: nowrap;">${t.single_code}</td>
|
||
<td style="padding: 6px 8px; border-top: 1px solid #edf2f7; white-space: nowrap;">${t.name || ''}</td>
|
||
<td style="padding: 6px 8px; border-top: 1px solid #edf2f7; text-align: right; font-weight: 600; color: #3182ce; white-space: nowrap;">${t.total_qty.toLocaleString()}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
if (data.unresolved && data.unresolved.length > 0) {
|
||
unresolvedBox.style.display = 'block';
|
||
unresolvedBox.innerHTML = '⚠ 코드표에 등록되지 않은 코드: ' +
|
||
data.unresolved.map(u => `${u.code} (${u.qty.toLocaleString()}개)`).join(', ');
|
||
} else {
|
||
unresolvedBox.style.display = 'none';
|
||
unresolvedBox.innerHTML = '';
|
||
}
|
||
|
||
const totalUnits = data.totals.reduce((sum, t) => sum + t.total_qty, 0);
|
||
summaryInfo.innerHTML = `${data.lines.length}줄 입력<br>낱개 합계 ${totalUnits.toLocaleString()}개`;
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('밀크런 분석 요청 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setMilkrunLoading(false);
|
||
}
|
||
}
|
||
|
||
analyzeBtn.addEventListener('click', runMilkrunAnalysis);
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', () => {
|
||
input.value = '';
|
||
resetMilkrunResult();
|
||
});
|
||
}
|
||
input.addEventListener('paste', () => {
|
||
setTimeout(runMilkrunAnalysis, 50);
|
||
});
|
||
|
||
if (downloadBtn) {
|
||
downloadBtn.addEventListener('click', async () => {
|
||
const text = input.value;
|
||
if (!text || !text.trim()) {
|
||
alert('먼저 밀크런 내용을 붙여넣고 분석해주세요.');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/coupang-milkrun/download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text })
|
||
});
|
||
if (!res.ok) {
|
||
alert('엑셀 다운로드 중 오류가 발생했습니다.');
|
||
return;
|
||
}
|
||
const blob = await res.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = '쿠팡_밀크런_낱개코드.xlsx';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
|
||
// Auto format phone number
|
||
document.getElementById('cust-phone').addEventListener('input', function (e) {
|
||
let val = this.value.replace(/[^0-9]/g, '');
|
||
if (val.length > 0 && val[0] !== '0') {
|
||
val = '010' + val;
|
||
}
|
||
let res = '';
|
||
if (val.startsWith('02')) {
|
||
if (val.length < 3) res = val;
|
||
else if (val.length < 6) res = val.replace(/(\d{2})(\d+)/, '$1-$2');
|
||
else if (val.length < 10) res = val.replace(/(\d{2})(\d{3})(\d+)/, '$1-$2-$3');
|
||
else res = val.replace(/(\d{2})(\d{4})(\d{4})/, '$1-$2-$3');
|
||
} else {
|
||
if (val.length < 4) res = val;
|
||
else if (val.length < 7) res = val.replace(/(\d{3})(\d+)/, '$1-$2');
|
||
else if (val.length < 11) res = val.replace(/(\d{3})(\d{3})(\d+)/, '$1-$2-$3');
|
||
else res = val.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
|
||
}
|
||
this.value = res;
|
||
});
|
||
|
||
function parseReshipmentInfo(text) {
|
||
const lines = text
|
||
.replace(/\r\n?/g, '\n')
|
||
.split('\n')
|
||
.map(line => line.trim())
|
||
.filter(Boolean);
|
||
const headerIndex = lines.indexOf('[재발송 정보]');
|
||
|
||
if (headerIndex === -1) return null;
|
||
|
||
const fields = {};
|
||
lines.slice(headerIndex + 1).forEach(line => {
|
||
const match = line.match(/^(이름|주소|휴대폰번호)\s*[::]\s*(.+)$/);
|
||
if (match) fields[match[1]] = match[2].trim();
|
||
});
|
||
|
||
if (!fields['이름'] || !fields['주소'] || !fields['휴대폰번호']) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
name: fields['이름'],
|
||
phone: fields['휴대폰번호'],
|
||
address: fields['주소']
|
||
};
|
||
}
|
||
|
||
async function handleClipboardPaste(targetField) {
|
||
try {
|
||
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
||
alert("현재 환경에서는 클립보드 연동 보안 정책으로 인해 자동 입력이 지원되지 않습니다.\\nHTTPS 상태일 때 동작합니다.\\n직접 붙여넣기(Ctrl+V)를 사용해주세요.");
|
||
return;
|
||
}
|
||
const text = await navigator.clipboard.readText();
|
||
const trimmed = text.trim();
|
||
const digits = text.replace(/[^0-9]/g, '');
|
||
const hasPhone = digits.length >= 9 && digits.length <= 12;
|
||
|
||
if (targetField === 'name') {
|
||
const reshipmentInfo = parseReshipmentInfo(text);
|
||
if (reshipmentInfo) {
|
||
const nameInput = document.getElementById('cust-name');
|
||
const phoneInput = document.getElementById('cust-phone');
|
||
const addressInput = document.getElementById('cust-address');
|
||
|
||
nameInput.value = reshipmentInfo.name;
|
||
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
phoneInput.value = reshipmentInfo.phone;
|
||
phoneInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
addressInput.value = reshipmentInfo.address;
|
||
addressInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
showToast('재발송 정보를 자동 입력했습니다.');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 이름은 한글 2~5글자를 인식하도록 완화
|
||
const nameMatch = trimmed.match(/^[가-힣]{2,5}/);
|
||
|
||
if (targetField === 'phone') {
|
||
const phoneInput = document.getElementById('cust-phone');
|
||
if (hasPhone) {
|
||
phoneInput.value = digits;
|
||
phoneInput.dispatchEvent(new Event('input'));
|
||
}
|
||
} else if (targetField === 'name') {
|
||
const nameInput = document.getElementById('cust-name');
|
||
if (nameMatch) {
|
||
nameInput.value = nameMatch[0];
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Clipboard read error:", err);
|
||
alert("클립보드 접근 권한이 없거나 읽어오지 못했습니다. 브라우저 주소창의 권한을 확인해주세요.");
|
||
}
|
||
}
|
||
|
||
document.getElementById('cust-phone').addEventListener('click', function (e) {
|
||
if (!this.value) handleClipboardPaste('phone');
|
||
});
|
||
|
||
document.getElementById('cust-name').addEventListener('click', function (e) {
|
||
if (!this.value) handleClipboardPaste('name');
|
||
});
|
||
|
||
const pointInput = document.getElementById('sms-point-deduction');
|
||
if (pointInput) {
|
||
pointInput.addEventListener('input', function(e) {
|
||
let val = this.value.replace(/[^0-9]/g, '');
|
||
this.value = val ? parseInt(val, 10).toLocaleString() : '';
|
||
});
|
||
}
|
||
|
||
// Global Data
|
||
let configData = {};
|
||
|
||
// Fetch configuration
|
||
fetch('/api/config')
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
configData = data;
|
||
|
||
if (data.APP_SETTINGS && data.APP_SETTINGS.font) {
|
||
document.body.style.fontFamily = `"${data.APP_SETTINGS.font}", 'Inter', 'Malgun Gothic', sans-serif`;
|
||
} else {
|
||
document.body.style.fontFamily = '';
|
||
}
|
||
|
||
buildOrderTab(data);
|
||
buildGiftTab(data);
|
||
if (typeof buildProductTab === 'function') {
|
||
buildProductTab(data);
|
||
}
|
||
loadSmsHistory();
|
||
if (typeof loadManualHistory === 'function') {
|
||
loadManualHistory();
|
||
}
|
||
})
|
||
.catch(err => console.error("Error loading config:", err));
|
||
|
||
|
||
|
||
// Fetch local fonts using hybrid approach (Browser API first, Backend API fallback)
|
||
async function initLocalFonts() {
|
||
const fontSelect = document.getElementById('app-font-select');
|
||
if (!fontSelect) return;
|
||
|
||
let fontNames = [];
|
||
|
||
try {
|
||
// Attempt to fetch via native browser API (works on HTTPS/localhost for all PC fonts)
|
||
if ('queryLocalFonts' in window && window.isSecureContext) {
|
||
const localFonts = await window.queryLocalFonts();
|
||
fontNames = [...new Set(localFonts.map(f => f.family))].sort();
|
||
} else {
|
||
throw new Error("Local font API not available or context not secure");
|
||
}
|
||
} catch (e) {
|
||
console.warn("내 컴퓨터 폰트 직접 읽기 실패, 서버 폰트로 대체:", e);
|
||
try {
|
||
// Fallback to server registry (works locally without HTTPS but fails to get windows fonts on Linux cloud)
|
||
const res = await fetch('/api/fonts');
|
||
fontNames = await res.json();
|
||
} catch (err) {
|
||
console.error("서버 폰트 불러오기 실패:", err);
|
||
}
|
||
}
|
||
|
||
fontSelect.innerHTML = '<option value="">기본 폰트 (선택 없음)</option>';
|
||
|
||
if (fontNames && fontNames.length > 0) {
|
||
// 명시적으로 호스팅된 폰트 추가
|
||
const customFonts = ['NanumSquareNeo', 'Pretendard-Regular'];
|
||
customFonts.forEach(f => {
|
||
if (!fontNames.includes(f)) fontNames.unshift(f);
|
||
});
|
||
// 중복 제거 및 정렬
|
||
fontNames = [...new Set(fontNames)].sort((a, b) => {
|
||
const aIsCustom = customFonts.includes(a);
|
||
const bIsCustom = customFonts.includes(b);
|
||
if (aIsCustom && bIsCustom) return customFonts.indexOf(a) - customFonts.indexOf(b);
|
||
if (aIsCustom) return -1;
|
||
if (bIsCustom) return 1;
|
||
return a.localeCompare(b);
|
||
});
|
||
|
||
fontNames.forEach(f => {
|
||
const opt = document.createElement('option');
|
||
opt.value = f;
|
||
|
||
// Truncate display text to prevent native dropdowns from becoming excessively wide
|
||
let displayText = f;
|
||
if (displayText.length > 35) {
|
||
displayText = displayText.substring(0, 35) + '...';
|
||
}
|
||
opt.textContent = displayText;
|
||
|
||
// Set the font family so users can preview the font in the dropdown
|
||
opt.style.fontFamily = `"${f}", sans-serif`;
|
||
fontSelect.appendChild(opt);
|
||
});
|
||
|
||
if (configData.APP_SETTINGS && configData.APP_SETTINGS.font) {
|
||
fontSelect.value = configData.APP_SETTINGS.font;
|
||
}
|
||
} else {
|
||
fontSelect.innerHTML = '<option value="">폰트를 불러오지 못했습니다</option>';
|
||
}
|
||
}
|
||
|
||
const btnFontDefault = document.getElementById('btn-font-default');
|
||
if (btnFontDefault) {
|
||
btnFontDefault.addEventListener('click', () => {
|
||
const fontSelect = document.getElementById('app-font-select');
|
||
if (fontSelect) fontSelect.value = '';
|
||
document.getElementById('btn-gift-save').style.backgroundColor = '#e53e3e';
|
||
});
|
||
}
|
||
|
||
// Initialize fonts
|
||
initLocalFonts();
|
||
|
||
// ============================================
|
||
// BUILD ORDER TAB
|
||
// ============================================
|
||
|
||
const orderItemsContainer = document.getElementById('order-items-container');
|
||
const orderGroupButtons = document.getElementById('order-group-buttons');
|
||
const orderSelectedItems = document.getElementById('order-selected-items');
|
||
const orderSelectedCount = document.getElementById('order-selected-count');
|
||
const orderSummaryOrderAmount = document.getElementById('order-summary-order-amount');
|
||
const orderSummaryDepositAmount = document.getElementById('order-summary-deposit-amount');
|
||
const orderSummaryAdjustments = document.getElementById('order-summary-adjustments');
|
||
|
||
function showOrderItemGroup(card, button) {
|
||
if (!orderItemsContainer || !card) return;
|
||
orderItemsContainer.querySelectorAll('.order-item-group.is-active').forEach(item => {
|
||
item.classList.remove('is-active');
|
||
});
|
||
orderGroupButtons.querySelectorAll('.order-group-button.is-active').forEach(item => {
|
||
item.classList.remove('is-active');
|
||
});
|
||
const emptyGuide = orderItemsContainer.querySelector('.order-items-inline-empty');
|
||
if (emptyGuide) emptyGuide.style.display = 'none';
|
||
card.classList.add('is-active');
|
||
if (button) button.classList.add('is-active');
|
||
}
|
||
|
||
function getOrderRowQuantity(row) {
|
||
const qtyControl = row ? row.querySelector('.qty-order') : null;
|
||
return Math.max(1, parseInt(qtyControl?.value, 10) || 1);
|
||
}
|
||
|
||
function updateOrderGroupBadges() {
|
||
if (!orderGroupButtons || !orderItemsContainer) return;
|
||
orderGroupButtons.querySelectorAll('.order-group-button').forEach(button => {
|
||
const card = Array.from(orderItemsContainer.querySelectorAll('.order-item-group'))
|
||
.find(item => item.dataset.groupKey === button.dataset.groupKey);
|
||
const selectedCount = card ? card.querySelectorAll('.chk-order:checked').length : 0;
|
||
button.classList.toggle('has-selection', selectedCount > 0);
|
||
const badge = button.querySelector('.order-group-count');
|
||
if (badge) badge.textContent = selectedCount;
|
||
});
|
||
}
|
||
|
||
function getSelectedOrderItemTotal(checkbox, quantity) {
|
||
const cost = parseInt(checkbox.dataset.cost, 10) || 0;
|
||
const dcost = checkbox.dataset.dcost === 'null' ? null : (parseInt(checkbox.dataset.dcost, 10) || null);
|
||
const orderType = document.querySelector('input[name="order-type"]:checked')?.value;
|
||
const noLidChecked = document.getElementById('chk-no-lid')?.checked;
|
||
if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) return 0;
|
||
if (dcost !== null && quantity >= 3) {
|
||
return Math.floor(quantity / 3) * dcost * 2 + (quantity % 3) * cost;
|
||
}
|
||
return cost * quantity;
|
||
}
|
||
|
||
function updateSelectedOrderItemsSummary() {
|
||
if (!orderItemsContainer || !orderSelectedItems || !orderSelectedCount) return;
|
||
const selected = new Map();
|
||
orderItemsContainer.querySelectorAll('.chk-order:checked').forEach(checkbox => {
|
||
const row = checkbox.closest('.item-row');
|
||
const label = checkbox.dataset.label || checkbox.dataset.name || '상품';
|
||
const quantity = getOrderRowQuantity(row);
|
||
selected.set(checkbox, {
|
||
label,
|
||
quantity,
|
||
amount: getSelectedOrderItemTotal(checkbox, quantity),
|
||
checkbox
|
||
});
|
||
});
|
||
|
||
orderSelectedItems.innerHTML = '';
|
||
if (selected.size === 0) {
|
||
const empty = document.createElement('p');
|
||
empty.className = 'order-selected-empty';
|
||
empty.textContent = '선택된 상품이 없습니다.';
|
||
orderSelectedItems.appendChild(empty);
|
||
} else {
|
||
selected.forEach(item => {
|
||
const row = document.createElement('div');
|
||
row.className = 'order-selected-row';
|
||
const remove = document.createElement('button');
|
||
remove.type = 'button';
|
||
remove.className = 'order-selected-remove';
|
||
remove.setAttribute('aria-label', `${item.label} 삭제`);
|
||
remove.title = '선택 상품 삭제';
|
||
remove.textContent = '×';
|
||
const name = document.createElement('span');
|
||
name.className = 'order-selected-name';
|
||
name.title = item.label;
|
||
name.textContent = item.label;
|
||
const quantity = document.createElement('select');
|
||
quantity.className = 'order-selected-qty';
|
||
const qtyValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30, 40, 50];
|
||
if (!qtyValues.includes(item.quantity)) qtyValues.push(item.quantity);
|
||
qtyValues.sort((a, b) => a - b).forEach(value => {
|
||
const option = document.createElement('option');
|
||
option.value = value;
|
||
option.textContent = `${value}개`;
|
||
option.selected = value === item.quantity;
|
||
quantity.appendChild(option);
|
||
});
|
||
const price = document.createElement('span');
|
||
price.className = 'order-selected-price';
|
||
price.textContent = `${item.amount.toLocaleString()}원`;
|
||
remove.addEventListener('click', () => {
|
||
item.checkbox.checked = false;
|
||
const sourceRow = item.checkbox.closest('.item-row');
|
||
if (sourceRow) sourceRow.style.backgroundColor = '';
|
||
updateOrderNoLidState();
|
||
updateSelectedOrderItemsSummary();
|
||
});
|
||
quantity.addEventListener('change', () => {
|
||
const sourceRow = item.checkbox.closest('.item-row');
|
||
const sourceQty = sourceRow?.querySelector('.qty-order');
|
||
if (!sourceQty) return;
|
||
const nextValue = quantity.value;
|
||
if (sourceQty.tagName === 'SELECT' && !Array.from(sourceQty.options).some(option => option.value === nextValue)) {
|
||
const option = document.createElement('option');
|
||
option.value = nextValue;
|
||
option.textContent = nextValue;
|
||
sourceQty.appendChild(option);
|
||
}
|
||
sourceQty.value = nextValue;
|
||
updateSelectedOrderItemsSummary();
|
||
});
|
||
row.append(remove, name, quantity, price);
|
||
orderSelectedItems.appendChild(row);
|
||
});
|
||
}
|
||
orderSelectedCount.textContent = `${selected.size}종`;
|
||
const payload = getOrderPayload();
|
||
const displayOrderAmount = selected.size > 0 ? payload.raw_total : 0;
|
||
const displayDepositAmount = selected.size > 0 ? payload.total_amount : 0;
|
||
if (orderSummaryOrderAmount) orderSummaryOrderAmount.textContent = `${displayOrderAmount.toLocaleString()}원`;
|
||
if (orderSummaryDepositAmount) orderSummaryDepositAmount.textContent = `${displayDepositAmount.toLocaleString()}원`;
|
||
if (orderSummaryAdjustments) {
|
||
orderSummaryAdjustments.innerHTML = '';
|
||
const detailRows = [];
|
||
if (selected.size > 0) {
|
||
if (payload.discount_5_amount > 0) detailRows.push(['5% 할인 금액', -payload.discount_5_amount]);
|
||
if (payload.coupon_amount > 0) detailRows.push(['쿠폰 할인 금액', -payload.coupon_amount]);
|
||
if (payload.point_deduction > 0) detailRows.push(['적립금 차감', -payload.point_deduction]);
|
||
if (payload.shipping_cost > 0) detailRows.push(['택배비', payload.shipping_cost]);
|
||
if (document.getElementById('sms-ship-free')?.checked || payload.free_reason) {
|
||
detailRows.push(['배송비', payload.shipping_cost > 0 ? payload.shipping_cost : '무료배송']);
|
||
}
|
||
if (payload.extra_shipping_cost > 0) detailRows.push(['추가배송비', payload.extra_shipping_cost]);
|
||
if (document.getElementById('sms-june-promo')?.checked) detailRows.push(['금액별 추가상품', '적용']);
|
||
}
|
||
detailRows.forEach(([label, value]) => {
|
||
const detail = document.createElement('div');
|
||
const valueText = typeof value === 'number'
|
||
? `${value < 0 ? '-' : ''}${Math.abs(value).toLocaleString()}원`
|
||
: value;
|
||
detail.innerHTML = `<span>${label}</span><b>${valueText}</b>`;
|
||
orderSummaryAdjustments.appendChild(detail);
|
||
});
|
||
}
|
||
updateOrderGroupBadges();
|
||
refreshLiveSmsPreview();
|
||
}
|
||
|
||
orderItemsContainer.addEventListener('change', function(e) {
|
||
if (e.target.classList.contains('qty-order') && e.target.tagName === 'SELECT' && e.target.value === 'manual') {
|
||
const input = document.createElement('input');
|
||
input.type = 'number';
|
||
input.min = '1';
|
||
input.value = '1';
|
||
input.className = 'qty-order';
|
||
|
||
e.target.replaceWith(input);
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
updateSelectedOrderItemsSummary();
|
||
});
|
||
|
||
orderItemsContainer.addEventListener('input', function(e) {
|
||
if (e.target.classList.contains('qty-order')) updateSelectedOrderItemsSummary();
|
||
});
|
||
|
||
document.getElementById('order-items-container').addEventListener('blur', function(e) {
|
||
if (e.target.classList.contains('qty-order') && e.target.tagName === 'INPUT') {
|
||
if (!e.target.value) {
|
||
const select = document.createElement('select');
|
||
select.className = 'qty-order';
|
||
const qtyOpts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30, 40, 50];
|
||
const optHtml = qtyOpts.map(q => `<option value="${q}">${q}</option>`).join('');
|
||
const manualOpt = `<option value="manual" style="background-color: black; color: white;">직접입력</option>`;
|
||
select.innerHTML = manualOpt + optHtml;
|
||
select.value = '1';
|
||
e.target.replaceWith(select);
|
||
} else if (parseInt(e.target.value) < 1) {
|
||
e.target.value = '1';
|
||
}
|
||
updateSelectedOrderItemsSummary();
|
||
}
|
||
}, true);
|
||
function buildOrderTab(data) {
|
||
const container = document.getElementById('order-items-container');
|
||
container.innerHTML = '';
|
||
if (orderGroupButtons) orderGroupButtons.innerHTML = '';
|
||
const emptyGuide = document.createElement('div');
|
||
emptyGuide.className = 'order-items-inline-empty';
|
||
emptyGuide.innerHTML = '<span>📦</span><strong>위의 상품 분류를 선택하세요.</strong>';
|
||
container.appendChild(emptyGuide);
|
||
let sections = [];
|
||
if (data.GROUP_NAMES) {
|
||
|
||
const order_gn = data.GROUP_NAMES.__order || Object.keys(data.GROUP_NAMES).filter(k => k !== '__order');
|
||
for (const key of order_gn) {
|
||
if (key === '__order') continue;
|
||
const title = data.GROUP_NAMES[key];
|
||
|
||
sections.push({
|
||
key: key, title: title
|
||
|
||
});
|
||
}
|
||
} else {
|
||
// Fallback for older config files without GROUP_NAMES
|
||
sections = [
|
||
{ key: 'ORDER_SINGLE_1', title: '단품 아이템 (1)' },
|
||
{ key: 'ORDER_SINGLE_2', title: '단품 아이템 (2)' },
|
||
{ key: 'ORDER_SET_1', title: '세트 아이템 (1)' },
|
||
{ key: 'ORDER_SET_2', title: '세트 아이템 (2)' },
|
||
{ key: 'ORDER_EVENT_1', title: '이벤트 (1)' },
|
||
{ key: 'ORDER_EVENT_2', title: '이벤트 (2)' },
|
||
{ key: 'ORDER_GIFT', title: '사은품' }
|
||
];
|
||
}
|
||
|
||
|
||
sections.forEach(sec => {
|
||
if (!data[sec.key]) return;
|
||
const card = document.createElement('div');
|
||
card.className = 'glass-card item-group-card order-item-group';
|
||
card.dataset.groupKey = sec.key;
|
||
|
||
const popupHeader = document.createElement('div');
|
||
popupHeader.className = 'order-popup-header';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = sec.title;
|
||
popupHeader.appendChild(title);
|
||
card.appendChild(popupHeader);
|
||
|
||
if (orderGroupButtons) {
|
||
const groupButton = document.createElement('button');
|
||
groupButton.type = 'button';
|
||
groupButton.className = 'order-group-button';
|
||
groupButton.dataset.groupKey = sec.key;
|
||
const buttonTitle = document.createElement('span');
|
||
buttonTitle.textContent = sec.title;
|
||
const buttonCount = document.createElement('span');
|
||
buttonCount.className = 'order-group-count';
|
||
buttonCount.textContent = '0';
|
||
groupButton.append(buttonTitle, buttonCount);
|
||
groupButton.addEventListener('click', () => showOrderItemGroup(card, groupButton));
|
||
orderGroupButtons.appendChild(groupButton);
|
||
}
|
||
|
||
const grid = document.createElement('div');
|
||
grid.className = 'item-list';
|
||
|
||
const order_1 = data[sec.key]?.__order || Object.keys(data[sec.key]).filter(k => k !== '__order');
|
||
order_1.forEach(rawLabel => {
|
||
if (rawLabel === '__order') return;
|
||
const value = data[sec.key][rawLabel];
|
||
let label = rawLabel.trim();
|
||
|
||
if (label.startsWith('---')) {
|
||
const hr = document.createElement('hr');
|
||
hr.style.gridColumn = "1 / -1";
|
||
hr.style.border = "none";
|
||
hr.style.borderTop = "1.5px dashed black";
|
||
hr.style.margin = "8px 0";
|
||
grid.appendChild(hr);
|
||
return;
|
||
}
|
||
|
||
let colorStyle = '';
|
||
const match = label.match(/_([ORBGP])$/i);
|
||
if (match) {
|
||
label = label.slice(0, -2).trim();
|
||
const colorMap = { 'O': '#ed8936', 'R': '#e53e3e', 'B': '#3182ce', 'G': '#1D6F42', 'P': '#d53f8c' };
|
||
colorStyle = `color: ${colorMap[match[1].toUpperCase()]}; font-weight: 700;`;
|
||
}
|
||
|
||
const safeValue = value || "";
|
||
const parts = safeValue.split('|').map(s => s.trim());
|
||
let code = parts[0] || "NONE";
|
||
|
||
let multiplier = 1;
|
||
const matchCode = code.match(/_(\d+)$/);
|
||
if (matchCode) {
|
||
multiplier = parseInt(matchCode[1], 10) || 1;
|
||
code = code.replace(/_\d+$/, '');
|
||
}
|
||
|
||
const isFree = label.startsWith('*');
|
||
if (isFree) {
|
||
label = label.replace(/^\*/, '').trim();
|
||
}
|
||
|
||
let name = label;
|
||
if (parts.length > 1 && parts[1] !== "") {
|
||
name = parts[1];
|
||
}
|
||
|
||
let cost = 0;
|
||
let dcost = "null";
|
||
if (parts.length > 2 && parts[2] !== "") {
|
||
cost = parseInt(parts[2].replace(/,/g, '')) || 0;
|
||
dcost = parts.length > 3 && parts[3] !== "" ? parseInt(parts[3].replace(/,/g, '')) : "null";
|
||
}
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'item-row';
|
||
|
||
let itemType = sec.key;
|
||
|
||
const qtyOpts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30, 40, 50];
|
||
const optHtml = qtyOpts.map(q => `<option value="${q}"${q === 1 ? ' selected' : ''}>${q}</option>`).join('');
|
||
const manualOpt = `<option value="manual" style="background-color: black; color: white;">직접입력</option>`;
|
||
|
||
row.innerHTML = `
|
||
<label style="${colorStyle}" title="${label}">
|
||
<input type="checkbox" class="chk-order" data-code="${code}" data-name="${name}" data-label="${label}" data-type="${itemType}" data-cost="${cost}" data-dcost="${dcost}" data-is-free="${isFree}" data-multiplier="${multiplier}">
|
||
${label}
|
||
</label>
|
||
<select class="qty-order">${manualOpt}${optHtml}</select>
|
||
`;
|
||
grid.appendChild(row);
|
||
|
||
row.querySelector('input[type="checkbox"]').addEventListener('change', updateOrderNoLidState);
|
||
|
||
|
||
});
|
||
|
||
const subgroupColumns = [];
|
||
let subgroupColumn = document.createElement('div');
|
||
subgroupColumn.className = 'order-item-subgroup';
|
||
Array.from(grid.children).forEach(child => {
|
||
if (child.tagName === 'HR') {
|
||
if (subgroupColumn.children.length > 0) subgroupColumns.push(subgroupColumn);
|
||
subgroupColumn = document.createElement('div');
|
||
subgroupColumn.className = 'order-item-subgroup';
|
||
return;
|
||
}
|
||
subgroupColumn.appendChild(child);
|
||
});
|
||
if (subgroupColumn.children.length > 0) subgroupColumns.push(subgroupColumn);
|
||
subgroupColumns.forEach((column, index) => {
|
||
const labels = Array.from(column.querySelectorAll('.chk-order'))
|
||
.map(checkbox => (checkbox.dataset.label || '').trim())
|
||
.filter(Boolean);
|
||
const families = labels.map(label => label
|
||
.replace(/^\*/, '')
|
||
.replace(/_\d+개$/, '')
|
||
.replace(/\([^)]*\)/g, '')
|
||
.replace(/\d[\d,.]*\s*(?:ml|mL|ML|ℓ|L|l|개|매|종)?/g, '')
|
||
.replace(/[_+,/-]+/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
).filter(Boolean);
|
||
const wordLists = families.map(family => family.split(' '));
|
||
const commonWords = [];
|
||
if (wordLists.length > 0) {
|
||
const shortestLength = Math.min(...wordLists.map(words => words.length));
|
||
for (let wordIndex = 0; wordIndex < shortestLength; wordIndex += 1) {
|
||
const candidate = wordLists[0][wordIndex];
|
||
if (!wordLists.every(words => words[wordIndex] === candidate)) break;
|
||
commonWords.push(candidate);
|
||
}
|
||
}
|
||
const firstFamily = families[0] || `그룹 ${index + 1}`;
|
||
const automaticTitle = commonWords.length > 0
|
||
? commonWords.join(' ')
|
||
: `${firstFamily}${labels.length > 1 ? ` 외 ${labels.length - 1}종` : ''}`;
|
||
const subgroupSettingKey = `${sec.key}__${index}`;
|
||
const savedTitle = (data.SUBGROUP_NAMES?.[subgroupSettingKey] || '').trim();
|
||
const subgroupTitle = document.createElement('div');
|
||
subgroupTitle.className = 'order-item-subgroup-title';
|
||
const renderSubgroupTitle = titleText => {
|
||
subgroupTitle.replaceChildren();
|
||
subgroupTitle.title = titleText;
|
||
const titleLabel = document.createElement('span');
|
||
titleLabel.className = 'order-item-subgroup-title-text';
|
||
titleLabel.textContent = titleText;
|
||
const editButton = document.createElement('button');
|
||
editButton.type = 'button';
|
||
editButton.className = 'order-item-subgroup-edit';
|
||
editButton.textContent = '✏️';
|
||
editButton.title = '그룹 이름 수정';
|
||
editButton.setAttribute('aria-label', `${titleText} 그룹 이름 수정`);
|
||
editButton.addEventListener('click', event => {
|
||
event.stopPropagation();
|
||
const previousTitle = titleLabel.textContent;
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'order-item-subgroup-title-input';
|
||
input.value = previousTitle;
|
||
input.maxLength = 40;
|
||
input.setAttribute('aria-label', '그룹 이름 입력');
|
||
subgroupTitle.replaceChildren(input);
|
||
input.focus();
|
||
input.select();
|
||
let finished = false;
|
||
const finishEditing = async shouldSave => {
|
||
if (finished) return;
|
||
finished = true;
|
||
if (!shouldSave) {
|
||
renderSubgroupTitle(previousTitle);
|
||
return;
|
||
}
|
||
const enteredName = input.value.trim();
|
||
const nextTitle = enteredName || automaticTitle;
|
||
renderSubgroupTitle(nextTitle);
|
||
if (nextTitle === previousTitle) return;
|
||
try {
|
||
const response = await fetch('/api/products/subgroup-name', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
group_key: sec.key,
|
||
subgroup_index: index,
|
||
name: enteredName
|
||
})
|
||
});
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
throw new Error(errorData.detail || '그룹 이름 저장에 실패했습니다.');
|
||
}
|
||
if (!configData.SUBGROUP_NAMES) configData.SUBGROUP_NAMES = {};
|
||
if (enteredName) configData.SUBGROUP_NAMES[subgroupSettingKey] = enteredName;
|
||
else delete configData.SUBGROUP_NAMES[subgroupSettingKey];
|
||
showToast('그룹 이름이 저장되었습니다.');
|
||
} catch (error) {
|
||
renderSubgroupTitle(previousTitle);
|
||
alert(error.message || '그룹 이름 저장에 실패했습니다.');
|
||
}
|
||
};
|
||
input.addEventListener('keydown', keyEvent => {
|
||
if (keyEvent.key === 'Enter') {
|
||
keyEvent.preventDefault();
|
||
input.blur();
|
||
} else if (keyEvent.key === 'Escape') {
|
||
keyEvent.preventDefault();
|
||
finishEditing(false);
|
||
}
|
||
});
|
||
input.addEventListener('blur', () => finishEditing(true));
|
||
});
|
||
subgroupTitle.append(titleLabel, editButton);
|
||
};
|
||
renderSubgroupTitle(savedTitle || automaticTitle);
|
||
column.prepend(subgroupTitle);
|
||
});
|
||
grid.replaceChildren(...subgroupColumns);
|
||
grid.style.setProperty('--subgroup-count', Math.max(1, subgroupColumns.length));
|
||
|
||
card.appendChild(grid);
|
||
container.appendChild(card);
|
||
|
||
|
||
});
|
||
|
||
const classicCards = Array.from(container.querySelectorAll(':scope > .order-item-group'));
|
||
const classicColumnPlan = [[0], [1], [2], [3, 4], [5, 6], [7, 8]];
|
||
classicColumnPlan.forEach(cardIndexes => {
|
||
const column = document.createElement('div');
|
||
column.className = 'classic-product-column';
|
||
cardIndexes.forEach(index => {
|
||
if (classicCards[index]) column.appendChild(classicCards[index]);
|
||
});
|
||
if (column.children.length > 0) container.appendChild(column);
|
||
});
|
||
|
||
// Event listeners for config checks
|
||
document.getElementById('sms-discount').addEventListener('change', e => {
|
||
if (e.target.checked) document.getElementById('sms-june-promo').checked = false;
|
||
if (e.target.checked) {
|
||
document.getElementById('sms-coupon').disabled = true;
|
||
} else {
|
||
document.getElementById('sms-coupon').disabled = false;
|
||
}
|
||
});
|
||
document.getElementById('sms-coupon').addEventListener('change', e => {
|
||
if (e.target.checked) {
|
||
document.getElementById('sms-discount').disabled = true;
|
||
} else {
|
||
document.getElementById('sms-discount').disabled = false;
|
||
}
|
||
});
|
||
document.getElementById('sms-june-promo').addEventListener('change', e => {
|
||
if (e.target.checked) document.getElementById('sms-discount').checked = false;
|
||
});
|
||
document.getElementById('sms-ship-free').addEventListener('change', e => {
|
||
// No need to handle old static shipping checkboxes
|
||
});
|
||
updateSelectedOrderItemsSummary();
|
||
}
|
||
|
||
function updateOrderNoLidState() {
|
||
const checked = Array.from(document.querySelectorAll('.chk-order:checked'));
|
||
const noLidChk = document.getElementById('chk-no-lid');
|
||
if (checked.length === 0) {
|
||
noLidChk.disabled = true;
|
||
noLidChk.checked = false;
|
||
return;
|
||
}
|
||
|
||
const canEnable = checked.every(chk => chk.dataset.type === 'ORDER_SINGLE');
|
||
noLidChk.disabled = !canEnable;
|
||
if (!canEnable) noLidChk.checked = false;
|
||
}
|
||
|
||
|
||
// ============================================
|
||
// ORDER ACTIONS
|
||
// ============================================
|
||
function getCouponDiscount(amount) {
|
||
if (amount >= 220000) return 25000;
|
||
if (amount >= 150000) return 15000;
|
||
if (amount >= 100000) return 10000;
|
||
if (amount >= 70000) return 5000;
|
||
if (amount >= 50000) return 3000;
|
||
if (amount >= 30000) return 2000;
|
||
return 0;
|
||
}
|
||
function getOrderPayload(excludedIndices = []) {
|
||
const items = [];
|
||
let totalCost = 0;
|
||
let hasMarkedFreeShipping = false;
|
||
|
||
document.querySelectorAll('.chk-order:checked').forEach((chk, idx) => {
|
||
const row = chk.closest('.item-row');
|
||
const qty = parseInt(row.querySelector('.qty-order').value) || 1;
|
||
const multiplier = parseInt(chk.dataset.multiplier) || 1;
|
||
const orderLabel = chk.dataset.label;
|
||
|
||
items.push({
|
||
code: chk.dataset.code,
|
||
name: chk.dataset.name,
|
||
label: orderLabel,
|
||
item_type: chk.dataset.type,
|
||
quantity: qty * multiplier
|
||
|
||
|
||
});
|
||
|
||
const cost = parseInt(chk.dataset.cost) || 0;
|
||
const dcost = chk.dataset.dcost === "null" ? null : (parseInt(chk.dataset.dcost) || null);
|
||
|
||
let itemTotal = 0;
|
||
if (dcost !== null && qty >= 3) {
|
||
itemTotal = Math.floor(qty / 3) * dcost * 2 + (qty % 3) * cost;
|
||
} else {
|
||
itemTotal = cost * qty;
|
||
}
|
||
|
||
const orderType = document.querySelector('input[name="order-type"]:checked').value;
|
||
const noLidChecked = document.getElementById('chk-no-lid').checked;
|
||
if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) {
|
||
itemTotal = 0;
|
||
}
|
||
|
||
if (!excludedIndices.includes(idx)) {
|
||
totalCost += itemTotal;
|
||
if (chk.dataset.isFree === "true") hasMarkedFreeShipping = true;
|
||
}
|
||
|
||
|
||
});
|
||
|
||
// 🟢 금액별 추가 상품 체킹 시 사은품 자동 탑재
|
||
if (document.getElementById('sms-june-promo').checked && configData['SMS_GIFT_RULES']) {
|
||
let seen = new Set();
|
||
for (let i = 1; i <= 4; i++) {
|
||
const sMin = configData['SMS_GIFT_RULES'][`condition${i}_min`];
|
||
const sMax = configData['SMS_GIFT_RULES'][`condition${i}_max`];
|
||
const sGiftStr = configData['SMS_GIFT_RULES'][`condition${i}_gift`];
|
||
if (!sGiftStr) continue;
|
||
|
||
const minV = sMin ? parseInt(sMin) : 0;
|
||
const maxV = sMax ? parseInt(sMax) : null;
|
||
|
||
if (totalCost >= minV && (maxV === null || totalCost < maxV)) {
|
||
if (!seen.has(sGiftStr)) {
|
||
seen.add(sGiftStr);
|
||
|
||
let foundCode = 'ZZ-0000';
|
||
let foundName = sGiftStr;
|
||
let cleanLabel = sGiftStr.trim();
|
||
if (cleanLabel.match(/_([ORBGP])$/i)) cleanLabel = cleanLabel.slice(0, -2).trim();
|
||
|
||
let foundConfigValue = null;
|
||
for (const secKey of Object.keys(configData)) {
|
||
if (secKey === 'SMS_PRICING' || secKey === 'SMS_GIFT_RULES' || secKey === 'GROUP_NAMES' || secKey === 'SUBGROUP_NAMES') continue;
|
||
const sectionData = configData[secKey] || {};
|
||
if (sectionData[sGiftStr]) {
|
||
foundConfigValue = sectionData[sGiftStr];
|
||
break;
|
||
}
|
||
|
||
for (const [itemKey, itemValue] of Object.entries(sectionData)) {
|
||
if (itemKey === '__order' || typeof itemValue !== 'string') continue;
|
||
const parts = itemValue.split('|').map(s => s.trim());
|
||
const itemName = parts.length > 1 ? parts[1] : itemKey;
|
||
if (itemName === sGiftStr || itemName === cleanLabel) {
|
||
foundConfigValue = itemValue;
|
||
break;
|
||
}
|
||
}
|
||
if (foundConfigValue) break;
|
||
}
|
||
|
||
if (foundConfigValue) {
|
||
const parts = foundConfigValue.split('|').map(s => s.trim());
|
||
foundCode = parts[0] || foundCode;
|
||
foundName = parts.length > 1 ? parts[1] : cleanLabel;
|
||
} else {
|
||
console.warn('Gift item code not found, using fallback ZZ-0000:', sGiftStr);
|
||
}
|
||
|
||
items.push({
|
||
code: foundCode,
|
||
name: foundName,
|
||
label: cleanLabel,
|
||
item_type: 'event',
|
||
quantity: 1
|
||
|
||
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let baseShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.shipping_fee) ? configData.APP_SETTINGS.shipping_fee : 3500);
|
||
let extraShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.extra_shipping_fee) ? configData.APP_SETTINGS.extra_shipping_fee : 3000);
|
||
|
||
let pointDeduction = parseInt(document.getElementById('sms-point-deduction').value.replace(/,/g, '')) || 0;
|
||
|
||
let totalAfterDiscount = totalCost;
|
||
let discount5Amount = 0;
|
||
let couponAmount = 0;
|
||
if (document.getElementById('sms-coupon').checked) {
|
||
couponAmount = getCouponDiscount(totalCost);
|
||
}
|
||
if (document.getElementById('sms-discount').checked) {
|
||
totalAfterDiscount = Math.floor((totalCost * 0.95) / 10) * 10;
|
||
discount5Amount = totalCost - totalAfterDiscount;
|
||
}
|
||
if (couponAmount > 0) {
|
||
totalAfterDiscount = Math.max(0, totalAfterDiscount - couponAmount);
|
||
}
|
||
if (pointDeduction > 0) {
|
||
totalAfterDiscount = Math.max(0, totalAfterDiscount - pointDeduction);
|
||
}
|
||
|
||
let isAutoFree = totalAfterDiscount >= 50000;
|
||
let isManualFree = document.getElementById('sms-ship-free').checked;
|
||
let isFreeShip = hasMarkedFreeShipping || isAutoFree || isManualFree;
|
||
|
||
let shippingCost = 0;
|
||
let freeReason = "";
|
||
if (!isFreeShip) {
|
||
shippingCost = baseShippingVal;
|
||
} else {
|
||
if (isAutoFree) freeReason = "5만원 이상";
|
||
else if (isManualFree) freeReason = "수동배송무료";
|
||
else if (hasMarkedFreeShipping) freeReason = "무료배송상품";
|
||
else freeReason = "무료";
|
||
}
|
||
|
||
let extraShippingCost = 0;
|
||
if (document.getElementById('sms-ship-extra').checked) {
|
||
extraShippingCost = extraShippingVal;
|
||
if (isFreeShip) freeReason += " / 도서산간 추가";
|
||
}
|
||
|
||
const currentOrderType = document.querySelector('input[name="order-type"]:checked').value;
|
||
if (SPECIAL_ORDER_TYPES.has(currentOrderType)) {
|
||
shippingCost = 0;
|
||
extraShippingCost = 0;
|
||
}
|
||
|
||
let finalPayable = totalAfterDiscount + shippingCost + extraShippingCost;
|
||
|
||
return {
|
||
customer_name: document.getElementById('cust-name').value.trim(),
|
||
address: document.getElementById('cust-address').value.trim(),
|
||
phone: document.getElementById('cust-phone').value.trim(),
|
||
comment: "",
|
||
order_type: document.querySelector('input[name="order-type"]:checked').value,
|
||
no_lid: document.getElementById('chk-no-lid').checked,
|
||
items: items,
|
||
total_amount: finalPayable,
|
||
raw_total: totalCost,
|
||
discount_amount: discount5Amount + couponAmount,
|
||
discount_5_amount: discount5Amount,
|
||
coupon_amount: couponAmount,
|
||
point_deduction: pointDeduction,
|
||
shipping_cost: shippingCost,
|
||
extra_shipping_cost: extraShippingCost,
|
||
free_reason: freeReason
|
||
};
|
||
}
|
||
|
||
let currentSubmitContext = null;
|
||
|
||
function submitOrder(url, isEllen = false) {
|
||
const tempPayload = getOrderPayload();
|
||
tempPayload.is_ellen = isEllen;
|
||
|
||
if (tempPayload.items.length === 0) {
|
||
alert("발주 아이템을 선택해주세요."); return;
|
||
}
|
||
|
||
if (url.includes('submit')) {
|
||
if (!tempPayload.customer_name) {
|
||
alert("이름 항목이 누락되었습니다. 입력해주세요.");
|
||
return;
|
||
}
|
||
if (!tempPayload.phone) {
|
||
alert("연락처 항목이 누락되었습니다. 입력해주세요.");
|
||
return;
|
||
}
|
||
if (!tempPayload.address) {
|
||
alert("주소 항목이 누락되었습니다. 입력해주세요.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Setup modal
|
||
const listContainer = document.getElementById('exclude-items-list');
|
||
listContainer.innerHTML = '';
|
||
|
||
const updateTotalDisplay = () => {
|
||
const excludedIndices = Array.from(document.querySelectorAll('.exclude-chk:checked')).map(c => parseInt(c.dataset.idx));
|
||
const livePayload = getOrderPayload(excludedIndices);
|
||
|
||
let displayTotal = livePayload.total_amount;
|
||
const chkSampleSend = document.getElementById('chk-sample-send');
|
||
if (chkSampleSend && chkSampleSend.checked) {
|
||
displayTotal = 0;
|
||
}
|
||
|
||
document.getElementById('exclude-modal-totalAmount').innerText = displayTotal.toLocaleString();
|
||
|
||
let extraDiv = document.getElementById('exclude-extra-items');
|
||
if(!extraDiv) {
|
||
extraDiv = document.createElement('div');
|
||
extraDiv.id = 'exclude-extra-items';
|
||
}
|
||
|
||
let extraHTML = '';
|
||
if (livePayload.discount_5_amount > 0) {
|
||
extraHTML += `
|
||
<div style="display:flex; align-items:center; gap:8px; padding:8px; background:#fff; border:1px solid #cbd5e0; border-radius:4px;">
|
||
<span style="flex:1; font-weight:600; color:#2d3748; font-size: 0.95rem; margin-left:36px;">5% 할인</span>
|
||
<span style="color:#3182ce; font-weight:bold; font-size: 0.95rem;">- ${livePayload.discount_5_amount.toLocaleString()}원</span>
|
||
</div>`;
|
||
}
|
||
if (livePayload.coupon_amount > 0) {
|
||
extraHTML += `
|
||
<div style="display:flex; align-items:center; gap:8px; padding:8px; background:#fff; border:1px solid #cbd5e0; border-radius:4px;">
|
||
<span style="flex:1; font-weight:600; color:#2d3748; font-size: 0.95rem; margin-left:36px;">쿠폰 할인</span>
|
||
<span style="color:#3182ce; font-weight:bold; font-size: 0.95rem;">- ${livePayload.coupon_amount.toLocaleString()}원</span>
|
||
</div>`;
|
||
}
|
||
if (livePayload.point_deduction > 0) {
|
||
extraHTML += `
|
||
<div style="display:flex; align-items:center; gap:8px; padding:8px; background:#fff; border:1px solid #cbd5e0; border-radius:4px;">
|
||
<span style="flex:1; font-weight:600; color:#e53e3e; font-size: 0.95rem; margin-left:36px;">적립금 차감</span>
|
||
<span style="color:#e53e3e; font-weight:bold; font-size: 0.95rem;">- ${livePayload.point_deduction.toLocaleString()}원</span>
|
||
</div>`;
|
||
}
|
||
if (livePayload.shipping_cost > 0) {
|
||
extraHTML += `
|
||
<div style="display:flex; align-items:center; gap:8px; padding:8px; background:#fff; border:1px solid #cbd5e0; border-radius:4px;">
|
||
<span style="flex:1; font-weight:600; color:#2d3748; font-size: 0.95rem; margin-left:36px;">배송비</span>
|
||
<span style="color:#2d3748; font-weight:bold; font-size: 0.95rem;">+ ${livePayload.shipping_cost.toLocaleString()}원</span>
|
||
</div>`;
|
||
} else {
|
||
if(!livePayload.free_reason) livePayload.free_reason = "기본";
|
||
extraHTML += `
|
||
<div style="display:flex; align-items:center; gap:8px; padding:8px; background:#fff; border:1px solid #cbd5e0; border-radius:4px;">
|
||
<span style="flex:1; font-weight:600; color:#2d3748; font-size: 0.95rem; margin-left:36px;">배송비 (무료 - ${livePayload.free_reason})</span>
|
||
<span style="color:#2d3748; font-weight:bold; font-size: 0.95rem;">0원</span>
|
||
</div>`;
|
||
}
|
||
if (livePayload.extra_shipping_cost > 0) {
|
||
extraHTML += `
|
||
<div style="display:flex; align-items:center; gap:8px; padding:8px; background:#fff; border:1px solid #cbd5e0; border-radius:4px;">
|
||
<span style="flex:1; font-weight:600; color:#2d3748; font-size: 0.95rem; margin-left:36px;">추가 배송비</span>
|
||
<span style="color:#2d3748; font-weight:bold; font-size: 0.95rem;">+ ${livePayload.extra_shipping_cost.toLocaleString()}원</span>
|
||
</div>`;
|
||
}
|
||
extraDiv.innerHTML = extraHTML;
|
||
listContainer.appendChild(extraDiv);
|
||
|
||
document.querySelectorAll('.exclude-chk').forEach(c => {
|
||
const priceSpan = c.closest('label').querySelector('.exclude-price-span');
|
||
if (priceSpan) {
|
||
if (c.checked) {
|
||
priceSpan.style.textDecoration = 'line-through';
|
||
priceSpan.style.textDecorationColor = 'red';
|
||
priceSpan.style.opacity = '0.5';
|
||
} else {
|
||
priceSpan.style.textDecoration = 'none';
|
||
priceSpan.style.opacity = '1';
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
document.querySelectorAll('.chk-order:checked').forEach((chk, idx) => {
|
||
const row = chk.closest('.item-row');
|
||
const qty = parseInt(row.querySelector('.qty-order').value) || 1;
|
||
const multiplier = parseInt(chk.dataset.multiplier) || 1;
|
||
const finalQty = qty * multiplier;
|
||
|
||
const cost = parseInt(chk.dataset.cost) || 0;
|
||
const dcost = chk.dataset.dcost === "null" ? null : (parseInt(chk.dataset.dcost) || null);
|
||
let itemTotal = 0;
|
||
if (dcost !== null && qty >= 3) {
|
||
itemTotal = Math.floor(qty / 3) * dcost * 2 + (qty % 3) * cost;
|
||
} else {
|
||
itemTotal = cost * qty;
|
||
}
|
||
|
||
const orderType = document.querySelector('input[name="order-type"]:checked').value;
|
||
const noLidChecked = document.getElementById('chk-no-lid').checked;
|
||
if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) {
|
||
itemTotal = 0;
|
||
}
|
||
|
||
let badges = [];
|
||
if (SPECIAL_ORDER_TYPES.has(orderType)) badges.push(ORDER_TYPE_LABELS[orderType]);
|
||
if (noLidChecked && chk.dataset.type === 'ORDER_SINGLE') badges.push('뚜껑(X)통(O)');
|
||
|
||
let reasonBadge = badges.length > 0 ? `<span style="font-size:0.75rem; background:#feebc8; color:#dd6b20; padding:2px 5px; border-radius:4px; margin-left:6px; font-weight:bold;">${badges.join(', ')}</span>` : '';
|
||
|
||
const div = document.createElement('div');
|
||
div.style.display = 'flex';
|
||
div.style.alignItems = 'center';
|
||
div.style.gap = '8px';
|
||
div.style.padding = '8px';
|
||
div.style.background = '#fff';
|
||
div.style.border = '1px solid #cbd5e0';
|
||
div.style.borderRadius = '4px';
|
||
|
||
div.innerHTML = `
|
||
<label style="cursor:pointer; display:flex; align-items:center; gap:8px; width:100%; margin: 0;">
|
||
<input type="checkbox" class="exclude-chk" data-idx="${idx}" style="transform: scale(1.2); cursor: pointer;">
|
||
<span style="flex:1; font-weight:600; color:#2d3748; font-size: 0.95rem;">${chk.dataset.label} (${finalQty}개)${reasonBadge}</span>
|
||
<span class="exclude-price-span" style="color:#e53e3e; font-weight:bold; font-size: 0.95rem; transition: opacity 0.2s;">${itemTotal.toLocaleString()}원</span>
|
||
</label>
|
||
`;
|
||
|
||
const checkbox = div.querySelector('.exclude-chk');
|
||
checkbox.addEventListener('change', updateTotalDisplay);
|
||
|
||
listContainer.appendChild(div);
|
||
});
|
||
|
||
currentSubmitContext = { url, isEllen };
|
||
|
||
const chkSampleSend = document.getElementById('chk-sample-send');
|
||
if (chkSampleSend) {
|
||
chkSampleSend.checked = false;
|
||
chkSampleSend.onchange = (e) => {
|
||
const isChecked = e.target.checked;
|
||
document.querySelectorAll('.exclude-chk').forEach(c => c.checked = isChecked);
|
||
updateTotalDisplay();
|
||
};
|
||
}
|
||
|
||
updateTotalDisplay();
|
||
document.getElementById('exclude-modal').style.display = 'flex';
|
||
}
|
||
|
||
document.getElementById('btn-exclude-cancel').addEventListener('click', () => {
|
||
document.getElementById('exclude-modal').style.display = 'none';
|
||
currentSubmitContext = null;
|
||
});
|
||
|
||
document.getElementById('btn-exclude-submit').addEventListener('click', () => {
|
||
if (!currentSubmitContext) return;
|
||
const excludedIndices = Array.from(document.querySelectorAll('.exclude-chk:checked')).map(c => parseInt(c.dataset.idx));
|
||
document.getElementById('exclude-modal').style.display = 'none';
|
||
doSubmitOrder(currentSubmitContext.url, currentSubmitContext.isEllen, excludedIndices);
|
||
currentSubmitContext = null;
|
||
});
|
||
|
||
function doSubmitOrder(url, isEllen = false, excludedIndices = []) {
|
||
const payload = getOrderPayload(excludedIndices);
|
||
payload.is_ellen = isEllen;
|
||
|
||
const isSpecialType = SPECIAL_ORDER_TYPES.has(payload.order_type);
|
||
const typeLabelMap = ORDER_TYPE_LABELS;
|
||
|
||
let finalPayloadAmount = payload.total_amount;
|
||
if (isSpecialType) finalPayloadAmount = 0;
|
||
|
||
const chkSampleSend = document.getElementById('chk-sample-send');
|
||
if (chkSampleSend && chkSampleSend.checked) {
|
||
finalPayloadAmount = 0;
|
||
}
|
||
|
||
const submitPayload = { ...payload, total_amount: finalPayloadAmount };
|
||
|
||
if (url.includes('submit')) {
|
||
const manualTabLink = document.querySelector('li[data-tab="manual-tab"]');
|
||
if (manualTabLink) manualTabLink.click();
|
||
const loader = document.getElementById('sheet-loader');
|
||
if (loader) loader.style.display = 'flex';
|
||
}
|
||
|
||
fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(submitPayload)
|
||
}).then(res => res.json()).then(data => {
|
||
if (url.includes('submit')) {
|
||
const loader = document.getElementById('sheet-loader');
|
||
if (loader) loader.style.display = 'none';
|
||
}
|
||
if (data.status === 'success') {
|
||
if (!url.includes('submit')) {
|
||
alert(data.message);
|
||
} else if (data.timestamp) {
|
||
const histData = {
|
||
timestamp: data.timestamp,
|
||
customer_name: submitPayload.customer_name,
|
||
total_amount: isSpecialType ? typeLabelMap[submitPayload.order_type] : submitPayload.total_amount
|
||
};
|
||
fetch('/api/manual/history', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(histData)
|
||
}).then(() => {
|
||
if (typeof loadManualHistory === 'function') {
|
||
loadManualHistory();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Copy the same text that is written to the sheet remarks/comparison field.
|
||
if (data.clipboard_text) {
|
||
copyTextToClipboard(data.clipboard_text).then(copied => {
|
||
if (!copied) {
|
||
alert("클립보드 복사에 실패했습니다. 브라우저 권한을 확인해주세요.");
|
||
}
|
||
});
|
||
}
|
||
document.getElementById('btn-reset').click();
|
||
if (url.includes('submit')) {
|
||
const iframe = document.querySelector('#manual-tab iframe');
|
||
if (iframe) iframe.src = iframe.src;
|
||
}
|
||
} else {
|
||
alert("Error: " + data.detail);
|
||
}
|
||
}).catch(err => {
|
||
if (url.includes('submit')) {
|
||
const loader = document.getElementById('sheet-loader');
|
||
if (loader) loader.style.display = 'none';
|
||
}
|
||
alert("요청 중 오류 발생: " + err);
|
||
|
||
|
||
});
|
||
}
|
||
|
||
document.getElementById('btn-submit-normal').addEventListener('click', () => submitOrder('/api/order/submit', false));
|
||
document.getElementById('btn-submit-ellen').addEventListener('click', () => submitOrder('/api/order/submit', true));
|
||
|
||
document.getElementById('btn-download-sheet').addEventListener('click', () => {
|
||
if (!confirm("수동발주로 입력한 자료는 엑셀 파일로 저장되며, 입력된 수동발주 내용은 사라집니다.\n진행하시겠습니까?")) return;
|
||
|
||
const btn = document.getElementById('btn-download-sheet');
|
||
btn.innerText = '저장 중...';
|
||
btn.disabled = true;
|
||
|
||
const progressContainer = document.getElementById('download-progress-container');
|
||
const progressBar = document.getElementById('download-progress-bar');
|
||
const progressText = document.getElementById('download-progress-text');
|
||
|
||
progressContainer.style.display = 'block';
|
||
progressBar.style.width = '0%';
|
||
progressText.innerText = '0%';
|
||
|
||
let progress = 0;
|
||
const interval = setInterval(() => {
|
||
if (progress < 90) {
|
||
progress += Math.floor(Math.random() * 8) + 4;
|
||
if (progress > 90) progress = 90;
|
||
progressBar.style.width = progress + '%';
|
||
progressText.innerText = progress + '%';
|
||
}
|
||
}, 500);
|
||
|
||
fetch('/api/sheet/download_and_clear', { method: 'POST' })
|
||
.then(res => {
|
||
if (!res.ok) throw new Error("서버에서 오류가 발생했습니다.");
|
||
return res.blob();
|
||
})
|
||
.then(blob => {
|
||
// Determine save filename client-side
|
||
const now = new Date();
|
||
const weekdays = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"];
|
||
const dayStr = weekdays[now.getDay()];
|
||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||
const dd = String(now.getDate()).padStart(2, '0');
|
||
const fileName = `${mm}월 ${dd}일 (${dayStr}) 5 수동발주.xlsx`;
|
||
|
||
// Spawn a temporary web link to fetch the constructed Blob and trigger "Save as"
|
||
const downloadUrl = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.style.display = 'none';
|
||
a.href = downloadUrl;
|
||
a.download = fileName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
|
||
// Cleanup Blob immediately
|
||
window.URL.revokeObjectURL(downloadUrl);
|
||
document.body.removeChild(a);
|
||
|
||
clearInterval(interval);
|
||
progressBar.style.width = '100%';
|
||
progressText.innerText = '100%';
|
||
|
||
setTimeout(() => {
|
||
alert("구글 엑셀 파일이 성공적으로 다운로드되고 시트가 초기화되었습니다.");
|
||
progressContainer.style.display = 'none';
|
||
btn.innerText = '엑셀로 저장';
|
||
btn.disabled = false;
|
||
|
||
const iframe = document.querySelector('#manual-tab iframe');
|
||
if (iframe) iframe.src = iframe.src;
|
||
}, 400);
|
||
})
|
||
.catch(err => {
|
||
clearInterval(interval);
|
||
alert("네트워크 오류/파일 저장 실패: " + err);
|
||
progressContainer.style.display = 'none';
|
||
btn.innerText = '엑셀로 저장';
|
||
btn.disabled = false;
|
||
|
||
|
||
});
|
||
|
||
|
||
});
|
||
|
||
document.getElementById('btn-reset').addEventListener('click', () => {
|
||
document.querySelectorAll('.chk-order').forEach(c => {
|
||
c.checked = false;
|
||
const row = c.closest('.item-row');
|
||
if (row) row.style.backgroundColor = '';
|
||
});
|
||
document.querySelectorAll('.qty-order').forEach(i => i.value = "1");
|
||
document.getElementById('cust-name').value = '';
|
||
document.getElementById('cust-phone').value = '';
|
||
document.getElementById('cust-address').value = '';
|
||
document.getElementById('chk-no-lid').checked = false;
|
||
document.getElementById('chk-no-lid').disabled = true;
|
||
document.querySelector('input[name="order-type"][value="normal"]').checked = true;
|
||
|
||
// sms reset as well
|
||
document.getElementById('sms-discount').checked = false;
|
||
document.getElementById('sms-discount').disabled = false;
|
||
document.getElementById('sms-coupon').checked = false;
|
||
document.getElementById('sms-coupon').disabled = false;
|
||
document.getElementById('sms-june-promo').checked = false;
|
||
document.getElementById('sms-ship-free').checked = false;
|
||
document.getElementById('sms-ship-extra').checked = false;
|
||
const ptInput = document.getElementById('sms-point-deduction');
|
||
if (ptInput) ptInput.value = '';
|
||
document.getElementById('sms-preview').innerHTML = '';
|
||
updateSelectedOrderItemsSummary();
|
||
|
||
|
||
});
|
||
|
||
// ============================================
|
||
// SMS CALCULATE WITH MATCHED PRICING
|
||
// ============================================
|
||
function getAmountGiftText(orderAmount) {
|
||
if (!configData['SMS_GIFT_RULES']) return "";
|
||
let matched = [];
|
||
let rules = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
const sMin = configData['SMS_GIFT_RULES'][`condition${i}_min`];
|
||
const sMax = configData['SMS_GIFT_RULES'][`condition${i}_max`];
|
||
const sGift = configData['SMS_GIFT_RULES'][`condition${i}_gift`];
|
||
if (!sGift) continue;
|
||
|
||
const minV = sMin ? parseInt(sMin) : 0;
|
||
const maxV = sMax ? parseInt(sMax) : null;
|
||
rules.push({
|
||
min: minV, max: maxV, gift: sGift
|
||
|
||
});
|
||
}
|
||
|
||
let seen = new Set();
|
||
rules.forEach(r => {
|
||
if (orderAmount < r.min) return;
|
||
if (r.max !== null && orderAmount >= r.max) return;
|
||
if (!seen.has(r.gift)) {
|
||
seen.add(r.gift);
|
||
matched.push(r.gift);
|
||
}
|
||
|
||
|
||
});
|
||
|
||
if (matched.length === 0) return "";
|
||
return "\n(금액별 사은품 증정)\n" + matched.join("\n") + "\n";
|
||
}
|
||
|
||
function buildSmsPreviewResult() {
|
||
let totalCost = 0;
|
||
let orderListStr = "";
|
||
let hasMarkedFreeShipping = false;
|
||
|
||
const checkedItems = document.querySelectorAll('.chk-order:checked');
|
||
if (checkedItems.length === 0) return null;
|
||
|
||
checkedItems.forEach(chk => {
|
||
const row = chk.closest('.item-row');
|
||
const qty = parseInt(row.querySelector('.qty-order').value) || 1;
|
||
const orderLabel = chk.dataset.label;
|
||
|
||
const cost = parseInt(chk.dataset.cost) || 0;
|
||
const dcost = chk.dataset.dcost === "null" ? null : (parseInt(chk.dataset.dcost) || null);
|
||
const isFree = chk.dataset.isFree === "true";
|
||
|
||
if (isFree) hasMarkedFreeShipping = true;
|
||
|
||
let itemTotal = 0;
|
||
if (dcost !== null && qty >= 3) {
|
||
itemTotal = Math.floor(qty / 3) * dcost * 2 + (qty % 3) * cost;
|
||
} else {
|
||
itemTotal = cost * qty;
|
||
}
|
||
|
||
const orderType = document.querySelector('input[name="order-type"]:checked').value;
|
||
const noLidChecked = document.getElementById('chk-no-lid').checked;
|
||
if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) {
|
||
itemTotal = 0;
|
||
}
|
||
|
||
const cleanLabel = orderLabel.replace(/^\*/, '').trim();
|
||
let dispName = cleanLabel.replace("오리 ", "");
|
||
let smsQty = qty;
|
||
|
||
const matchName = dispName.match(/_(\d+)개$/);
|
||
if (matchName) {
|
||
smsQty = qty * parseInt(matchName[1], 10);
|
||
dispName = dispName.replace(/_\d+개$/, '').trim();
|
||
}
|
||
|
||
if (cost === 0) {
|
||
orderListStr += `<span style="color:red; font-weight:bold;">${dispName} (가격미입력)</span>\n`;
|
||
} else {
|
||
orderListStr += `${dispName} ${smsQty}개 ${itemTotal.toLocaleString()}원\n`;
|
||
}
|
||
totalCost += itemTotal;
|
||
|
||
|
||
});
|
||
|
||
let baseShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.shipping_fee) ? configData.APP_SETTINGS.shipping_fee : 3500);
|
||
let extraShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.extra_shipping_fee) ? configData.APP_SETTINGS.extra_shipping_fee : 3000);
|
||
let pointDeduction = parseInt(document.getElementById('sms-point-deduction').value.replace(/,/g, '')) || 0;
|
||
|
||
let totalAfterDiscount = totalCost;
|
||
let discount5Amount = 0;
|
||
let couponAmount = 0;
|
||
if (document.getElementById('sms-coupon').checked) {
|
||
couponAmount = getCouponDiscount(totalCost);
|
||
}
|
||
if (document.getElementById('sms-discount').checked) {
|
||
totalAfterDiscount = Math.floor((totalCost * 0.95) / 10) * 10;
|
||
discount5Amount = totalCost - totalAfterDiscount;
|
||
}
|
||
if (couponAmount > 0) {
|
||
totalAfterDiscount = Math.max(0, totalAfterDiscount - couponAmount);
|
||
}
|
||
if (pointDeduction > 0) {
|
||
totalAfterDiscount = Math.max(0, totalAfterDiscount - pointDeduction);
|
||
}
|
||
|
||
let junePromoText = "";
|
||
if (document.getElementById('sms-june-promo').checked) {
|
||
junePromoText = getAmountGiftText(totalCost);
|
||
}
|
||
|
||
let isAutoFree = totalAfterDiscount >= 50000;
|
||
let isManualFree = document.getElementById('sms-ship-free').checked;
|
||
let isFreeShip = hasMarkedFreeShipping || isAutoFree || isManualFree;
|
||
|
||
let shippingCost = 0;
|
||
if (!isFreeShip) {
|
||
shippingCost = baseShippingVal;
|
||
}
|
||
|
||
let extraShippingCost = 0;
|
||
if (document.getElementById('sms-ship-extra').checked) {
|
||
extraShippingCost = extraShippingVal;
|
||
}
|
||
|
||
const currentOrderType = document.querySelector('input[name="order-type"]:checked').value;
|
||
const noLidChecked = document.getElementById('chk-no-lid').checked;
|
||
if (SPECIAL_ORDER_TYPES.has(currentOrderType) || noLidChecked) {
|
||
shippingCost = 0;
|
||
extraShippingCost = 0;
|
||
}
|
||
|
||
let finalPayable = totalAfterDiscount + shippingCost + extraShippingCost;
|
||
|
||
let depositVal = `${finalPayable.toLocaleString()}원`;
|
||
if (hasMarkedFreeShipping) {
|
||
if (extraShippingCost > 0) depositVal += "(무료배송, 추가배송비만 적용)";
|
||
else depositVal += "(무료배송 상품 포함)";
|
||
}
|
||
else if (isFreeShip) depositVal += "(무료배송)";
|
||
let depositLine = `입금 금액: ${depositVal}`;
|
||
|
||
const defaultHeader = "안녕하세요, 미라네 주방입니다! 😊 \n주문내용 확인 부탁드립니다.";
|
||
const defaultFooter = "1. 아래 계좌로 입금해 주세요.\n\n▶입금 금액: {최종금액} \n▶입금 계좌 (농협): 317-0025-1542-51\n▶예금주: (주)더블엑스 코퍼레이션\n\n2. 입금 후 [성함/주소/연락처]를 남겨주세요.\n\n[배송 안내]\n■오후 1시 이전 입금 건은 당일 발송됩니다.\n★이벤트 상품 주문시 1~2일 정도 늦어질 수 있으니 너그러운 양해 부탁드립니다.";
|
||
const rules = configData['SMS_GIFT_RULES'] || {};
|
||
|
||
let headerText = rules['header_text'] ? rules['header_text'].replace(/\\n/g, '\n') : defaultHeader;
|
||
let footerText = rules['footer_text'] ? rules['footer_text'].replace(/\\n/g, '\n') : defaultFooter;
|
||
|
||
footerText = footerText.replace(/{최종금액}/g, depositVal);
|
||
|
||
let msg = `${headerText}\n\n*****주문 내용*****\n${orderListStr}${junePromoText}\n주문 금액: ${totalCost.toLocaleString()}원\n`;
|
||
if (discount5Amount > 0) msg += `할인 금액: -${discount5Amount.toLocaleString()}원(5%)\n`;
|
||
if (couponAmount > 0) msg += `쿠폰 할인: -${couponAmount.toLocaleString()}원\n`;
|
||
if (pointDeduction > 0) msg += `적립금 차감: -${pointDeduction.toLocaleString()}원\n`;
|
||
if (shippingCost > 0) msg += ` 택배비: ${shippingCost.toLocaleString()}원\n`;
|
||
if (extraShippingCost > 0) msg += `추가배송비: ${extraShippingCost.toLocaleString()}원\n`;
|
||
|
||
msg += `==========================\n${depositLine}\n\n${footerText}`;
|
||
|
||
return { msg, finalPayable, pointDeduction, checkedItems };
|
||
}
|
||
|
||
function refreshLiveSmsPreview() {
|
||
const previewDiv = document.getElementById('sms-preview');
|
||
const result = buildSmsPreviewResult();
|
||
if (!result) {
|
||
previewDiv.textContent = '선택된 상품이 없습니다.';
|
||
return null;
|
||
}
|
||
previewDiv.innerHTML = result.msg;
|
||
return result;
|
||
}
|
||
|
||
const smsPreviewModal = document.getElementById('sms-preview-modal');
|
||
const btnSmsPreviewOpen = document.getElementById('btn-sms-preview-open');
|
||
const btnSmsPreviewClose = document.getElementById('btn-sms-preview-close');
|
||
|
||
function closeSmsPreviewModal() {
|
||
smsPreviewModal.classList.remove('is-open');
|
||
smsPreviewModal.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
if (btnSmsPreviewOpen) {
|
||
btnSmsPreviewOpen.addEventListener('click', () => {
|
||
refreshLiveSmsPreview();
|
||
smsPreviewModal.classList.add('is-open');
|
||
smsPreviewModal.setAttribute('aria-hidden', 'false');
|
||
btnSmsPreviewClose.focus();
|
||
});
|
||
}
|
||
if (btnSmsPreviewClose) btnSmsPreviewClose.addEventListener('click', closeSmsPreviewModal);
|
||
smsPreviewModal.addEventListener('click', event => {
|
||
if (event.target === smsPreviewModal) closeSmsPreviewModal();
|
||
});
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape' && smsPreviewModal.classList.contains('is-open')) {
|
||
closeSmsPreviewModal();
|
||
}
|
||
});
|
||
|
||
function scheduleLiveOrderRefresh() {
|
||
queueMicrotask(updateSelectedOrderItemsSummary);
|
||
}
|
||
|
||
const smsOptionsPanel = document.querySelector('.sms-options');
|
||
if (smsOptionsPanel) {
|
||
smsOptionsPanel.addEventListener('change', scheduleLiveOrderRefresh);
|
||
smsOptionsPanel.addEventListener('input', scheduleLiveOrderRefresh);
|
||
}
|
||
document.querySelectorAll('input[name="order-type"]').forEach(input => {
|
||
input.addEventListener('change', scheduleLiveOrderRefresh);
|
||
});
|
||
document.getElementById('chk-no-lid').addEventListener('change', scheduleLiveOrderRefresh);
|
||
|
||
document.getElementById('btn-sms-calc').addEventListener('click', () => {
|
||
const result = refreshLiveSmsPreview();
|
||
if (!result) {
|
||
alert('선택된 상품이 없습니다.');
|
||
return;
|
||
}
|
||
const { msg, finalPayable, pointDeduction, checkedItems } = result;
|
||
const previewDiv = document.getElementById('sms-preview');
|
||
copyTextToClipboard(previewDiv.innerText).then(copied => {
|
||
if (copied) showToast('문자 내용이 복사되었습니다.');
|
||
});
|
||
|
||
|
||
// Save History
|
||
const now = new Date();
|
||
const stamp = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
|
||
|
||
const savedItems = [];
|
||
checkedItems.forEach(chk => {
|
||
const row = chk.closest('.item-row');
|
||
const qty = parseInt(row.querySelector('.qty-order').value) || 1;
|
||
savedItems.push({
|
||
code: chk.dataset.code,
|
||
label: chk.dataset.label,
|
||
qty: qty
|
||
|
||
|
||
});
|
||
|
||
|
||
});
|
||
|
||
const custName = document.getElementById('cust-name').value.trim();
|
||
const custPhone = document.getElementById('cust-phone').value.trim();
|
||
const custAddress = document.getElementById('cust-address').value.trim();
|
||
|
||
const histData = {
|
||
timestamp: stamp,
|
||
deposit_amount_str: finalPayable.toLocaleString() + "원",
|
||
custName: custName,
|
||
custPhone: custPhone,
|
||
custAddress: custAddress,
|
||
customer_name: custName,
|
||
phone: custPhone,
|
||
address: custAddress,
|
||
orderType: document.querySelector('input[name="order-type"]:checked').value,
|
||
noLid: document.getElementById('chk-no-lid').checked,
|
||
items: savedItems,
|
||
smsOpts: {
|
||
discount: document.getElementById('sms-discount').checked,
|
||
junePromo: document.getElementById('sms-june-promo').checked,
|
||
shipExtra: document.getElementById('sms-ship-extra').checked,
|
||
shipFree: document.getElementById('sms-ship-free').checked,
|
||
pointDeduction: pointDeduction
|
||
},
|
||
previewText: msg
|
||
};
|
||
|
||
fetch('/api/sms/history', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(histData)
|
||
}).then(() => {
|
||
writeSmsCustomerCache(stamp, {
|
||
custName: custName,
|
||
custPhone: custPhone,
|
||
custAddress: custAddress
|
||
});
|
||
loadSmsHistory();
|
||
});
|
||
|
||
|
||
});
|
||
|
||
// ============================================
|
||
// SMS HISTORY
|
||
// ============================================
|
||
function loadSmsHistory() {
|
||
const u = document.getElementById('sms-history-list');
|
||
u.innerHTML = '';
|
||
fetch('/api/sms/history').then(r => r.json()).then(data => {
|
||
data.slice().reverse().forEach((d, idx) => {
|
||
const actualIdx = data.length - 1 - idx;
|
||
const li = document.createElement('li');
|
||
li.style.cursor = 'pointer';
|
||
li.style.padding = '4px 0';
|
||
li.style.borderBottom = '1px solid #eee';
|
||
li.onclick = () => window.loadHistoryItem(actualIdx);
|
||
|
||
const cachedCustomer = readSmsCustomerCache()[d.timestamp] || {};
|
||
const name = firstNonEmpty(d.custName, d.customer_name, cachedCustomer.custName, cachedCustomer.customer_name);
|
||
const phone = firstNonEmpty(d.custPhone, d.phone, cachedCustomer.custPhone, cachedCustomer.phone);
|
||
const address = firstNonEmpty(d.custAddress, d.address, cachedCustomer.custAddress, cachedCustomer.address);
|
||
|
||
const hasCustInfo = (name && name.trim() && name.trim().toLowerCase() !== 'none') ||
|
||
(phone && phone.trim() && phone.trim().toLowerCase() !== 'none') ||
|
||
(address && address.trim() && address.trim().toLowerCase() !== 'none');
|
||
|
||
let amountStr = d.deposit_amount_str || '';
|
||
if (hasCustInfo) {
|
||
amountStr = amountStr.replace('원', '원*');
|
||
}
|
||
|
||
const spanDate = document.createElement('span');
|
||
spanDate.textContent = `${d.timestamp} | ${amountStr}`;
|
||
li.appendChild(spanDate);
|
||
|
||
const spanDel = document.createElement('span');
|
||
spanDel.className = 'action-text ms-2';
|
||
spanDel.style.marginLeft = '8px';
|
||
spanDel.title = '삭제';
|
||
spanDel.textContent = '❌';
|
||
spanDel.onclick = (e) => window.deleteHistory(actualIdx, e);
|
||
li.appendChild(spanDel);
|
||
|
||
u.appendChild(li);
|
||
|
||
|
||
});
|
||
|
||
|
||
});
|
||
}
|
||
|
||
const SMS_CUSTOMER_CACHE_KEY = 'smsCustomerHistoryByTimestamp';
|
||
|
||
function readSmsCustomerCache() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(SMS_CUSTOMER_CACHE_KEY) || '{}');
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeSmsCustomerCache(timestamp, customer) {
|
||
if (!timestamp) return;
|
||
const cache = readSmsCustomerCache();
|
||
cache[timestamp] = customer;
|
||
localStorage.setItem(SMS_CUSTOMER_CACHE_KEY, JSON.stringify(cache));
|
||
}
|
||
|
||
function firstNonEmpty(...values) {
|
||
for (const value of values) {
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||
return value;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
window.loadManualHistory = function () {
|
||
const u = document.getElementById('manual-history-list');
|
||
if (!u) return;
|
||
u.innerHTML = '';
|
||
fetch('/api/manual/history').then(r => r.json()).then(data => {
|
||
let totalCount = data.length;
|
||
let totalAmount = 0;
|
||
data.forEach((d) => {
|
||
if (typeof d.total_amount === 'number') totalAmount += d.total_amount;
|
||
else if (typeof d.total_amount === 'string' && !isNaN(parseInt(d.total_amount))) totalAmount += parseInt(d.total_amount);
|
||
});
|
||
|
||
let txtCount = document.getElementById('manual-total-count');
|
||
let txtAmount = document.getElementById('manual-total-amount');
|
||
if (txtCount) txtCount.textContent = totalCount;
|
||
if (txtAmount) txtAmount.textContent = totalAmount.toLocaleString();
|
||
|
||
data.slice().reverse().forEach((d) => {
|
||
const li = document.createElement('li');
|
||
li.style.padding = '4px 0';
|
||
li.style.borderBottom = '1px solid #eee';
|
||
li.style.display = 'flex';
|
||
li.style.justifyContent = 'space-between';
|
||
li.style.alignItems = 'center';
|
||
li.style.fontSize = '0.9rem';
|
||
|
||
const spanInfo = document.createElement('span');
|
||
const timeStr = d.timestamp.split(' ')[1] || d.timestamp;
|
||
let amtDisplay = (typeof d.total_amount === 'number' || (typeof d.total_amount === 'string' && !isNaN(parseInt(d.total_amount))))
|
||
? parseInt(d.total_amount).toLocaleString() + '원'
|
||
: d.total_amount;
|
||
spanInfo.textContent = `${timeStr} | ${d.customer_name} | ${amtDisplay}`;
|
||
li.appendChild(spanInfo);
|
||
|
||
const spanDel = document.createElement('span');
|
||
spanDel.className = 'action-text ms-2';
|
||
spanDel.style.marginLeft = '8px';
|
||
spanDel.style.cursor = 'pointer';
|
||
spanDel.title = '삭제';
|
||
spanDel.textContent = '❌';
|
||
spanDel.onclick = (e) => {
|
||
e.stopPropagation();
|
||
if (confirm("🚨 [경고] 이 수동발주 기록을 삭제하시겠습니까?\n\n(구글 시트에서도 함께 삭제됩니다!)")) {
|
||
const btn = e.target;
|
||
btn.textContent = '⏳';
|
||
btn.style.pointerEvents = 'none';
|
||
fetch(`/api/manual/history/${encodeURIComponent(d.timestamp)}`, { method: 'DELETE' })
|
||
.then(() => {
|
||
window.loadManualHistory();
|
||
const iframe = document.querySelector('#manual-tab iframe');
|
||
if (iframe) iframe.src = iframe.src;
|
||
})
|
||
.catch(() => {
|
||
alert("삭제 중 오류가 발생했습니다.");
|
||
btn.textContent = '❌';
|
||
btn.style.pointerEvents = 'auto';
|
||
});
|
||
}
|
||
};
|
||
li.appendChild(spanDel);
|
||
|
||
u.appendChild(li);
|
||
});
|
||
}).catch(err => console.error("Error loading manual history", err));
|
||
}
|
||
|
||
window.loadHistoryItem = function (idx) {
|
||
fetch('/api/sms/history').then(r => r.json()).then(data => {
|
||
if (idx < 0 || idx >= data.length) return;
|
||
const d = data[idx];
|
||
|
||
document.getElementById('btn-reset').click();
|
||
|
||
const cachedCustomer = readSmsCustomerCache()[d.timestamp] || {};
|
||
const savedCustName = firstNonEmpty(d.custName, d.customer_name, cachedCustomer.custName, cachedCustomer.customer_name);
|
||
const savedCustPhone = firstNonEmpty(d.custPhone, d.phone, cachedCustomer.custPhone, cachedCustomer.phone);
|
||
const savedCustAddress = firstNonEmpty(d.custAddress, d.address, cachedCustomer.custAddress, cachedCustomer.address);
|
||
document.getElementById('cust-name').value = savedCustName;
|
||
document.getElementById('cust-phone').value = savedCustPhone;
|
||
document.getElementById('cust-address').value = savedCustAddress;
|
||
if (d.orderType !== undefined) {
|
||
const rb = document.querySelector(`input[name="order-type"][value="${d.orderType}"]`);
|
||
if (rb) rb.checked = true;
|
||
}
|
||
|
||
// Fallback for Google Sheets history where items array is missing but items_str exists
|
||
if ((!d.items || !Array.isArray(d.items)) && d.items_str) {
|
||
d.items = [];
|
||
const parts = d.items_str.split(',');
|
||
parts.forEach(part => {
|
||
const match = part.trim().match(/^(.*)[\[\(](.*?)[\]\)]\s*(\d+)개$/);
|
||
if (match) {
|
||
d.items.push({
|
||
label: match[1].trim(),
|
||
code: match[2].trim(),
|
||
qty: parseInt(match[3])
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
if (d.items && Array.isArray(d.items)) {
|
||
d.items.forEach(item => {
|
||
let chk = Array.from(document.querySelectorAll('.chk-order:not(:checked)')).find(c => c.dataset.code === item.code && c.dataset.label === item.label);
|
||
if (!chk) {
|
||
chk = Array.from(document.querySelectorAll('.chk-order:not(:checked)')).find(c => c.dataset.code === item.code || c.dataset.label === item.label);
|
||
}
|
||
if (chk) {
|
||
chk.checked = true;
|
||
const row = chk.closest('.item-row');
|
||
if (row) {
|
||
const sel = row.querySelector('.qty-order');
|
||
if (sel) {
|
||
if (sel.tagName === 'SELECT') {
|
||
const optionExists = Array.from(sel.options).some(opt => opt.value == item.qty);
|
||
if (!optionExists) {
|
||
const input = document.createElement('input');
|
||
input.type = 'number';
|
||
input.min = '1';
|
||
input.value = item.qty;
|
||
input.className = 'qty-order';
|
||
sel.replaceWith(input);
|
||
} else {
|
||
sel.value = item.qty;
|
||
}
|
||
} else {
|
||
sel.value = item.qty;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
});
|
||
|
||
const anyChk = document.querySelector('.chk-order');
|
||
if (anyChk) {
|
||
anyChk.dispatchEvent(new Event('change'));
|
||
}
|
||
updateSelectedOrderItemsSummary();
|
||
|
||
if (d.noLid !== undefined) document.getElementById('chk-no-lid').checked = d.noLid;
|
||
}
|
||
|
||
if (d.smsOpts) {
|
||
document.getElementById('sms-discount').checked = !!d.smsOpts.discount;
|
||
document.getElementById('sms-june-promo').checked = !!d.smsOpts.junePromo;
|
||
document.getElementById('sms-ship-extra').checked = !!d.smsOpts.shipExtra;
|
||
document.getElementById('sms-ship-free').checked = !!d.smsOpts.shipFree;
|
||
if (d.smsOpts.pointDeduction) {
|
||
const ptInput = document.getElementById('sms-point-deduction');
|
||
if(ptInput) ptInput.value = parseInt(d.smsOpts.pointDeduction).toLocaleString();
|
||
} else {
|
||
const ptInput = document.getElementById('sms-point-deduction');
|
||
if(ptInput) ptInput.value = '';
|
||
}
|
||
}
|
||
|
||
if (d.previewText !== undefined) {
|
||
document.getElementById('sms-preview').innerHTML = d.previewText;
|
||
}
|
||
updateSelectedOrderItemsSummary();
|
||
|
||
|
||
});
|
||
}
|
||
|
||
window.deleteHistory = function (idx, e) {
|
||
e.stopPropagation();
|
||
if (confirm("이 기록을 삭제하시겠습니까?")) {
|
||
fetch(`/api/sms/history/${idx}`, { method: 'DELETE' }).then(() => loadSmsHistory());
|
||
}
|
||
}
|
||
|
||
document.getElementById('btn-sms-clear-all').addEventListener('click', () => {
|
||
if (confirm("정말로 모든 기록을 삭제하시겠습니까?")) {
|
||
fetch('/api/sms/history', { method: 'DELETE' }).then(() => loadSmsHistory());
|
||
}
|
||
|
||
|
||
});
|
||
|
||
// ============================================
|
||
// GIFT TAB
|
||
// ============================================
|
||
function buildGiftTab(data) {
|
||
const container = document.getElementById('gift-rules-container');
|
||
container.innerHTML = ''; // 중복 방지를 위해 기존 내용 완전히 비우기
|
||
const giftsRaw = data['ORDER_GIFT'] ? (data['ORDER_GIFT'].__order || Object.keys(data['ORDER_GIFT'])) : [];
|
||
const gifts = giftsRaw.filter(k => k !== '__order');
|
||
const rules = data['SMS_GIFT_RULES'] || {};
|
||
|
||
for (let i = 1; i <= 4; i++) {
|
||
const card = document.createElement('div');
|
||
card.className = 'glass-card gift-rule-card';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = `조건 ${i}`;
|
||
|
||
const minV = rules[`condition${i}_min`] || '';
|
||
const maxV = rules[`condition${i}_max`] || '';
|
||
const selGift = rules[`condition${i}_gift`] || '';
|
||
|
||
const fmt = (v) => {
|
||
if (!v) return '';
|
||
let n = parseInt(String(v).replace(/,/g, ''), 10);
|
||
return isNaN(n) ? '' : n.toLocaleString();
|
||
};
|
||
const inputs = document.createElement('div');
|
||
inputs.className = 'rule-inputs';
|
||
inputs.innerHTML = `
|
||
<input type="text" id="rule${i}-min" placeholder="최소금액" value="${fmt(minV)}">이상
|
||
<input type="text" id="rule${i}-max" placeholder="최대금액" value="${fmt(maxV)}">미만
|
||
<button class="btn btn-secondary btn-clear-rule" data-idx="${i}">조건 삭제</button>
|
||
`;
|
||
|
||
const radioList = document.createElement('div');
|
||
radioList.className = 'gift-radio-list';
|
||
|
||
// None option
|
||
radioList.innerHTML += `<label><input type="radio" name="rule${i}-gift" value="" ${!selGift ? 'checked' : ''}> 선택 없음</label>`;
|
||
|
||
gifts.forEach(g => {
|
||
radioList.innerHTML += `<label><input type="radio" name="rule${i}-gift" value="${g}" ${selGift === g ? 'checked' : ''}> ${g}</label>`;
|
||
|
||
|
||
});
|
||
|
||
card.appendChild(title);
|
||
card.appendChild(inputs);
|
||
card.appendChild(radioList);
|
||
container.appendChild(card);
|
||
}
|
||
|
||
document.querySelectorAll('.btn-clear-rule').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
const i = e.target.dataset.idx;
|
||
document.getElementById(`rule${i}-min`).value = '';
|
||
document.getElementById(`rule${i}-max`).value = '';
|
||
document.querySelector(`input[name="rule${i}-gift"][value=""]`).checked = true;
|
||
|
||
|
||
});
|
||
|
||
|
||
});
|
||
|
||
const defaultHeader = "안녕하세요, 미라네 주방입니다! 😊 \n주문내용 확인 부탁드립니다.";
|
||
const defaultFooter = "1. 아래 계좌로 입금해 주세요.\n\n▶입금 금액: {최종금액} \n▶입금 계좌 (농협): 317-0025-1542-51\n▶예금주: (주)더블엑스 코퍼레이션\n\n2. 입금 후 [성함/주소/연락처]를 남겨주세요.\n\n[배송 안내]\n■오후 1시 이전 입금 건은 당일 발송됩니다.\n★이벤트 상품 주문시 1~2일 정도 늦어질 수 있으니 너그러운 양해 부탁드립니다.";
|
||
|
||
let hText = rules['header_text'] ? rules['header_text'].replace(/\\n/g, '\n') : defaultHeader;
|
||
let fText = rules['footer_text'] ? rules['footer_text'].replace(/\\n/g, '\n') : defaultFooter;
|
||
|
||
document.getElementById('sms-header-text').value = hText;
|
||
document.getElementById('sms-footer-text').value = fText;
|
||
|
||
let appFont = data['APP_SETTINGS'] ? data['APP_SETTINGS']['font'] : '';
|
||
const fontSelect = document.getElementById('app-font-select');
|
||
if (fontSelect && appFont) {
|
||
fontSelect.value = appFont;
|
||
}
|
||
|
||
let baseShippingVal = data['APP_SETTINGS'] && data['APP_SETTINGS']['shipping_fee'] ? data['APP_SETTINGS']['shipping_fee'] : '3500';
|
||
let extraShippingVal = data['APP_SETTINGS'] && data['APP_SETTINGS']['extra_shipping_fee'] ? data['APP_SETTINGS']['extra_shipping_fee'] : '3000';
|
||
|
||
document.getElementById('setting-ship-base').value = parseInt(baseShippingVal).toLocaleString();
|
||
document.getElementById('setting-ship-extra').value = parseInt(extraShippingVal).toLocaleString();
|
||
|
||
const lblShipExtra = document.getElementById('lbl-ship-extra');
|
||
if (lblShipExtra) {
|
||
lblShipExtra.textContent = '추가배송비 ' + parseInt(extraShippingVal).toLocaleString() + '원';
|
||
}
|
||
|
||
const btnGiftSave = document.getElementById('btn-gift-save');
|
||
// Reset color in case it was red
|
||
btnGiftSave.style.backgroundColor = '';
|
||
|
||
const markUnsaved = () => {
|
||
btnGiftSave.style.backgroundColor = '#e53e3e';
|
||
};
|
||
|
||
// Format money fields on input
|
||
document.querySelectorAll('input[id^="rule"][id$="-min"], input[id^="rule"][id$="-max"], #setting-ship-base, #setting-ship-extra').forEach(el => {
|
||
el.addEventListener('input', e => {
|
||
let val = e.target.value.replace(/[^0-9]/g, '');
|
||
e.target.value = val ? parseInt(val, 10).toLocaleString() : '';
|
||
|
||
|
||
});
|
||
|
||
|
||
});
|
||
|
||
// Inputs within gift tab
|
||
document.querySelectorAll('#gift-tab input, #gift-tab textarea, #gift-tab select').forEach(el => {
|
||
el.removeEventListener('input', markUnsaved);
|
||
el.removeEventListener('change', markUnsaved);
|
||
el.addEventListener('input', markUnsaved);
|
||
el.addEventListener('change', markUnsaved);
|
||
|
||
|
||
});
|
||
|
||
document.querySelectorAll('.btn-clear-rule').forEach(btn => {
|
||
btn.addEventListener('click', markUnsaved);
|
||
|
||
|
||
});
|
||
}
|
||
|
||
const btnGiftSave = document.getElementById('btn-gift-save');
|
||
if (btnGiftSave) {
|
||
btnGiftSave.addEventListener('click', async () => {
|
||
const payload = {
|
||
rules: {},
|
||
app_settings: {
|
||
shipping_fee: document.getElementById('setting-ship-base').value.replace(/[^0-9]/g, ''),
|
||
extra_shipping_fee: document.getElementById('setting-ship-extra').value.replace(/[^0-9]/g, '')
|
||
}
|
||
};
|
||
for (let i = 1; i <= 4; i++) {
|
||
const minEl = document.getElementById(`rule${i}-min`);
|
||
const maxEl = document.getElementById(`rule${i}-max`);
|
||
const giftEl = document.querySelector(`input[name="rule${i}-gift"]:checked`);
|
||
if (minEl && maxEl && giftEl) {
|
||
payload.rules[`condition${i}_min`] = minEl.value.replace(/[^0-9]/g, '');
|
||
payload.rules[`condition${i}_max`] = maxEl.value.replace(/[^0-9]/g, '');
|
||
payload.rules[`condition${i}_gift`] = giftEl.value;
|
||
}
|
||
}
|
||
|
||
const hText = document.getElementById('sms-header-text');
|
||
if (hText) payload.rules['header_text'] = hText.value;
|
||
const fText = document.getElementById('sms-footer-text');
|
||
if (fText) payload.rules['footer_text'] = fText.value;
|
||
const fontSel = document.getElementById('app-font-select');
|
||
if (fontSel) payload.app_settings.font = fontSel.value;
|
||
|
||
try {
|
||
const res = await fetch('/api/gift/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (res.ok) {
|
||
alert('저장되었습니다.');
|
||
btnGiftSave.style.backgroundColor = '';
|
||
fetch('/api/config').then(r => r.json()).then(d => {
|
||
configData = d;
|
||
const oc = document.getElementById('order-items-container');
|
||
if (oc) oc.innerHTML = '';
|
||
buildOrderTab(configData);
|
||
buildGiftTab(configData);
|
||
buildProductTab(configData);
|
||
});
|
||
} else {
|
||
alert('저장 실패');
|
||
}
|
||
} catch (e) {
|
||
alert('오류 발생');
|
||
}
|
||
});
|
||
}
|
||
|
||
const formRec = document.getElementById('form-return-receive');
|
||
if (formRec) {
|
||
formRec.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const payload = {
|
||
receive_date: document.getElementById('rec-date').value,
|
||
tracking_no: document.getElementById('rec-tracking-no').value.trim(),
|
||
mall: document.getElementById('rec-mall').value,
|
||
receiver_name: document.getElementById('rec-name').value,
|
||
phone: document.getElementById('rec-phone').value,
|
||
address: document.getElementById('rec-address').value,
|
||
remarks: document.getElementById('rec-remarks').value,
|
||
receive_status: document.getElementById('rec-status').value
|
||
};
|
||
const recId = document.getElementById('rec-id');
|
||
if (recId && recId.value) {
|
||
payload.id = parseInt(recId.value, 10);
|
||
}
|
||
try {
|
||
const res = await fetch('/api/return/receive', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
let result = {};
|
||
try {
|
||
result = await res.json();
|
||
} catch(err) {}
|
||
|
||
if(res.ok && result.success !== false) {
|
||
formRec.reset();
|
||
document.getElementById('rec-date').value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
if(recId) recId.value = '';
|
||
if (typeof loadReturnReceivings === 'function') {
|
||
loadReturnReceivings();
|
||
}
|
||
if (typeof loadReturnRequests === 'function') {
|
||
loadReturnRequests();
|
||
}
|
||
} else if (result.error_type === 'duplicate') {
|
||
const dup = result.data || {};
|
||
alert(
|
||
`🚨 [안내] 이미 입고된 송장번호입니다.\n\n` +
|
||
`• 입고날짜: ${dup.receive_date || ''}\n` +
|
||
`• 이름: ${dup.receiver_name || ''}\n` +
|
||
`• 핸드폰 번호: ${dup.phone || ''}\n` +
|
||
`• 쇼핑몰: ${dup.mall || ''}\n` +
|
||
`• 입고상태: ${dup.receive_status || ''}\n` +
|
||
`• 비고: ${dup.remarks || ''}`
|
||
);
|
||
} else {
|
||
alert('등록 실패');
|
||
}
|
||
} catch(e) { alert('오류 발생'); }
|
||
});
|
||
}
|
||
|
||
// Bulk receive logic
|
||
const btnBulkReceive = document.getElementById('btn-bulk-receive');
|
||
const bulkReceiveModal = document.getElementById('bulk-receive-modal');
|
||
const btnBulkCancel = document.getElementById('btn-bulk-cancel');
|
||
const btnBulkSubmit = document.getElementById('btn-bulk-submit');
|
||
const bulkTrackingNumbers = document.getElementById('bulk-tracking-numbers');
|
||
const bulkTrackingLineNumbers = document.getElementById('bulk-tracking-line-numbers');
|
||
const bulkPrefixInput = document.getElementById('bulk-prefix-input');
|
||
const btnBulkPrefixSave = document.getElementById('btn-bulk-prefix-save');
|
||
const BULK_TRACKING_PREFIX_SETTING = 'bulk_tracking_prefix';
|
||
|
||
function getBulkTrackingPrefix() {
|
||
if (!bulkPrefixInput) return '';
|
||
const prefix = bulkPrefixInput.value.replace(/\D/g, '').slice(0, 4);
|
||
bulkPrefixInput.value = prefix;
|
||
return prefix;
|
||
}
|
||
|
||
function loadBulkTrackingPrefix() {
|
||
if (!bulkPrefixInput) return;
|
||
bulkPrefixInput.value = (configData.APP_SETTINGS && configData.APP_SETTINGS[BULK_TRACKING_PREFIX_SETTING]) || '';
|
||
if (btnBulkPrefixSave) btnBulkPrefixSave.style.backgroundColor = '#4a5568';
|
||
}
|
||
|
||
async function saveBulkTrackingPrefix({ silent = false } = {}) {
|
||
const prefix = getBulkTrackingPrefix();
|
||
const res = await fetch('/api/app/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ [BULK_TRACKING_PREFIX_SETTING]: prefix })
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Failed to save bulk tracking prefix');
|
||
if (!configData.APP_SETTINGS) configData.APP_SETTINGS = {};
|
||
configData.APP_SETTINGS[BULK_TRACKING_PREFIX_SETTING] = prefix;
|
||
if (btnBulkPrefixSave) btnBulkPrefixSave.style.backgroundColor = '#4a5568';
|
||
if (!silent) alert('저장되었습니다.');
|
||
return prefix;
|
||
}
|
||
|
||
loadBulkTrackingPrefix();
|
||
|
||
function updateBulkTrackingLineNumbers() {
|
||
if (!bulkTrackingNumbers || !bulkTrackingLineNumbers) return;
|
||
const lineCount = bulkTrackingNumbers.value.split(/\r\n|\r|\n/).length;
|
||
bulkTrackingLineNumbers.textContent = Array.from(
|
||
{ length: lineCount },
|
||
(_, index) => index + 1
|
||
).join('\n');
|
||
bulkTrackingLineNumbers.scrollTop = bulkTrackingNumbers.scrollTop;
|
||
}
|
||
|
||
if (btnBulkReceive && bulkReceiveModal) {
|
||
btnBulkReceive.addEventListener('click', () => {
|
||
bulkReceiveModal.style.display = 'flex';
|
||
loadBulkTrackingPrefix();
|
||
updateBulkTrackingLineNumbers();
|
||
});
|
||
|
||
if (btnBulkPrefixSave && bulkPrefixInput) {
|
||
bulkPrefixInput.addEventListener('input', () => {
|
||
getBulkTrackingPrefix();
|
||
btnBulkPrefixSave.style.backgroundColor = '#e53e3e';
|
||
});
|
||
|
||
btnBulkPrefixSave.addEventListener('click', async () => {
|
||
try {
|
||
await saveBulkTrackingPrefix();
|
||
} catch (err) {
|
||
console.error('Bulk prefix save failed:', err);
|
||
alert('송장 앞 숫자 저장에 실패했습니다.');
|
||
}
|
||
});
|
||
}
|
||
|
||
btnBulkCancel.addEventListener('click', () => {
|
||
bulkReceiveModal.style.display = 'none';
|
||
bulkTrackingNumbers.value = '';
|
||
updateBulkTrackingLineNumbers();
|
||
});
|
||
|
||
if (bulkTrackingNumbers) {
|
||
bulkTrackingNumbers.addEventListener('input', updateBulkTrackingLineNumbers);
|
||
bulkTrackingNumbers.addEventListener('scroll', () => {
|
||
if (bulkTrackingLineNumbers) {
|
||
bulkTrackingLineNumbers.scrollTop = bulkTrackingNumbers.scrollTop;
|
||
}
|
||
});
|
||
bulkTrackingNumbers.addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (btnBulkSubmit) btnBulkSubmit.click();
|
||
}
|
||
});
|
||
}
|
||
|
||
btnBulkSubmit.addEventListener('click', async () => {
|
||
const lines = bulkTrackingNumbers.value.split('\n');
|
||
let savedPrefix = getBulkTrackingPrefix();
|
||
try {
|
||
savedPrefix = await saveBulkTrackingPrefix({ silent: true });
|
||
} catch (err) {
|
||
console.error('Bulk prefix save failed:', err);
|
||
alert('송장 앞 숫자 저장에 실패했습니다.');
|
||
return;
|
||
}
|
||
|
||
const trackingNumbers = lines.map(l => {
|
||
let val = l.trim();
|
||
if (val && savedPrefix && val.length === 8) {
|
||
return savedPrefix + val;
|
||
}
|
||
return val;
|
||
}).filter(l => l);
|
||
|
||
if (trackingNumbers.length === 0) {
|
||
alert('송장번호를 입력해주세요.');
|
||
return;
|
||
}
|
||
|
||
btnBulkSubmit.disabled = true;
|
||
btnBulkSubmit.textContent = '처리 중...';
|
||
|
||
try {
|
||
const res = await fetch('/api/return/receive/bulk', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ tracking_numbers: trackingNumbers })
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
bulkReceiveModal.style.display = 'none';
|
||
bulkTrackingNumbers.value = '';
|
||
updateBulkTrackingLineNumbers();
|
||
if (typeof loadReturnReceivings === 'function') {
|
||
loadReturnReceivings();
|
||
}
|
||
if (typeof loadReturnRequests === 'function') {
|
||
loadReturnRequests();
|
||
}
|
||
} else {
|
||
alert('일괄 등록 실패');
|
||
}
|
||
} catch (e) {
|
||
alert('오류 발생');
|
||
} finally {
|
||
btnBulkSubmit.disabled = false;
|
||
btnBulkSubmit.textContent = '일괄 등록';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Load Lists
|
||
async function loadReturnRequests() {
|
||
const tbody = document.getElementById('tbody-return-requests');
|
||
if(!tbody) return;
|
||
try {
|
||
const res = await fetch('/api/return/request');
|
||
const recRes = await fetch('/api/return/receive');
|
||
if(res.ok && recRes.ok) {
|
||
const data = await res.json();
|
||
const recData = await recRes.json();
|
||
const receivedTrackingNos = new Set((recData.data || []).map(r => r.tracking_no).filter(t => t));
|
||
|
||
tbody.innerHTML = '';
|
||
if(data.data) {
|
||
|
||
const excludeReceived = document.getElementById('chk-exclude-received')?.checked;
|
||
let displayData = data.data;
|
||
if (excludeReceived) {
|
||
displayData = displayData.filter(i => !(i.tracking_no && receivedTrackingNos.has(i.tracking_no)));
|
||
}
|
||
displayData.forEach(item => {
|
||
const isReceived = item.tracking_no && receivedTrackingNos.has(item.tracking_no);
|
||
const tr = document.createElement('tr');
|
||
tr.style.cursor = 'pointer';
|
||
let displayRemarks = item.remarks || '';
|
||
if (isReceived) {
|
||
tr.style.textDecoration = 'line-through';
|
||
tr.style.color = '#a0aec0';
|
||
displayRemarks += ' <span style="color: #e53e3e; font-size: 0.7rem; font-weight: bold;">[입고완료]</span>';
|
||
}
|
||
tr.addEventListener('click', () => {
|
||
document.getElementById('req-date').value = item.request_date || '';
|
||
document.getElementById('req-type').value = item.request_type || '';
|
||
document.getElementById('req-name').value = item.receiver_name || '';
|
||
document.getElementById('req-address').value = item.address || '';
|
||
document.getElementById('req-phone').value = item.phone || '';
|
||
document.getElementById('req-tracking-no').value = item.tracking_no || '';
|
||
document.getElementById('req-mall').value = item.mall || '';
|
||
document.getElementById('req-remarks').value = item.remarks || '';
|
||
const reqId = document.getElementById('req-id');
|
||
if(reqId) reqId.value = item.id;
|
||
});
|
||
const todayStr = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
if (item.request_date && !item.request_date.startsWith(todayStr)) {
|
||
tr.style.backgroundColor = '#cbd5e0';
|
||
}
|
||
const rowDataJson = JSON.stringify(item).replace(/'/g, "'").replace(/"/g, """);
|
||
tr.innerHTML = `
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; width: 30px;" onclick="event.stopPropagation();">
|
||
<input type="checkbox" class="chk-req-row" data-item="${rowDataJson}" ${isReceived ? 'disabled' : ''}>
|
||
</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; white-space: nowrap; width: 100px;">${formatShortDate(item.request_date)}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; cursor: pointer; white-space: nowrap; width: 90px;" onclick="navigator.clipboard.writeText(this.innerText); showToast('이름이 복사되었습니다.');" title="클릭하여 복사">${item.receiver_name}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: left; cursor: pointer; white-space: nowrap; width: 130px;" onclick="navigator.clipboard.writeText(this.innerText); showToast('연락처가 복사되었습니다.');" title="클릭하여 복사">${item.phone}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: left; cursor: pointer; white-space: nowrap; width: 110px;" onclick="navigator.clipboard.writeText(this.innerText); showToast('송장번호가 복사되었습니다.');" title="클릭하여 복사">${item.tracking_no}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; white-space: nowrap; width: 65px;">${item.request_type}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: left; width: 215px;">${displayRemarks}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; width: 40px;" onclick="event.stopPropagation();">
|
||
<button class="btn-delete-req" data-id="${item.id}" style="background:none; border:none; color:red; cursor:pointer;">✕</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
|
||
const chkAll = document.getElementById('chk-all-req');
|
||
if (chkAll) {
|
||
chkAll.checked = false;
|
||
chkAll.onchange = (e) => {
|
||
document.querySelectorAll('.chk-req-row').forEach(cb => cb.checked = e.target.checked);
|
||
};
|
||
}
|
||
|
||
document.querySelectorAll('.btn-delete-req').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
if(confirm('정말로 삭제하시겠습니까?')) {
|
||
try {
|
||
const delRes = await fetch(`/api/return/request/${e.target.dataset.id}`, {method: 'DELETE'});
|
||
if(delRes.ok) {
|
||
if (typeof loadReturnRequests === 'function') loadReturnRequests();
|
||
if (typeof loadReturnReceivings === 'function') loadReturnReceivings();
|
||
}
|
||
} catch(err) { alert('삭제 실패'); }
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function loadReturnReceivings() {
|
||
const tbody = document.getElementById('tbody-return-receivings');
|
||
if(!tbody) return;
|
||
try {
|
||
const res = await fetch('/api/return/receive');
|
||
if(res.ok) {
|
||
const data = await res.json();
|
||
tbody.innerHTML = '';
|
||
if(data.data) {
|
||
|
||
const excludeCompleted = document.getElementById('chk-exclude-completed')?.checked;
|
||
let displayData = data.data;
|
||
if (excludeCompleted) {
|
||
displayData = displayData.filter(i => i.receive_status !== '환불완료' && i.receive_status !== '교환완료');
|
||
}
|
||
displayData.forEach(item => {
|
||
const tr = document.createElement('tr');
|
||
tr.style.cursor = 'pointer';
|
||
tr.addEventListener('click', () => {
|
||
document.getElementById('rec-date').value = item.receive_date || '';
|
||
document.getElementById('rec-tracking-no').value = item.tracking_no || '';
|
||
document.getElementById('rec-mall').value = item.mall || '';
|
||
document.getElementById('rec-name').value = item.receiver_name || '';
|
||
document.getElementById('rec-phone').value = item.phone || '';
|
||
document.getElementById('rec-address').value = item.address || '';
|
||
document.getElementById('rec-remarks').value = item.remarks || '';
|
||
const recStatus = document.getElementById('rec-status');
|
||
if (recStatus) recStatus.value = item.receive_status || '환불 대기';
|
||
const recId = document.getElementById('rec-id');
|
||
if (recId) recId.value = item.id;
|
||
});
|
||
const todayStr = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
if (item.receive_date && !item.receive_date.startsWith(todayStr)) {
|
||
tr.style.backgroundColor = '#cbd5e0';
|
||
}
|
||
const isCompleted = (item.receive_status === '환불완료' || item.receive_status === '교환완료');
|
||
const statusStyle = isCompleted ? 'color: #e53e3e; font-weight: bold;' : '';
|
||
tr.innerHTML = `
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; white-space: nowrap; width: 100px;">${formatShortDate(item.receive_date)}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; cursor: pointer; white-space: nowrap; width: 90px;" onclick="navigator.clipboard.writeText(this.innerText); showToast('이름이 복사되었습니다.');" title="클릭하여 복사">${item.receiver_name}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: left; cursor: pointer; white-space: nowrap; width: 130px;" onclick="navigator.clipboard.writeText(this.innerText); showToast('연락처가 복사되었습니다.');" title="클릭하여 복사">${item.phone}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: left; cursor: pointer; white-space: nowrap; width: 110px;" onclick="navigator.clipboard.writeText(this.innerText); showToast('송장번호가 복사되었습니다.');" title="클릭하여 복사">${item.tracking_no}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; white-space: nowrap; width: 85px;">${item.mall || ''}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; white-space: nowrap; width: 65px; ${statusStyle}">${item.receive_status}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: left; width: 160px;">${(item.remarks || '').replace(/맞교환/g, `<span style=\"color: red;\">맞교환</span>`)}</td>
|
||
<td style="padding: 8px; border-bottom: 1px solid #e2e8f0; text-align: center; width: 40px;" onclick="event.stopPropagation();">
|
||
<button class="btn-delete-rec" data-id="${item.id}" style="background:none; border:none; color:red; cursor:pointer;">✕</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
document.querySelectorAll('.btn-delete-rec').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
if(confirm('정말로 삭제하시겠습니까?')) {
|
||
try {
|
||
const delRes = await fetch(`/api/return/receive/${e.target.dataset.id}`, {method: 'DELETE'});
|
||
if(delRes.ok) {
|
||
if (typeof loadReturnReceivings === 'function') loadReturnReceivings();
|
||
if (typeof loadReturnRequests === 'function') loadReturnRequests();
|
||
}
|
||
} catch(err) { alert('삭제 실패'); }
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
// Initial load
|
||
const returnTabMenu = document.querySelector('[data-tab="return-tab"]');
|
||
|
||
const chkExcludeCompleted = document.getElementById('chk-exclude-completed');
|
||
if (chkExcludeCompleted) {
|
||
chkExcludeCompleted.addEventListener('change', () => {
|
||
loadReturnReceivings();
|
||
});
|
||
}
|
||
|
||
const chkExcludeReceived = document.getElementById('chk-exclude-received');
|
||
if (chkExcludeReceived) {
|
||
chkExcludeReceived.addEventListener('change', () => {
|
||
loadReturnRequests();
|
||
});
|
||
}
|
||
|
||
if(returnTabMenu) {
|
||
returnTabMenu.addEventListener('click', () => {
|
||
loadReturnRequests();
|
||
loadReturnReceivings();
|
||
});
|
||
|
||
// Load immediately on page load
|
||
setTimeout(() => {
|
||
loadReturnRequests();
|
||
loadReturnReceivings();
|
||
}, 500);
|
||
}
|
||
const reqTrackingNo = document.getElementById('req-tracking-no');
|
||
const recTrackingNo = document.getElementById('rec-tracking-no');
|
||
|
||
// Auto-paste 12-digit tracking number on click
|
||
async function handleTrackingClick(e) {
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
if (!text) return;
|
||
const cleanText = text.replace(/[-\s]/g, '');
|
||
// Check if exactly 12 digits as requested
|
||
if (/^\d{12}$/.test(cleanText)) {
|
||
e.target.value = cleanText;
|
||
e.target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||
}
|
||
} catch(err) {
|
||
console.log('Clipboard read failed: ', err);
|
||
}
|
||
}
|
||
|
||
if (reqTrackingNo) reqTrackingNo.addEventListener('click', handleTrackingClick);
|
||
if (recTrackingNo) recTrackingNo.addEventListener('click', handleTrackingClick);
|
||
|
||
|
||
// Default dates to today
|
||
const reqDate = document.getElementById('req-date');
|
||
if (reqDate) reqDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
const recDate = document.getElementById('rec-date');
|
||
if (recDate) recDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
|
||
// Search on Return Request tab
|
||
if(reqTrackingNo) {
|
||
reqTrackingNo.addEventListener('keydown', async (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const no = reqTrackingNo.value.trim();
|
||
if(!no) return;
|
||
try {
|
||
const res = await fetch('/api/return/lookup?tracking_no=' + encodeURIComponent(no));
|
||
if(res.ok) {
|
||
const data = await res.json();
|
||
if(data.success && data.data) {
|
||
document.getElementById('req-mall').value = data.data.vendor || '';
|
||
document.getElementById('req-name').value = data.data.recipient_name || '';
|
||
document.getElementById('req-phone').value = data.data.recipient_phone || data.data.recipient_mobile || '';
|
||
document.getElementById('req-address').value = data.data.address || '';
|
||
}
|
||
}
|
||
} catch(e) { console.error('Lookup failed', e); }
|
||
}
|
||
});
|
||
}
|
||
|
||
// Search on Return Receive tab
|
||
if(recTrackingNo) {
|
||
recTrackingNo.addEventListener('keydown', async (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const no = recTrackingNo.value.trim();
|
||
if(!no) return;
|
||
try {
|
||
// First try to find in return requests
|
||
const res1 = await fetch('/api/return/request?tracking_no=' + encodeURIComponent(no));
|
||
let foundInReq = false;
|
||
if(res1.ok) {
|
||
const data1 = await res1.json();
|
||
if(data1.success && data1.data && data1.data.length > 0) {
|
||
const item = data1.data[0];
|
||
document.getElementById('rec-mall').value = item.mall || '';
|
||
document.getElementById('rec-name').value = item.receiver_name || '';
|
||
document.getElementById('rec-phone').value = item.phone || '';
|
||
const reqType = item.request_type || '';
|
||
const reqRemarks = item.remarks || '';
|
||
const autoRemark = reqRemarks ? `${reqType}/${reqRemarks}` : reqType;
|
||
document.getElementById('rec-remarks').value = autoRemark;
|
||
if (item.address) document.getElementById('rec-address').value = item.address;
|
||
foundInReq = true;
|
||
}
|
||
}
|
||
|
||
// If not found in requests, fallback to orderlist_app
|
||
if(!foundInReq) {
|
||
const res2 = await fetch('/api/return/lookup?tracking_no=' + encodeURIComponent(no));
|
||
if(res2.ok) {
|
||
const data2 = await res2.json();
|
||
if(data2.success && data2.data) {
|
||
document.getElementById('rec-mall').value = data2.data.vendor || '';
|
||
document.getElementById('rec-name').value = data2.data.recipient_name || '';
|
||
document.getElementById('rec-phone').value = data2.data.recipient_phone || data2.data.recipient_mobile || '';
|
||
document.getElementById('rec-address').value = data2.data.address || '';
|
||
}
|
||
}
|
||
}
|
||
} catch(e) { console.error('Receive lookup failed', e); }
|
||
}
|
||
});
|
||
}
|
||
|
||
// Submit Return Request
|
||
const formReq = document.getElementById('form-return-request');
|
||
if(formReq) {
|
||
formReq.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const payload = {
|
||
request_date: document.getElementById('req-date').value,
|
||
request_type: document.getElementById('req-type').value,
|
||
receiver_name: document.getElementById('req-name').value,
|
||
address: document.getElementById('req-address').value,
|
||
phone: document.getElementById('req-phone').value,
|
||
tracking_no: document.getElementById('req-tracking-no').value,
|
||
mall: document.getElementById('req-mall').value,
|
||
remarks: document.getElementById('req-remarks').value
|
||
};
|
||
const reqId = document.getElementById('req-id');
|
||
if (reqId && reqId.value) {
|
||
payload.id = parseInt(reqId.value, 10);
|
||
}
|
||
try {
|
||
const res = await fetch('/api/return/request', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if(res.ok) {
|
||
formReq.reset();
|
||
document.getElementById('req-date').value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
if(reqId) reqId.value = '';
|
||
if (typeof loadReturnRequests === 'function') {
|
||
loadReturnRequests();
|
||
}
|
||
if (typeof loadReturnReceivings === 'function') {
|
||
loadReturnReceivings();
|
||
}
|
||
} else {
|
||
alert('등록 실패');
|
||
}
|
||
} catch(e) { alert('오류 발생'); }
|
||
});
|
||
}
|
||
// ============================================
|
||
// PRODUCT SETTINGS TAB
|
||
// ============================================
|
||
window.productRulesGroupData = {};
|
||
window.productRulesListData = {};
|
||
|
||
function buildProductTab(data) {
|
||
const container = document.getElementById('product-groups-container');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
|
||
const groupNames = data['GROUP_NAMES'] || {};
|
||
window.productRulesGroupData = {};
|
||
window.productRulesListData = {};
|
||
|
||
let groupIdCounter = 0;
|
||
|
||
|
||
const order_group = groupNames.__order || Object.keys(groupNames).filter(k => k !== '__order');
|
||
for (const gKey of order_group) {
|
||
if (gKey === '__order') continue;
|
||
const gName = groupNames[gKey];
|
||
|
||
groupIdCounter++;
|
||
const gId = `pgroup-${groupIdCounter}`;
|
||
window.productRulesGroupData[gId] = { old_code: gKey, new_code: gKey, name: gName };
|
||
|
||
const pList = data[gKey] || {};
|
||
window.productRulesListData[gId] = [];
|
||
|
||
const order_3 = pList.__order || Object.keys(pList).filter(k => k !== '__order');
|
||
for (const pKey of order_3) {
|
||
if (pKey === '__order') continue;
|
||
const pVal = pList[pKey];
|
||
|
||
if (pKey.startsWith('---separator_')) {
|
||
window.productRulesListData[gId].push({ is_separator: true });
|
||
} else {
|
||
let is_free = pKey.startsWith('*');
|
||
let cleanKey = is_free ? pKey.substring(1) : pKey;
|
||
|
||
let color = '';
|
||
if (cleanKey.endsWith('_O')) { color = '_O'; cleanKey = cleanKey.slice(0, -2); }
|
||
else if (cleanKey.endsWith('_R')) { color = '_R'; cleanKey = cleanKey.slice(0, -2); }
|
||
else if (cleanKey.endsWith('_B')) { color = '_B'; cleanKey = cleanKey.slice(0, -2); }
|
||
else if (cleanKey.endsWith('_G')) { color = '_G'; cleanKey = cleanKey.slice(0, -2); }
|
||
else if (cleanKey.endsWith('_P')) { color = '_P'; cleanKey = cleanKey.slice(0, -2); }
|
||
|
||
let label = cleanKey;
|
||
let parts = pVal.split('|').map(s => s.trim());
|
||
let code = parts[0] || '';
|
||
let name = parts[1] || '';
|
||
let price = parts.length > 2 ? parseInt(parts[2], 10) : 0;
|
||
let dprice = parts.length > 3 ? parseInt(parts[3], 10) : 0;
|
||
|
||
window.productRulesListData[gId].push({
|
||
is_separator: false,
|
||
code, name, label, price, dprice, color, is_free
|
||
});
|
||
}
|
||
}
|
||
|
||
renderProductGroup(gId, container);
|
||
}
|
||
|
||
const btnProductSave = document.getElementById('btn-product-save');
|
||
if (btnProductSave) btnProductSave.style.backgroundColor = '';
|
||
|
||
const markProductUnsaved = () => {
|
||
if (btnProductSave) btnProductSave.style.backgroundColor = '#e53e3e';
|
||
};
|
||
|
||
container.addEventListener('input', markProductUnsaved);
|
||
container.addEventListener('change', markProductUnsaved);
|
||
container.addEventListener('click', (e) => {
|
||
if (e.target.tagName === 'BUTTON') markProductUnsaved();
|
||
});
|
||
}
|
||
|
||
function renderProductGroup(gId, parentContainer) {
|
||
let card = document.getElementById(gId);
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = gId;
|
||
card.className = 'glass-card';
|
||
card.style.padding = '10px 15px';
|
||
card.style.marginBottom = '10px';
|
||
card.style.width = 'max-content'; // 박스 전체 폭을 콘텐츠에 맞게 축소
|
||
parentContainer.appendChild(card);
|
||
}
|
||
|
||
const gData = window.productRulesGroupData[gId];
|
||
const items = window.productRulesListData[gId];
|
||
const fmt = (v) => v ? parseInt(String(v).replace(/,/g, ''), 10).toLocaleString() : '';
|
||
|
||
// 엑셀 시트처럼 보이도록 <table>+colgroup을 사용합니다. 헤더(thead)와
|
||
// 본문(tbody)이 같은 표(table)에 속하므로 table-layout:fixed 컬럼 폭이
|
||
// 브라우저에 의해 강제로 100% 동일하게 맞춰집니다. table-layout:fixed는
|
||
// width가 auto이면 컨테이너 크기에 따라 폭 계산이 애매해질 수 있어,
|
||
// colgroup 합계와 정확히 같은 고정 width를 표에 명시적으로 지정합니다.
|
||
const COL_WIDTHS = [34, 170, 90, 210, 80, 80, 350, 85, 55];
|
||
const TABLE_WIDTH = COL_WIDTHS.reduce((a, b) => a + b, 0);
|
||
const CELL_BORDER = 'border-right: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;';
|
||
const ROW_BOTTOM = 'border-bottom: 1px solid #e2e8f0;';
|
||
const HEADER_TH = 'box-sizing: border-box; text-align: center; vertical-align: middle; padding: 8px 4px; font-size: 0.82rem; font-weight: 700;';
|
||
const TD_BASE = 'box-sizing: border-box; vertical-align: middle; height: 30px;';
|
||
const colgroup = COL_WIDTHS.map(w => `<col style="width: ${w}px;">`).join('');
|
||
|
||
let html = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #3182ce; padding-bottom: 5px; margin-bottom: 10px;">
|
||
<div style="display: flex; gap: 10px; align-items: center;">
|
||
<span style="font-size: 1.5rem; font-weight: 800; color: #2c5282; margin: 0; padding-bottom: 4px;">${gData.name}</span>
|
||
</div>
|
||
</div>
|
||
<div style="border: 1px solid #cbd5e0; border-radius: 4px; overflow: hidden; width: ${TABLE_WIDTH}px;">
|
||
<table style="border-collapse: collapse; table-layout: fixed; width: ${TABLE_WIDTH}px;">
|
||
<colgroup>${colgroup}</colgroup>
|
||
<thead>
|
||
<tr style="background: #edf2f7; border-bottom: 2px solid #cbd5e0; color: #2d3748;">
|
||
<th style="${HEADER_TH} ${CELL_BORDER}"></th>
|
||
<th style="${HEADER_TH} ${CELL_BORDER}">표기명</th>
|
||
<th style="${HEADER_TH} ${CELL_BORDER}">코드</th>
|
||
<th style="${HEADER_TH} ${CELL_BORDER}">정식옵션명</th>
|
||
<th style="${HEADER_TH} ${CELL_BORDER}">가격</th>
|
||
<th style="${HEADER_TH} ${CELL_BORDER}">2+1가격</th>
|
||
<th style="${HEADER_TH} ${CELL_BORDER}">색상 옵션</th>
|
||
<th style="${HEADER_TH} ${CELL_BORDER}">무료배송</th>
|
||
<th style="${HEADER_TH} ${ROW_BOTTOM}">관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="product-items-list">
|
||
`;
|
||
|
||
items.forEach((item, idx) => {
|
||
if (item.is_separator) {
|
||
html += `
|
||
<tr class="pr-item-row" data-idx="${idx}" style="background: #edf2f7;">
|
||
<td style="${TD_BASE} text-align: center; color: #718096; font-size: 1.05rem; cursor: grab; ${CELL_BORDER}"><span class="drag-handle">☰</span></td>
|
||
<td colspan="7" style="${TD_BASE} padding: 0 8px; color: #4a5568; font-size: 0.75rem; font-weight: bold; ${CELL_BORDER}">--- 구분선 ---</td>
|
||
<td style="${TD_BASE} text-align: center; ${ROW_BOTTOM}">
|
||
<button class="btn btn-danger btn-sm p-del" data-idx="${idx}" title="삭제" style="padding: 1px 4px; font-size: 0.7rem;">❌</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
} else {
|
||
html += `
|
||
<tr class="pr-item-row" data-idx="${idx}" style="background: #fff;">
|
||
<td style="${TD_BASE} text-align: center; color: #a0aec0; font-size: 1rem; cursor: grab; ${CELL_BORDER}"><span class="drag-handle">☰</span></td>
|
||
<td style="${TD_BASE} padding: 0; ${CELL_BORDER}">
|
||
<input type="text" class="p-label" value="${item.label}" placeholder="표기명(예: 오리)" autocomplete="off" style="color-scheme: light; color: #1a202c; -webkit-text-fill-color: #1a202c; opacity: 1; border: none; background: transparent; height: 30px; width: 100%; box-sizing: border-box; padding: 0 6px; font-size: 0.75rem;">
|
||
</td>
|
||
<td style="${TD_BASE} padding: 0; ${CELL_BORDER}">
|
||
<input type="text" class="p-code" value="${item.code}" placeholder="코드" autocomplete="off" style="color-scheme: light; color: #1a202c; -webkit-text-fill-color: #1a202c; opacity: 1; border: none; background: transparent; height: 30px; width: 100%; box-sizing: border-box; padding: 0 6px; font-size: 0.75rem;">
|
||
</td>
|
||
<td style="${TD_BASE} padding: 0; ${CELL_BORDER}">
|
||
<input type="text" class="p-name" value="${item.name}" placeholder="정식옵션명" autocomplete="off" style="color-scheme: light; color: #1a202c; -webkit-text-fill-color: #1a202c; opacity: 1; border: none; background: transparent; height: 30px; width: 100%; box-sizing: border-box; padding: 0 6px; font-size: 0.75rem;">
|
||
</td>
|
||
<td style="${TD_BASE} padding: 0; ${CELL_BORDER}">
|
||
<input type="text" class="p-price" value="${fmt(item.price)}" placeholder="가격" autocomplete="off" style="color-scheme: light; color: #1a202c; -webkit-text-fill-color: #1a202c; opacity: 1; border: none; background: transparent; height: 30px; width: 100%; box-sizing: border-box; padding: 0 6px; font-size: 0.75rem; text-align: right;">
|
||
</td>
|
||
<td style="${TD_BASE} padding: 0; ${CELL_BORDER}">
|
||
<input type="text" class="p-dprice" value="${fmt(item.dprice)}" placeholder="2+1가격" autocomplete="off" style="color-scheme: light; color: #1a202c; -webkit-text-fill-color: #1a202c; opacity: 1; border: none; background: transparent; height: 30px; width: 100%; box-sizing: border-box; padding: 0 6px; font-size: 0.75rem; text-align: right;">
|
||
</td>
|
||
<td style="${TD_BASE} ${CELL_BORDER}">
|
||
<div class="color-options" style="display: flex; gap: 6px; align-items: center; justify-content: center; font-size: 0.68rem; overflow: hidden;">
|
||
<label style="cursor:pointer; white-space:nowrap;"><input type="radio" name="color-${gId}-${idx}" value="" ${item.color === '' ? 'checked' : ''}> 기본</label>
|
||
<label style="color:#dd6b20; cursor:pointer; white-space:nowrap;"><input type="radio" name="color-${gId}-${idx}" value="_O" ${item.color === '_O' ? 'checked' : ''}> 주황</label>
|
||
<label style="color:#e53e3e; cursor:pointer; white-space:nowrap;"><input type="radio" name="color-${gId}-${idx}" value="_R" ${item.color === '_R' ? 'checked' : ''}> 빨강</label>
|
||
<label style="color:#3182ce; cursor:pointer; white-space:nowrap;"><input type="radio" name="color-${gId}-${idx}" value="_B" ${item.color === '_B' ? 'checked' : ''}> 파랑</label>
|
||
<label style="color:#38a169; cursor:pointer; white-space:nowrap;"><input type="radio" name="color-${gId}-${idx}" value="_G" ${item.color === '_G' ? 'checked' : ''}> 초록</label>
|
||
<label style="color:#d53f8c; cursor:pointer; white-space:nowrap;"><input type="radio" name="color-${gId}-${idx}" value="_P" ${item.color === '_P' ? 'checked' : ''}> 핑크</label>
|
||
</div>
|
||
</td>
|
||
<td style="${TD_BASE} text-align: center; ${CELL_BORDER}">
|
||
<label title="무료배송" style="display: inline-flex; align-items: center; justify-content: center; font-size: 0.85rem; cursor: pointer;">
|
||
<input type="checkbox" class="p-free" ${item.is_free ? 'checked' : ''}> 🚚
|
||
</label>
|
||
</td>
|
||
<td style="${TD_BASE} text-align: center; ${ROW_BOTTOM}">
|
||
<button class="btn btn-danger btn-sm p-del" data-idx="${idx}" title="삭제" style="padding: 1px 4px; font-size: 0.7rem;">❌</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
});
|
||
|
||
html += `
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; margin-top: 8px;">
|
||
<button class="btn btn-primary btn-sm btn-add-item" style="font-size: 0.75rem;">+ 상품 추가</button>
|
||
<button class="btn btn-secondary btn-sm btn-add-sep" style="font-size: 0.75rem;">+ 구분선 추가</button>
|
||
</div>
|
||
`;
|
||
|
||
card.innerHTML = html;
|
||
|
||
const rows = card.querySelectorAll('.product-items-list > .pr-item-row');
|
||
rows.forEach((row, i) => {
|
||
if (!items[i].is_separator) {
|
||
row.querySelector('.p-label').addEventListener('input', e => items[i].label = e.target.value);
|
||
row.querySelector('.p-code').addEventListener('input', e => items[i].code = e.target.value);
|
||
row.querySelector('.p-name').addEventListener('input', e => items[i].name = e.target.value);
|
||
|
||
['.p-price', '.p-dprice'].forEach(sel => {
|
||
row.querySelector(sel).addEventListener('input', e => {
|
||
let val = e.target.value.replace(/[^0-9]/g, '');
|
||
if (sel === '.p-price') items[i].price = parseInt(val, 10) || 0;
|
||
else items[i].dprice = parseInt(val, 10) || 0;
|
||
e.target.value = val ? parseInt(val, 10).toLocaleString() : '';
|
||
});
|
||
});
|
||
|
||
row.querySelectorAll(`input[name="color-${gId}-${i}"]`).forEach(radio => {
|
||
radio.addEventListener('change', e => { if (e.target.checked) items[i].color = e.target.value; });
|
||
});
|
||
|
||
row.querySelector('.p-free').addEventListener('change', e => items[i].is_free = e.target.checked);
|
||
}
|
||
});
|
||
|
||
// SortableJS for bulletproof drag & drop
|
||
new Sortable(card.querySelector('.product-items-list'), {
|
||
handle: '.drag-handle',
|
||
animation: 150,
|
||
onEnd: function (evt) {
|
||
if (evt.oldIndex !== evt.newIndex) {
|
||
const movedItem = items.splice(evt.oldIndex, 1)[0];
|
||
items.splice(evt.newIndex, 0, movedItem);
|
||
const btnSave = document.getElementById('btn-product-save');
|
||
if (btnSave) btnSave.style.backgroundColor = '#e53e3e';
|
||
// Optional: renderProductGroup() if we need to sync data-idx, but items array is already updated in sync with DOM!
|
||
// It's safer to re-render to attach listeners based on new indices though:
|
||
renderProductGroup(gId, parentContainer);
|
||
}
|
||
}
|
||
});
|
||
|
||
card.querySelectorAll('.p-del').forEach(b => {
|
||
b.addEventListener('click', e => {
|
||
const parent = document.getElementById('product-groups-container');
|
||
const y = parent ? parent.scrollTop : 0;
|
||
const i = parseInt(e.target.dataset.idx, 10);
|
||
items.splice(i, 1);
|
||
renderProductGroup(gId, parentContainer);
|
||
if (parent) requestAnimationFrame(() => parent.scrollTo(0, y));
|
||
});
|
||
});
|
||
card.querySelector('.btn-add-item').addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const parent = document.getElementById('product-groups-container');
|
||
const y = parent ? parent.scrollTop : 0;
|
||
items.push({ is_separator: false, code: '', name: '', label: '', price: 0, dprice: 0, color: '', is_free: false });
|
||
renderProductGroup(gId, parentContainer);
|
||
if (parent) requestAnimationFrame(() => parent.scrollTo(0, y));
|
||
});
|
||
card.querySelector('.btn-add-sep').addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const parent = document.getElementById('product-groups-container');
|
||
const y = parent ? parent.scrollTop : 0;
|
||
items.push({ is_separator: true });
|
||
renderProductGroup(gId, parentContainer);
|
||
if (parent) requestAnimationFrame(() => parent.scrollTo(0, y));
|
||
});
|
||
}
|
||
|
||
const _btnProdSave = document.getElementById('btn-product-save');
|
||
if (_btnProdSave) {
|
||
_btnProdSave.addEventListener('click', () => {
|
||
const payload = { groups: [], products: {} };
|
||
// window.productRulesGroupData
|
||
for (const gId in window.productRulesGroupData) {
|
||
const gData = window.productRulesGroupData[gId];
|
||
if (gData.new_code && gData.name) {
|
||
payload.groups.push(gData);
|
||
payload.products[gData.new_code] = window.productRulesListData[gId];
|
||
}
|
||
}
|
||
|
||
document.getElementById('btn-product-save').innerText = '저장 중...';
|
||
|
||
fetch('/api/products/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
}).then(r => {
|
||
if (!r.ok) throw new Error("Network response was not ok (status " + r.status + ")");
|
||
return r.json();
|
||
}).then(res => {
|
||
alert("상품 설정이 저장되었습니다.");
|
||
document.getElementById('btn-product-save').innerText = '상품 설정 저장';
|
||
fetch('/api/config').then(r => r.json()).then(d => {
|
||
configData = d;
|
||
document.getElementById('btn-product-save').style.backgroundColor = '';
|
||
|
||
// Clear all UI and rebuild
|
||
const oc = document.getElementById('order-items-container');
|
||
if (oc) oc.innerHTML = '';
|
||
|
||
buildOrderTab(configData);
|
||
buildGiftTab(configData);
|
||
buildProductTab(configData);
|
||
});
|
||
}).catch(err => {
|
||
console.error(err);
|
||
alert("저장 실패!");
|
||
document.getElementById('btn-product-save').innerText = '상품 설정 저장';
|
||
});
|
||
});
|
||
}
|
||
|
||
const btnCopyManual = document.getElementById('btn-copy-manual-total');
|
||
if (btnCopyManual) {
|
||
btnCopyManual.addEventListener('click', () => {
|
||
const count = document.getElementById('manual-total-count').textContent;
|
||
const amount = document.getElementById('manual-total-amount').textContent;
|
||
const textToCopy = `수기 주문: ${count}건 / ${amount}원`;
|
||
|
||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||
const toast = document.getElementById('toast-notification');
|
||
if (toast) {
|
||
toast.style.opacity = '1';
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
}, 1500);
|
||
}
|
||
}).catch(err => {
|
||
console.error('Failed to copy', err);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================
|
||
// CODE TAB (Master Data Mgmt)
|
||
// ============================================
|
||
function initCodeTab() {
|
||
const btnAddSin = document.getElementById('btn-add-single');
|
||
const btnAddSet = document.getElementById('btn-add-set');
|
||
if (!btnAddSin) return;
|
||
|
||
let editingSingleCode = null;
|
||
let editingSetCode = null;
|
||
let singleItemsList = [];
|
||
let setItemsList = [];
|
||
let singleSort = { key: 'item_code', asc: true };
|
||
let setSort = { key: 'item_code', asc: true };
|
||
let singleSearch = '';
|
||
let setSearch = '';
|
||
|
||
// 검색어(공백 무시, 대소문자 무시)로 이름을 필터링
|
||
const normalizeKeyword = (str) => (str || '').toString().toLowerCase().replace(/\s+/g, '');
|
||
const matchKeyword = (name, keyword) => !keyword || normalizeKeyword(name).includes(keyword);
|
||
const emptyRowHtml = (colspan) => `<tr><td colspan="${colspan}" style="padding: 15px 10px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</td></tr>`;
|
||
|
||
// Render Single Items
|
||
const renderSingleItems = () => {
|
||
const tbody = document.getElementById('single-items-tbody');
|
||
tbody.innerHTML = '';
|
||
|
||
singleItemsList.sort((a,b) => {
|
||
let valA = a[singleSort.key] || '';
|
||
let valB = b[singleSort.key] || '';
|
||
if(valA < valB) return singleSort.asc ? -1 : 1;
|
||
if(valA > valB) return singleSort.asc ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
const visibleSingleItems = singleItemsList.filter(item => matchKeyword(item.name, singleSearch));
|
||
if (visibleSingleItems.length === 0) tbody.innerHTML = emptyRowHtml(4);
|
||
|
||
visibleSingleItems.forEach(item => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'hover-row';
|
||
tr.style.height = '27px';
|
||
tr.style.borderBottom = '1px solid #e2e8f0';
|
||
tr.innerHTML = `
|
||
<td style="padding: 0 10px; font-size: 0.85rem;">${item.item_code}</td>
|
||
<td style="padding: 0 5px; font-size: 0.75rem; color: #718096;">${item.sabangnet_code}</td>
|
||
<td style="padding: 0 10px; font-size: 0.85rem;">${item.name}</td>
|
||
<td style="padding: 0 5px; text-align: right; vertical-align: middle;">
|
||
<div style="display: flex; gap: 8px; justify-content: flex-end; align-items: center; height: 100%;">
|
||
<button class="btn btn-sm btn-edit-sin" style="padding:2px; display:flex; align-items:center; background:transparent; color:#4a5568; box-shadow:none; border:none; cursor:pointer;" data-code="${item.item_code}" title="수정">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||
</button>
|
||
<button class="btn btn-sm btn-del-sin" style="padding:2px; display:flex; align-items:center; background:transparent; color:#e53e3e; box-shadow:none; border:none; cursor:pointer;" data-code="${item.item_code}" title="삭제">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
document.querySelectorAll('.th-sortable[data-list="single"]').forEach(th => {
|
||
const icon = th.querySelector('.sort-icon');
|
||
if(!icon) return;
|
||
if(th.dataset.key === singleSort.key) {
|
||
icon.innerText = singleSort.asc ? '▲' : '▼';
|
||
icon.style.color = '#3182ce';
|
||
} else {
|
||
icon.innerText = '↕';
|
||
icon.style.color = '#e2e8f0';
|
||
}
|
||
});
|
||
};
|
||
|
||
// Load data
|
||
const loadSingleItems = () => {
|
||
return fetch('/api/codes/single').then(r=>r.json()).then(res => {
|
||
singleItemsList = res.data;
|
||
renderSingleItems();
|
||
});
|
||
};
|
||
const renderSetItems = () => {
|
||
const tbody = document.getElementById('set-items-tbody');
|
||
tbody.innerHTML = '';
|
||
|
||
let tooltip = document.getElementById('set-comp-tooltip');
|
||
if (!tooltip) {
|
||
tooltip = document.createElement('div');
|
||
tooltip.id = 'set-comp-tooltip';
|
||
tooltip.style.position = 'fixed';
|
||
tooltip.style.background = '#fff';
|
||
tooltip.style.border = '1px solid #cbd5e0';
|
||
tooltip.style.padding = '8px';
|
||
tooltip.style.borderRadius = '5px';
|
||
tooltip.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
|
||
tooltip.style.display = 'none';
|
||
tooltip.style.zIndex = '9999';
|
||
tooltip.style.pointerEvents = 'none';
|
||
tooltip.style.flexDirection = 'column';
|
||
tooltip.style.gap = '2px';
|
||
document.body.appendChild(tooltip);
|
||
}
|
||
|
||
const mapCompInfo = c => {
|
||
const sInfo = singleItemsList.find(s => s.item_code === c.single_code);
|
||
let displayName = sInfo ? sInfo.name : c.single_code;
|
||
displayName = displayName.replace(/미라클 통 /g, '').trim();
|
||
const isHwangto = displayName.includes('황토');
|
||
const bg = isHwangto ? 'background:#feebc8;' : 'background:#edf2f7;';
|
||
const border = isHwangto ? 'border:1px solid #fbd38d;' : 'border:1px solid #cbd5e0;';
|
||
const qtyColor = isHwangto ? '#dd6b20' : '#3182ce';
|
||
const textColor = isHwangto ? 'color:#c05621;' : '';
|
||
return `<span class="comp-span" style="display:inline-block; ${bg} ${border} padding:1px 4px; border-radius:3px; font-size:0.75rem; margin:1px; ${textColor}">${displayName} <b style="color:${qtyColor};">${c.quantity}</b></span>`;
|
||
};
|
||
|
||
setItemsList.sort((a,b) => {
|
||
let valA = a[setSort.key] || '';
|
||
let valB = b[setSort.key] || '';
|
||
if(valA < valB) return setSort.asc ? -1 : 1;
|
||
if(valA > valB) return setSort.asc ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
const visibleSetItems = setItemsList.filter(item => matchKeyword(item.name, setSearch));
|
||
if (visibleSetItems.length === 0) tbody.innerHTML = emptyRowHtml(4);
|
||
|
||
visibleSetItems.forEach(item => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'hover-row';
|
||
tr.style.height = '27px';
|
||
tr.style.borderBottom = '1px solid #e2e8f0';
|
||
|
||
const compsHtml = item.components.map(mapCompInfo).join('');
|
||
|
||
tr.innerHTML = `
|
||
<td style="padding: 0 10px; font-weight:bold; font-size: 0.85rem;">${item.item_code}</td>
|
||
<td style="padding: 0 10px; font-size: 0.85rem; white-space: nowrap;">${item.name || ''}</td>
|
||
<td class="comps-cell" style="padding: 4px 10px; line-height: 1.2; width: 100%; max-width: 1px;">
|
||
<div class="comps-wrapper" style="white-space: nowrap; overflow: hidden; position: relative; padding-right: 48px;">
|
||
${compsHtml}
|
||
<span class="more-indicator" style="display:none; color:#e53e3e; font-weight:bold; font-size:0.8rem; cursor:default; position: absolute; right: 0; background: transparent; padding-left: 2px;"></span>
|
||
</div>
|
||
</td>
|
||
<td style="padding: 0 5px; text-align: right; vertical-align: middle;">
|
||
<div style="display: flex; gap: 8px; justify-content: flex-end; align-items: center; height: 100%;">
|
||
<button class="btn btn-sm btn-edit-set" style="padding:2px; display:flex; align-items:center; background:transparent; color:#4a5568; box-shadow:none; border:none; cursor:pointer;" data-code="${item.item_code}" title="수정">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||
</button>
|
||
<button class="btn btn-sm btn-del-set" style="padding:2px; display:flex; align-items:center; background:transparent; color:#e53e3e; box-shadow:none; border:none; cursor:pointer;" data-code="${item.item_code}" title="삭제">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
`;
|
||
tr._itemComps = item.components;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
// 모든 행이 그려진 후 브라우저가 그리드를 계산할 여유를 주기 위해 지연실행
|
||
setTimeout(() => {
|
||
const rows = tbody.querySelectorAll('tr');
|
||
rows.forEach(tr => {
|
||
const wrapper = tr.querySelector('.comps-wrapper');
|
||
if (!wrapper) return;
|
||
const spans = wrapper.querySelectorAll('.comp-span');
|
||
const indicator = wrapper.querySelector('.more-indicator');
|
||
|
||
if (spans.length > 0) {
|
||
let hiddenCount = 0;
|
||
let lastVisibleIndex = spans.length - 1;
|
||
|
||
// X좌표를 기준으로 컨테이너를 벗어나는 아이템 탐색
|
||
const maxLimit = wrapper.offsetWidth - 48; // 오른쪽 48px는 '외 N개' 표기를 위해 예약
|
||
for (let i = 0; i < spans.length; i++) {
|
||
if (spans[i].offsetLeft + spans[i].offsetWidth > maxLimit) {
|
||
lastVisibleIndex = i - 1;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (lastVisibleIndex < spans.length - 1 && lastVisibleIndex >= 0) {
|
||
hiddenCount = spans.length - 1 - lastVisibleIndex;
|
||
|
||
for (let i = lastVisibleIndex + 1; i < spans.length; i++) {
|
||
spans[i].style.display = 'none';
|
||
}
|
||
|
||
indicator.innerText = `외 ${hiddenCount}개`;
|
||
indicator.style.display = 'inline-block';
|
||
} else if (lastVisibleIndex < 0 && spans.length > 1) {
|
||
// 매우 좁은 공간일 경우 적어도 1개는 숨기고 처리
|
||
hiddenCount = spans.length - 1;
|
||
for (let i = 1; i < spans.length; i++) {
|
||
spans[i].style.display = 'none';
|
||
}
|
||
indicator.innerText = `외 ${hiddenCount}개`;
|
||
indicator.style.display = 'inline-block';
|
||
}
|
||
|
||
if (hiddenCount > 0 && tr._itemComps) {
|
||
const compsCell = tr.querySelector('.comps-cell');
|
||
compsCell.addEventListener('mouseenter', (e) => {
|
||
compsCell._hoverTimeout = setTimeout(() => {
|
||
tooltip.innerHTML = tr._itemComps.map(mapCompInfo).map(html => `<div>${html}</div>`).join('');
|
||
tooltip.style.display = 'flex';
|
||
tooltip.style.left = (e.clientX + 40) + 'px';
|
||
tooltip.style.top = (e.clientY - 10) + 'px';
|
||
}, 1000);
|
||
});
|
||
compsCell.addEventListener('mousemove', (e) => {
|
||
if (tooltip.style.display === 'flex') {
|
||
tooltip.style.left = (e.clientX + 40) + 'px';
|
||
tooltip.style.top = (e.clientY - 10) + 'px';
|
||
}
|
||
});
|
||
compsCell.addEventListener('mouseleave', () => {
|
||
clearTimeout(compsCell._hoverTimeout);
|
||
tooltip.style.display = 'none';
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}, 50);
|
||
|
||
document.querySelectorAll('.th-sortable[data-list="set"]').forEach(th => {
|
||
const icon = th.querySelector('.sort-icon');
|
||
if(!icon) return;
|
||
if(th.dataset.key === setSort.key) {
|
||
icon.innerText = setSort.asc ? '▲' : '▼';
|
||
icon.style.color = '#3182ce';
|
||
} else {
|
||
icon.innerText = '↕';
|
||
icon.style.color = '#e2e8f0';
|
||
}
|
||
});
|
||
};
|
||
|
||
const loadSetItems = () => {
|
||
return fetch('/api/codes/set').then(r=>r.json()).then(res => {
|
||
setItemsList = res.data;
|
||
renderSetItems();
|
||
});
|
||
};
|
||
|
||
const loadSingleItemsAndSetItems = () => {
|
||
loadSingleItems().then(() => loadSetItems());
|
||
};
|
||
|
||
window.reRenderSetItemsForWrap = () => {
|
||
if (setItemsList && setItemsList.length > 0) renderSetItems();
|
||
};
|
||
|
||
window.addEventListener('resize', () => {
|
||
if(document.getElementById('code-tab').classList.contains('active')) {
|
||
clearTimeout(window._setItemsResizeTimer);
|
||
window._setItemsResizeTimer = setTimeout(() => {
|
||
if (setItemsList && setItemsList.length > 0) renderSetItems();
|
||
}, 100);
|
||
}
|
||
});
|
||
|
||
loadSingleItemsAndSetItems();
|
||
|
||
// 실시간 이름 검색 (단품 / 세트)
|
||
const bindSearchBox = (inputId, clearBtnId, onChange) => {
|
||
const input = document.getElementById(inputId);
|
||
const clearBtn = document.getElementById(clearBtnId);
|
||
if (!input) return;
|
||
input.addEventListener('input', () => onChange(normalizeKeyword(input.value)));
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
input.value = '';
|
||
onChange('');
|
||
}
|
||
});
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', () => {
|
||
input.value = '';
|
||
onChange('');
|
||
input.focus();
|
||
});
|
||
}
|
||
};
|
||
|
||
bindSearchBox('single-search-input', 'btn-single-search-clear', (kw) => {
|
||
singleSearch = kw;
|
||
renderSingleItems();
|
||
});
|
||
bindSearchBox('set-search-input', 'btn-set-search-clear', (kw) => {
|
||
setSearch = kw;
|
||
renderSetItems();
|
||
});
|
||
|
||
// Sorting events
|
||
document.querySelectorAll('.th-sortable').forEach(th => {
|
||
th.addEventListener('click', (e) => {
|
||
const listType = th.dataset.list;
|
||
const key = th.dataset.key;
|
||
if(listType === 'single') {
|
||
if(singleSort.key === key) singleSort.asc = !singleSort.asc;
|
||
else { singleSort.key = key; singleSort.asc = true; }
|
||
renderSingleItems();
|
||
} else if(listType === 'set') {
|
||
if(setSort.key === key) setSort.asc = !setSort.asc;
|
||
else { setSort.key = key; setSort.asc = true; }
|
||
renderSetItems();
|
||
}
|
||
});
|
||
});
|
||
|
||
document.getElementById('code-tab').addEventListener('click', (e) => {
|
||
if(e.target.parentElement && e.target.parentElement.classList.contains('btn-del-sin')) return; // ignore if click is inside not button
|
||
const btnDelSin = e.target.closest('.btn-del-sin');
|
||
if(btnDelSin) {
|
||
if(confirm('단품 항목을 삭제하시겠습니까? (연결된 세트 구성품도 삭제될 수 있습니다)')) {
|
||
fetch('/api/codes/single/' + btnDelSin.dataset.code, {method: 'DELETE'}).then(()=> loadSingleItemsAndSetItems());
|
||
}
|
||
return;
|
||
}
|
||
|
||
const btnDelSet = e.target.closest('.btn-del-set');
|
||
if(btnDelSet) {
|
||
if(confirm('세트 항목을 삭제하시겠습니까?')) {
|
||
fetch('/api/codes/set/' + btnDelSet.dataset.code, {method: 'DELETE'}).then(()=> { setItemsList = []; loadSetItems(); });
|
||
}
|
||
return;
|
||
}
|
||
|
||
const btnEditSin = e.target.closest('.btn-edit-sin');
|
||
if(btnEditSin) {
|
||
const code = btnEditSin.dataset.code;
|
||
fetch('/api/codes/single').then(r=>r.json()).then(res => {
|
||
const item = res.data.find(d => d.item_code === code);
|
||
if(item) {
|
||
editingSingleCode = code;
|
||
document.getElementById('modal-single-title').innerText = '📦 단품 수정';
|
||
document.getElementById('sin-item-code').value = item.item_code;
|
||
document.getElementById('sin-item-code').disabled = true;
|
||
document.getElementById('sin-sabangnet-code').value = item.sabangnet_code;
|
||
document.getElementById('sin-name').value = item.name;
|
||
document.getElementById('single-item-modal').style.display = 'flex';
|
||
}
|
||
});
|
||
}
|
||
const btnEditSet = e.target.closest('.btn-edit-set');
|
||
if(btnEditSet) {
|
||
const code = btnEditSet.dataset.code;
|
||
fetch('/api/codes/set').then(r=>r.json()).then(res => {
|
||
const item = res.data.find(d => d.item_code === code);
|
||
if(item) {
|
||
editingSetCode = code;
|
||
document.getElementById('modal-set-title').innerText = '🍱 세트 수정';
|
||
document.getElementById('set-item-code').value = item.item_code;
|
||
document.getElementById('set-item-code').disabled = true;
|
||
document.getElementById('set-name').value = item.name;
|
||
const wrapper = document.getElementById('set-components-wrapper');
|
||
wrapper.innerHTML = '';
|
||
item.components.forEach(c => addSetComponentRow(c.single_code, c.quantity));
|
||
document.getElementById('set-item-modal').style.display = 'flex';
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Single Modal
|
||
const sinItemCodeInput = document.getElementById('sin-item-code');
|
||
const sinItemWarn = document.getElementById('sin-item-warn');
|
||
|
||
sinItemCodeInput.addEventListener('input', (e) => {
|
||
let val = e.target.value.toUpperCase();
|
||
e.target.value = val;
|
||
if (val.length > 0 && !/^[A-Z]/.test(val)) {
|
||
sinItemWarn.textContent = '⚠️ 아이템 코드 첫 글자는 영문이어야 합니다.';
|
||
sinItemWarn.style.display = 'block';
|
||
} else if(!editingSingleCode && val && singleItemsList.some(s => s.item_code === val)) {
|
||
sinItemWarn.textContent = '⚠️ 이미 존재하는 단품 코드입니다!';
|
||
sinItemWarn.style.display = 'block';
|
||
} else {
|
||
sinItemWarn.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
btnAddSin.addEventListener('click', () => {
|
||
editingSingleCode = null;
|
||
document.getElementById('modal-single-title').innerText = '📦 새 단품 추가';
|
||
sinItemCodeInput.value = '';
|
||
sinItemCodeInput.disabled = false;
|
||
if(sinItemWarn) sinItemWarn.style.display = 'none';
|
||
document.getElementById('sin-sabangnet-code').value = '';
|
||
document.getElementById('sin-name').value = '';
|
||
document.getElementById('single-item-modal').style.display = 'flex';
|
||
});
|
||
document.getElementById('btn-sin-cancel').addEventListener('click', () => {
|
||
document.getElementById('single-item-modal').style.display = 'none';
|
||
});
|
||
document.getElementById('btn-sin-save').addEventListener('click', () => {
|
||
const itemCode = document.getElementById('sin-item-code').value.trim();
|
||
let sabangnet = document.getElementById('sin-sabangnet-code').value.trim();
|
||
const name = document.getElementById('sin-name').value.trim();
|
||
if(!itemCode || !sabangnet || !name) { alert('모든 항목을 입력해주세요.'); return; }
|
||
|
||
if (itemCode.length > 0 && !/^[A-Z]/.test(itemCode)) {
|
||
alert('아이템 코드의 첫 글자는 반드시 영문이어야 합니다!');
|
||
return;
|
||
}
|
||
|
||
if(!editingSingleCode && singleItemsList.some(s => s.item_code === itemCode)) {
|
||
alert('이미 존재하는 단품 아이템 코드입니다!');
|
||
return;
|
||
}
|
||
if(!sabangnet.endsWith('-0001')) sabangnet += '-0001';
|
||
|
||
const payload = {item_code: itemCode, sabangnet_code: sabangnet, name: name};
|
||
const method = editingSingleCode ? 'PUT' : 'POST';
|
||
const url = editingSingleCode ? '/api/codes/single/' + encodeURIComponent(editingSingleCode) : '/api/codes/single';
|
||
|
||
fetch(url, {
|
||
method: method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||
}).then(r => r.json()).then(res => {
|
||
if(res.detail) { alert(res.detail); }
|
||
else {
|
||
document.getElementById('single-item-modal').style.display = 'none';
|
||
loadSingleItems();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Single sabangnet auto append -0001 on blur
|
||
document.getElementById('sin-sabangnet-code').addEventListener('blur', (e) => {
|
||
let val = e.target.value.trim();
|
||
if(val && !val.endsWith('-0001') && !val.includes('-')) {
|
||
e.target.value = val + '-0001';
|
||
}
|
||
});
|
||
|
||
// Set Modal
|
||
const setItemCodeInput = document.getElementById('set-item-code');
|
||
const setItemWarn = document.getElementById('set-item-warn');
|
||
|
||
setItemCodeInput.addEventListener('input', (e) => {
|
||
let val = e.target.value.toUpperCase();
|
||
e.target.value = val;
|
||
if (val.length > 0 && !/^[A-Z]/.test(val)) {
|
||
setItemWarn.textContent = '⚠️ 아이템 코드 첫 글자는 영문이어야 합니다.';
|
||
setItemWarn.style.display = 'block';
|
||
} else if(!editingSetCode && val && setItemsList.some(s => s.item_code === val)) {
|
||
setItemWarn.textContent = '⚠️ 이미 존재하는 세트 코드입니다!';
|
||
setItemWarn.style.display = 'block';
|
||
} else {
|
||
setItemWarn.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
const addSetComponentRow = (sc = '', qt = 1) => {
|
||
const div = document.createElement('div');
|
||
div.style.display = 'flex';
|
||
div.style.gap = '5px';
|
||
div.style.alignItems = 'center';
|
||
|
||
let optionsHtml = '<option value="">단품코드 선택</option>';
|
||
singleItemsList.forEach(s => {
|
||
const selected = (s.item_code === sc) ? 'selected' : '';
|
||
optionsHtml += `<option value="${s.item_code}" ${selected}>[${s.item_code}] ${s.name}</option>`;
|
||
});
|
||
|
||
div.innerHTML = `
|
||
<div class="drag-handle" style="cursor: grab; display: flex; align-items: center; justify-content: center; padding: 4px; color: #a0aec0; user-select: none;">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:block; pointer-events: none;"><circle cx="9" cy="12" r="1.5"></circle><circle cx="9" cy="5" r="1.5"></circle><circle cx="9" cy="19" r="1.5"></circle><circle cx="15" cy="12" r="1.5"></circle><circle cx="15" cy="5" r="1.5"></circle><circle cx="15" cy="19" r="1.5"></circle></svg>
|
||
</div>
|
||
<select class="comp-code" style="flex:1; padding: 4px; border: 1px solid #cbd5e0; border-radius: 4px;">
|
||
${optionsHtml}
|
||
</select>
|
||
<input type="number" class="comp-qty" min="1" value="${qt}" style="width:60px; padding: 4px; border: 1px solid #cbd5e0; border-radius: 4px;">
|
||
<button class="btn btn-sm comp-del" style="background:#e53e3e; padding:4px 8px;" title="삭제">X</button>
|
||
`;
|
||
div.querySelector('.comp-del').addEventListener('click', () => div.remove());
|
||
document.getElementById('set-components-wrapper').appendChild(div);
|
||
};
|
||
|
||
btnAddSet.addEventListener('click', () => {
|
||
editingSetCode = null;
|
||
document.getElementById('modal-set-title').innerText = '🍱 새 세트 추가';
|
||
setItemCodeInput.value = '';
|
||
setItemCodeInput.disabled = false;
|
||
if(setItemWarn) setItemWarn.style.display = 'none';
|
||
document.getElementById('set-name').value = '';
|
||
document.getElementById('set-components-wrapper').innerHTML = '';
|
||
addSetComponentRow();
|
||
document.getElementById('set-item-modal').style.display = 'flex';
|
||
});
|
||
document.getElementById('btn-add-set-component').addEventListener('click', () => {
|
||
addSetComponentRow();
|
||
});
|
||
document.getElementById('btn-set-cancel').addEventListener('click', () => {
|
||
document.getElementById('set-item-modal').style.display = 'none';
|
||
});
|
||
document.getElementById('btn-set-save').addEventListener('click', () => {
|
||
const itemCode = document.getElementById('set-item-code').value.trim();
|
||
const name = document.getElementById('set-name').value.trim();
|
||
if(!itemCode) { alert('세트 아이템 코드를 입력해주세요.'); return; }
|
||
|
||
if (itemCode.length > 0 && !/^[A-Z]/.test(itemCode)) {
|
||
alert('아이템 코드의 첫 글자는 반드시 영문이어야 합니다!');
|
||
return;
|
||
}
|
||
|
||
if(!editingSetCode && setItemsList.some(s => s.item_code === itemCode)) {
|
||
alert('이미 존재하는 세트 아이템 코드입니다!');
|
||
return;
|
||
}
|
||
|
||
const components = [];
|
||
const rows = document.getElementById('set-components-wrapper').querySelectorAll(':scope > div');
|
||
rows.forEach(r => {
|
||
const compCodeEl = r.querySelector('.comp-code');
|
||
const compQtyEl = r.querySelector('.comp-qty');
|
||
if (compCodeEl && compQtyEl) {
|
||
const sc = compCodeEl.value.trim();
|
||
const qt = parseInt(compQtyEl.value, 10);
|
||
if(sc && !isNaN(qt) && qt > 0) {
|
||
components.push({single_code: sc, quantity: qt});
|
||
}
|
||
}
|
||
});
|
||
|
||
if(components.length === 0) { alert('유효한 단품 구성품을 최소 1개 이상 선택해주세요.'); return; }
|
||
|
||
const payload = {item_code: itemCode, components: components, sabangnet_code: "", name: name};
|
||
const method = editingSetCode ? 'PUT' : 'POST';
|
||
const url = editingSetCode ? '/api/codes/set/' + encodeURIComponent(editingSetCode) : '/api/codes/set';
|
||
|
||
fetch(url, {
|
||
method: method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||
}).then(r => r.json()).then(res => {
|
||
if(res.detail) { alert(res.detail); }
|
||
else {
|
||
document.getElementById('set-item-modal').style.display = 'none';
|
||
loadSetItems();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Initialize SortableJS on set components wrapper
|
||
const setCompWrapper = document.getElementById('set-components-wrapper');
|
||
if (setCompWrapper && typeof Sortable !== 'undefined') {
|
||
new Sortable(setCompWrapper, {
|
||
handle: '.drag-handle',
|
||
animation: 150,
|
||
ghostClass: 'sortable-ghost',
|
||
chosenClass: 'sortable-chosen'
|
||
});
|
||
}
|
||
}
|
||
|
||
initCodeTab();
|
||
});
|
||
|
||
const btnEmailReq = document.getElementById('btn-email-req');
|
||
if (btnEmailReq) {
|
||
btnEmailReq.addEventListener('click', async () => {
|
||
const checkedBoxes = document.querySelectorAll('.chk-req-row:checked');
|
||
if (checkedBoxes.length === 0) {
|
||
alert('이메일로 보낼 반품 신청 항목을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const d = new Date();
|
||
const yyyy = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const dd = String(d.getDate()).padStart(2, '0');
|
||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||
const dow = days[d.getDay()];
|
||
const subject = `★ ${yyyy}년 ${m}월 ${dd}일(${dow}) 미라네주방 반품 신청`;
|
||
|
||
let plainTextGreeting = "안녕하세요.\r\n오늘 반품 신청합니다.\r\n내용은 아래와 같습니다.\r\n감사합니다.\r\n\r\n";
|
||
let ptIndex = 1;
|
||
checkedBoxes.forEach(cb => {
|
||
try {
|
||
const item = JSON.parse(cb.dataset.item);
|
||
let ptStatus = item.request_type || '';
|
||
if (ptStatus === '맞교환') {
|
||
ptStatus = '★맞교환★';
|
||
}
|
||
plainTextGreeting += `${ptIndex}. 이름: ${item.receiver_name || ''} / 연락처: ${item.phone || ''} / 송장번호: ${item.tracking_no || ''} / 상태: ${ptStatus}
|
||
|
||
`;
|
||
ptIndex++;
|
||
} catch(e) {}
|
||
});
|
||
|
||
|
||
let htmlTable = `
|
||
<table border="1" style="border-collapse: collapse; font-family: '맑은 고딕', sans-serif; font-size: 10pt; text-align: center;">
|
||
<thead>
|
||
<tr style="background-color: #f2f2f2;">
|
||
<th style="padding: 8px;">번호</th>
|
||
<th style="padding: 8px;">이름</th>
|
||
<th style="padding: 8px;">전화번호</th>
|
||
<th style="padding: 8px;">송장번호</th>
|
||
<th style="padding: 8px;">상태</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
let index = 1;
|
||
checkedBoxes.forEach(cb => {
|
||
try {
|
||
const item = JSON.parse(cb.dataset.item);
|
||
let statusText = item.request_type || '';
|
||
let trStyle = '';
|
||
if (statusText === '맞교환') {
|
||
trStyle = ' style="font-weight: bold; color: red;"';
|
||
}
|
||
htmlTable += `
|
||
<tr${trStyle}>
|
||
<td style="padding: 8px;">${index++}</td>
|
||
<td style="padding: 8px;">${item.receiver_name || ''}</td>
|
||
<td style="padding: 8px; mso-number-format:'\@';">${item.phone || ''}</td>
|
||
<td style="padding: 8px; mso-number-format:'\@';">${item.tracking_no || ''}</td>
|
||
<td style="padding: 8px;">${statusText}</td>
|
||
</tr>
|
||
`;
|
||
} catch(e) {}
|
||
});
|
||
htmlTable += `</tbody></table>`;
|
||
|
||
try {
|
||
const blobHtml = new Blob([htmlTable], { type: 'text/html' });
|
||
const blobText = new Blob(["표가 클립보드에 복사되었습니다. 메일 본문에 붙여넣기 하세요."], { type: 'text/plain' });
|
||
const data = new ClipboardItem({
|
||
'text/html': blobHtml,
|
||
'text/plain': blobText
|
||
});
|
||
navigator.clipboard.write([data]).then(() => {
|
||
showToast("표가 클립보드에 복사되었습니다! 아웃룩 본문에 '붙여넣기(Ctrl+V)' 하세요.");
|
||
setTimeout(() => {
|
||
const mailtoLink = `mailto:skylove6969@hanmail.net?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(plainTextGreeting)}`;
|
||
window.location.href = mailtoLink;
|
||
}, 1000);
|
||
}).catch(err => {
|
||
alert('클립보드 복사에 실패했습니다.');
|
||
const mailtoLink = `mailto:skylove6969@hanmail.net?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(plainTextGreeting)}`;
|
||
window.location.href = mailtoLink;
|
||
});
|
||
} catch(e) {
|
||
const mailtoLink = `mailto:skylove6969@hanmail.net?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(plainTextGreeting)}`;
|
||
window.location.href = mailtoLink;
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// Clear form when clicking empty space around inputs
|
||
function handleFormEmptyClick(e, formId) {
|
||
// If the click is on an input, select, button, label, or textarea, do nothing
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'LABEL' || e.target.tagName === 'TEXTAREA') return;
|
||
if (e.target.closest('button')) return;
|
||
|
||
// Otherwise it's an empty space click
|
||
const form = document.getElementById(formId);
|
||
if (form) {
|
||
form.reset();
|
||
// Reset hidden IDs and dates
|
||
if (formId === 'form-return-request') {
|
||
const reqId = document.getElementById('req-id');
|
||
if (reqId) reqId.value = '';
|
||
const reqDate = document.getElementById('req-date');
|
||
if (reqDate) reqDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
} else if (formId === 'form-return-receive') {
|
||
const recId = document.getElementById('rec-id');
|
||
if (recId) recId.value = '';
|
||
const recDate = document.getElementById('rec-date');
|
||
if (recDate) recDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
const reqFormContainer = document.getElementById('form-return-request');
|
||
if (reqFormContainer) {
|
||
reqFormContainer.addEventListener('click', (e) => handleFormEmptyClick(e, 'form-return-request'));
|
||
}
|
||
|
||
const recFormContainer = document.getElementById('form-return-receive');
|
||
if (recFormContainer) {
|
||
recFormContainer.addEventListener('click', (e) => handleFormEmptyClick(e, 'form-return-receive'));
|
||
}
|
||
|
||
// ============================================
|
||
// Version Update Notice
|
||
// ============================================
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
let CURRENT_APP_VERSION = "9.0";
|
||
const versionEl = document.getElementById('app-version-text');
|
||
if (versionEl) {
|
||
const text = versionEl.textContent.trim();
|
||
const match = text.match(/Ver\s+([0-9.]+)/i);
|
||
if (match) {
|
||
CURRENT_APP_VERSION = match[1];
|
||
}
|
||
}
|
||
const updateModal = document.getElementById('update-notice-modal');
|
||
if (updateModal) {
|
||
document.getElementById('update-notice-version').textContent = CURRENT_APP_VERSION;
|
||
const storageKey = `hide_update_popup_${CURRENT_APP_VERSION}`;
|
||
|
||
if (localStorage.getItem(storageKey) !== 'true') {
|
||
updateModal.style.display = 'flex';
|
||
}
|
||
|
||
document.getElementById('btn-close-update-notice').addEventListener('click', () => {
|
||
if (document.getElementById('chk-hide-update-notice').checked) {
|
||
localStorage.setItem(storageKey, 'true');
|
||
}
|
||
updateModal.style.display = 'none';
|
||
|
||
// Switch to Version History tab
|
||
const historyTab = document.querySelector('[data-tab="version-history-tab"]');
|
||
if (historyTab) {
|
||
historyTab.click();
|
||
}
|
||
});
|
||
}
|
||
});
|