Files
dbx-corm/static/js/app.js
T
king 065e81bf97 feat: v9.0 업그레이드 — CS 작업 탭 개편 + 밀크런/자사몰 행사 모듈
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>
2026-08-31 23:56:17 +09:00

4223 lines
207 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 서브경로 호스팅 — 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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, "&apos;").replace(/"/g, "&quot;");
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();
}
});
}
});