@@ -2300,7 +3361,7 @@ document.addEventListener('DOMContentLoaded', () => {
card.innerHTML = html;
- const rows = card.querySelectorAll('.product-items-list > .item-row');
+ 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);
@@ -2449,6 +3510,13 @@ document.addEventListener('DOMContentLoaded', () => {
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) => `
| 검색 결과가 없습니다. |
`;
// Render Single Items
const renderSingleItems = () => {
@@ -2463,7 +3531,10 @@ document.addEventListener('DOMContentLoaded', () => {
return 0;
});
- singleItemsList.forEach(item => {
+ 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';
@@ -2548,7 +3619,10 @@ document.addEventListener('DOMContentLoaded', () => {
return 0;
});
- setItemsList.forEach(item => {
+ 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';
@@ -2685,6 +3759,36 @@ document.addEventListener('DOMContentLoaded', () => {
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) => {
@@ -2849,6 +3953,7 @@ document.addEventListener('DOMContentLoaded', () => {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.gap = '5px';
+ div.style.alignItems = 'center';
let optionsHtml = '
';
singleItemsList.forEach(s => {
@@ -2857,6 +3962,9 @@ document.addEventListener('DOMContentLoaded', () => {
});
div.innerHTML = `
+
+
+
@@ -2900,12 +4008,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
const components = [];
- const rows = document.getElementById('set-components-wrapper').querySelectorAll('div');
+ const rows = document.getElementById('set-components-wrapper').querySelectorAll(':scope > div');
rows.forEach(r => {
- const sc = r.querySelector('.comp-code').value.trim();
- const qt = parseInt(r.querySelector('.comp-qty').value, 10);
- if(sc && !isNaN(qt) && qt > 0) {
- components.push({single_code: sc, quantity: qt});
+ 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});
+ }
}
});
@@ -2925,6 +4037,17 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
});
+
+ // 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();
@@ -3065,7 +4188,7 @@ if (recFormContainer) {
// Version Update Notice
// ============================================
document.addEventListener('DOMContentLoaded', () => {
- let CURRENT_APP_VERSION = "8.54";
+ let CURRENT_APP_VERSION = "9.0";
const versionEl = document.getElementById('app-version-text');
if (versionEl) {
const text = versionEl.textContent.trim();
diff --git a/static/js/mall_event.js b/static/js/mall_event.js
new file mode 100644
index 0000000..39bd9f1
--- /dev/null
+++ b/static/js/mall_event.js
@@ -0,0 +1,841 @@
+// 자사몰 행사 탭 - 카페24 주문/발주 파일 가공
+//
+// 업로드 → 조건 선택 → 미리보기 → 가공된 엑셀 다운로드 흐름을 담당한다.
+// 기능이 늘어나면 initFirstComeGift() 처럼 기능별 초기화 함수를 추가하고
+// 왼쪽 '기능' 목록 버튼(.me-feature-btn)과 패널(#me-panel-
)을 연결한다.
+(function () {
+ 'use strict';
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const tab = document.getElementById('mall-event-tab');
+ if (!tab) return;
+
+ initFeatureSwitcher();
+ initFirstComeGift();
+ initEventExtract();
+ });
+
+ // ------------------------------------------------------------------
+ // 기능 공통 헬퍼
+ // ------------------------------------------------------------------
+ function escapeHtml(str) {
+ return String(str == null ? '' : str)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+
+ function normalize(str) {
+ return String(str || '').toLowerCase().replace(/\s+/g, '');
+ }
+
+ async function readError(res, fallback) {
+ // 413은 앱이 아니라 앞단 웹서버(nginx 등)가 용량 제한으로 막은 것이라
+ // 응답에 이유가 없다. 원인을 바로 알 수 있게 따로 안내한다.
+ if (res.status === 413) {
+ return '파일이 너무 커서 서버가 업로드를 거부했습니다 (413). ' +
+ '서버 웹서버(nginx 등)의 업로드 용량 제한(client_max_body_size)을 늘려야 합니다.';
+ }
+ try {
+ const data = await res.json();
+ if (data && data.detail) {
+ // FastAPI 검증 오류는 detail이 배열로 온다
+ if (Array.isArray(data.detail)) {
+ return data.detail.map(function (d) { return d.msg || JSON.stringify(d); }).join(', ');
+ }
+ return data.detail;
+ }
+ } catch (e) { /* 본문이 JSON이 아니면 아래 기본 메시지 */ }
+ // 서버 오류(500 등)는 본문이 없으므로 상태 코드라도 남겨 원인을 좁힌다
+ return fallback + ' (서버 응답 ' + res.status + ')';
+ }
+
+ // 서버가 Content-Disposition으로 내려준 파일명을 꺼낸다
+ function filenameFromResponse(res, fallback) {
+ const disposition = res.headers.get('Content-Disposition') || '';
+ const match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
+ if (match) {
+ try { return decodeURIComponent(match[1]); } catch (e) { /* 무시 */ }
+ }
+ return fallback;
+ }
+
+ // 탭 전체를 덮는 로딩 오버레이 (기능들이 공유)
+ function setOverlay(isLoading, text) {
+ const overlay = document.getElementById('me-loading-overlay');
+ const overlayText = document.getElementById('me-loading-text');
+ if (overlay) overlay.style.display = isLoading ? 'flex' : 'none';
+ if (overlayText && text) overlayText.textContent = text;
+ }
+
+ // 파일 선택 버튼 + 드래그 앤 드롭을 한 번에 붙인다
+ function bindDropzone(dropzone, fileInput, pickBtn, onFile) {
+ if (!dropzone || !fileInput) return;
+
+ if (pickBtn) {
+ pickBtn.addEventListener('click', function (e) {
+ e.stopPropagation();
+ fileInput.click();
+ });
+ }
+ dropzone.addEventListener('click', function () { fileInput.click(); });
+ fileInput.addEventListener('change', function () {
+ if (fileInput.files && fileInput.files[0]) onFile(fileInput.files[0]);
+ });
+
+ ['dragenter', 'dragover'].forEach(function (type) {
+ dropzone.addEventListener(type, function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ dropzone.style.background = '#ebf8ff';
+ dropzone.style.borderColor = '#3182ce';
+ });
+ });
+ ['dragleave', 'drop'].forEach(function (type) {
+ dropzone.addEventListener(type, function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ dropzone.style.background = '#f7fafc';
+ dropzone.style.borderColor = '#cbd5e0';
+ });
+ });
+ dropzone.addEventListener('drop', function (e) {
+ const files = e.dataTransfer && e.dataTransfer.files;
+ if (files && files.length > 0) onFile(files[0]);
+ });
+ }
+
+ // 탭 밖에 파일을 떨어뜨렸을 때 브라우저가 파일을 열어버리는 것 방지
+ ['dragover', 'drop'].forEach(function (type) {
+ window.addEventListener(type, function (e) {
+ const inDropzone = e.target.closest && e.target.closest('#me-dropzone, #ee-dropzone');
+ if (!inDropzone) e.preventDefault();
+ });
+ });
+
+ // blob 응답을 파일로 저장
+ async function saveBlob(res, fallbackName) {
+ const blob = await res.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filenameFromResponse(res, fallbackName);
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ window.URL.revokeObjectURL(url);
+ }
+
+ // ------------------------------------------------------------------
+ // 기능 전환
+ // ------------------------------------------------------------------
+ function initFeatureSwitcher() {
+ const buttons = document.querySelectorAll('.me-feature-btn');
+ if (buttons.length < 2) return; // 기능이 하나면 전환할 것이 없다
+
+ buttons.forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ buttons.forEach(function (b) {
+ b.classList.remove('active');
+ b.style.background = '#fff';
+ b.style.color = '#4a5568';
+ b.style.borderColor = '#e2e8f0';
+ });
+ btn.classList.add('active');
+ btn.style.background = '#ebf8ff';
+ btn.style.color = '#2b6cb0';
+ btn.style.borderColor = '#bee3f8';
+
+ document.querySelectorAll('.me-feature-panel').forEach(function (panel) {
+ panel.style.display = 'none';
+ });
+ const target = document.getElementById('me-panel-' + btn.dataset.feature);
+ if (target) target.style.display = 'flex';
+ });
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // 기능 1) 선착순 사은품 추가
+ // ------------------------------------------------------------------
+ function initFirstComeGift() {
+ const dropzone = document.getElementById('me-dropzone');
+ const fileInput = document.getElementById('me-file-input');
+ const pickBtn = document.getElementById('btn-me-pick-file');
+ const resetBtn = document.getElementById('btn-me-reset');
+ const fileInfo = document.getElementById('me-file-info');
+ const fileError = document.getElementById('me-file-error');
+ const orderListBox = document.getElementById('me-order-list');
+ const orderSearch = document.getElementById('me-order-search');
+ const selectAllBtn = document.getElementById('btn-me-select-all');
+ const selectNoneBtn = document.getElementById('btn-me-select-none');
+ const selectedInfo = document.getElementById('me-order-selected-info');
+ const limitInput = document.getElementById('me-limit');
+ const giftSelect = document.getElementById('me-gift-select');
+ const giftQtyInput = document.getElementById('me-gift-qty');
+ const applyBtn = document.getElementById('btn-me-apply');
+ const warningBox = document.getElementById('me-warning-box');
+ const resultInfo = document.getElementById('me-result-info');
+ const resultTbody = document.getElementById('me-result-tbody');
+ const downloadFullBtn = document.getElementById('btn-me-download-full');
+ const downloadGiftBtn = document.getElementById('btn-me-download-gift');
+ const overlay = document.getElementById('me-loading-overlay');
+ const overlayText = document.getElementById('me-loading-text');
+ if (!dropzone || !fileInput) return;
+
+ let currentFile = null;
+ let orderItems = []; // [{name, order_count}]
+ const selectedNames = new Set();
+ let searchKeyword = '';
+ let previewReady = false;
+
+ function setLoading(isLoading, text) {
+ setOverlay(isLoading, text);
+ applyBtn.disabled = isLoading;
+ }
+
+ function showFileError(message) {
+ if (!fileError) return;
+ if (!message) {
+ fileError.style.display = 'none';
+ fileError.textContent = '';
+ return;
+ }
+ fileError.style.display = 'block';
+ fileError.textContent = message;
+ }
+
+ // ---- 다운로드 버튼 활성/비활성 ----
+ function setDownloadEnabled(enabled) {
+ previewReady = enabled;
+ [downloadFullBtn, downloadGiftBtn].forEach(function (btn) {
+ if (!btn) return;
+ btn.disabled = !enabled;
+ btn.style.opacity = enabled ? '1' : '0.5';
+ btn.style.cursor = enabled ? 'pointer' : 'default';
+ });
+ }
+
+ // 조건이 바뀌면 이전 미리보기 결과는 더 이상 유효하지 않다
+ function invalidatePreview() {
+ if (!previewReady) return;
+ setDownloadEnabled(false);
+ resultInfo.innerHTML = '조건이 바뀌었습니다. "사은품 적용 결과 보기"를 다시 눌러주세요.';
+ }
+
+ // ---- 파일 업로드 ----
+ function resetAll() {
+ currentFile = null;
+ orderItems = [];
+ selectedNames.clear();
+ searchKeyword = '';
+ fileInput.value = '';
+ if (orderSearch) orderSearch.value = '';
+ fileInfo.style.display = 'none';
+ fileInfo.innerHTML = '';
+ showFileError('');
+ orderListBox.innerHTML = '발주서를 업로드하면 주문 목록이 표시됩니다.
';
+ selectedInfo.textContent = '';
+ resultTbody.innerHTML = '';
+ resultInfo.textContent = '';
+ warningBox.style.display = 'none';
+ warningBox.innerHTML = '';
+ setDownloadEnabled(false);
+ }
+
+ async function handleFile(file) {
+ if (!file) return;
+ const lower = file.name.toLowerCase();
+ if (lower.endsWith('.xls')) {
+ showFileError('구형 .xls 파일은 처리할 수 없습니다. 엑셀에서 .xlsx로 저장한 뒤 올려주세요.');
+ return;
+ }
+ if (!lower.endsWith('.xlsx') && !lower.endsWith('.xlsm')) {
+ showFileError('엑셀 파일(.xlsx)만 업로드할 수 있습니다.');
+ return;
+ }
+
+ showFileError('');
+ currentFile = file;
+ selectedNames.clear();
+ searchKeyword = '';
+ if (orderSearch) orderSearch.value = '';
+ setDownloadEnabled(false);
+ resultTbody.innerHTML = '';
+ resultInfo.textContent = '';
+ warningBox.style.display = 'none';
+
+ setLoading(true, '발주서를 분석하는 중...');
+ try {
+ const form = new FormData();
+ form.append('file', file);
+ const res = await fetch('/api/mall-event/first-come/analyze', { method: 'POST', body: form });
+ if (!res.ok) {
+ showFileError(await readError(res, '발주서를 분석하지 못했습니다.'));
+ currentFile = null;
+ return;
+ }
+ const data = await res.json();
+ orderItems = data.order_list_items || [];
+
+ fileInfo.style.display = 'block';
+ fileInfo.innerHTML =
+ '📄 ' + escapeHtml(file.name) + '
' +
+ '시트: ' + escapeHtml(data.sheet_name) + ' · 헤더 ' + data.header_row + '행
' +
+ '데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
+ Number(data.total_orders).toLocaleString() + '건 · 상품 ' + orderItems.length + '종
';
+
+ renderOrderList();
+ } catch (err) {
+ console.error(err);
+ showFileError('업로드 중 오류가 발생했습니다.');
+ currentFile = null;
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ bindDropzone(dropzone, fileInput, pickBtn, handleFile);
+
+ if (resetBtn) resetBtn.addEventListener('click', resetAll);
+
+ // ---- 주문목록 리스트 ----
+ function renderOrderList() {
+ if (!orderItems.length) {
+ orderListBox.innerHTML = 'Q열(주문목록)에서 상품을 찾지 못했습니다.
';
+ updateSelectedInfo();
+ return;
+ }
+
+ const visible = orderItems.filter(function (item) {
+ return !searchKeyword || normalize(item.name).includes(searchKeyword);
+ });
+
+ if (!visible.length) {
+ orderListBox.innerHTML = '검색 결과가 없습니다.
';
+ updateSelectedInfo();
+ return;
+ }
+
+ orderListBox.innerHTML = visible.map(function (item) {
+ const checked = selectedNames.has(item.name) ? ' checked' : '';
+ return '' +
+ '';
+ }).join('');
+
+ updateSelectedInfo();
+ }
+
+ function updateSelectedInfo() {
+ if (!selectedNames.size) {
+ selectedInfo.textContent = orderItems.length ? '선택된 상품이 없습니다.' : '';
+ return;
+ }
+ const names = Array.from(selectedNames);
+ const preview = names.slice(0, 2).join(', ');
+ const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
+ selectedInfo.innerHTML = '선택 ' + names.length + '개 — ' + escapeHtml(preview + more);
+ }
+
+ orderListBox.addEventListener('change', function (e) {
+ const check = e.target.closest('.me-order-check');
+ if (!check) return;
+ if (check.checked) selectedNames.add(check.value);
+ else selectedNames.delete(check.value);
+ updateSelectedInfo();
+ invalidatePreview();
+ });
+
+ if (orderSearch) {
+ orderSearch.addEventListener('input', function () {
+ searchKeyword = normalize(orderSearch.value);
+ renderOrderList();
+ });
+ }
+ if (selectAllBtn) {
+ selectAllBtn.addEventListener('click', function () {
+ orderItems.forEach(function (item) {
+ if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
+ });
+ renderOrderList();
+ invalidatePreview();
+ });
+ }
+ if (selectNoneBtn) {
+ selectNoneBtn.addEventListener('click', function () {
+ selectedNames.clear();
+ renderOrderList();
+ invalidatePreview();
+ });
+ }
+
+ // ---- 사은품 목록 (코드표 세트 코드 중 이름에 '사은품'이 들어간 것만) ----
+ const GIFT_NAME_KEYWORD = '사은품';
+
+ async function loadGiftOptions() {
+ try {
+ const res = await fetch('/api/codes/set');
+ if (!res.ok) throw new Error('status ' + res.status);
+ const data = await res.json();
+ const allSets = (data && data.data) || [];
+ const sets = allSets.filter(function (s) {
+ return String(s.name || '').includes(GIFT_NAME_KEYWORD);
+ });
+ if (!sets.length) {
+ giftSelect.innerHTML = '';
+ return;
+ }
+ sets.sort(function (a, b) {
+ return String(a.name || '').localeCompare(String(b.name || ''), 'ko');
+ });
+ giftSelect.innerHTML = '' + sets.map(function (s) {
+ return '';
+ }).join('');
+ } catch (err) {
+ console.error(err);
+ giftSelect.innerHTML = '';
+ }
+ }
+ loadGiftOptions();
+
+ // 코드표 탭에서 세트를 수정하고 돌아왔을 때 목록을 새로 받는다
+ const mallEventMenu = document.querySelector('[data-tab="mall-event-tab"]');
+ if (mallEventMenu) {
+ mallEventMenu.addEventListener('click', function () {
+ if (!giftSelect.value) loadGiftOptions();
+ });
+ }
+
+ // ---- 조건 변경 감지 ----
+ document.querySelectorAll('.me-limit-preset').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ limitInput.value = btn.dataset.value;
+ invalidatePreview();
+ });
+ });
+ [limitInput, giftSelect, giftQtyInput].forEach(function (el) {
+ if (el) el.addEventListener('change', invalidatePreview);
+ });
+ if (limitInput) limitInput.addEventListener('input', invalidatePreview);
+ if (giftQtyInput) giftQtyInput.addEventListener('input', invalidatePreview);
+
+ // ---- 조건 수집/검증 ----
+ function buildFormData() {
+ if (!currentFile) {
+ alert('먼저 발주서 엑셀 파일을 업로드해주세요.');
+ return null;
+ }
+ if (!selectedNames.size) {
+ alert('사은품이 지급되는 주문을 최소 1개 선택해주세요.');
+ return null;
+ }
+ const limit = parseInt(limitInput.value, 10);
+ if (!limit || limit < 1) {
+ alert('선착순 인원을 1명 이상으로 입력해주세요.');
+ return null;
+ }
+ const giftCode = giftSelect.value;
+ if (!giftCode) {
+ alert('사은품(세트 코드)을 선택해주세요.');
+ return null;
+ }
+ const giftQty = parseInt(giftQtyInput.value, 10);
+ if (!giftQty || giftQty < 1) {
+ alert('사은품 수량을 1개 이상으로 입력해주세요.');
+ return null;
+ }
+ const giftName = giftSelect.options[giftSelect.selectedIndex].dataset.name || '';
+
+ const form = new FormData();
+ form.append('file', currentFile);
+ form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
+ form.append('limit', String(limit));
+ form.append('gift_item_code', giftCode);
+ form.append('gift_name', giftName);
+ form.append('gift_qty', String(giftQty));
+ return form;
+ }
+
+ // ---- 미리보기 ----
+ applyBtn.addEventListener('click', async function () {
+ const form = buildFormData();
+ if (!form) return;
+
+ setLoading(true, '사은품 대상을 계산하는 중...');
+ try {
+ const res = await fetch('/api/mall-event/first-come/preview', { method: 'POST', body: form });
+ if (!res.ok) {
+ alert(await readError(res, '사은품 적용 중 오류가 발생했습니다.'));
+ return;
+ }
+ const data = await res.json();
+
+ if (data.warnings && data.warnings.length) {
+ warningBox.style.display = 'block';
+ warningBox.innerHTML = data.warnings.map(function (w) {
+ return '⚠ ' + escapeHtml(w);
+ }).join('
');
+ } else {
+ warningBox.style.display = 'none';
+ warningBox.innerHTML = '';
+ }
+
+ const added = data.added || [];
+ if (!added.length) {
+ resultTbody.innerHTML = '| 사은품이 적용된 주문이 없습니다. |
';
+ resultInfo.innerHTML = '적용 대상 0건';
+ setDownloadEnabled(false);
+ return;
+ }
+
+ resultTbody.innerHTML = added.map(function (row, idx) {
+ return '' +
+ '| ' + (idx + 1) + ' | ' +
+ '' + escapeHtml(row.order_no) + ' | ' +
+ '' + escapeHtml(row.order_date) + ' | ' +
+ '' + escapeHtml(row.seq) + ' | ' +
+ '
';
+ }).join('');
+
+ resultInfo.innerHTML = '조건 충족 주문 ' + Number(data.matched_order_count).toLocaleString() + '건 중 ' +
+ '' + Number(data.applied_count).toLocaleString() + '건에 사은품 행 추가';
+ setDownloadEnabled(true);
+ } catch (err) {
+ console.error(err);
+ alert('사은품 적용 요청 중 오류가 발생했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ // ---- 다운로드 ----
+ async function download(mode, fallbackName, label) {
+ const form = buildFormData();
+ if (!form) return;
+ form.append('mode', mode);
+
+ setLoading(true, label + ' 파일을 만드는 중...');
+ try {
+ const res = await fetch('/api/mall-event/first-come/download', { method: 'POST', body: form });
+ if (!res.ok) {
+ alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
+ return;
+ }
+ await saveBlob(res, fallbackName);
+ } catch (err) {
+ console.error(err);
+ alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // 서버가 Content-Disposition으로 이름을 내려주지만, 못 읽었을 때 쓸 대비책
+ function baseName() {
+ const name = (currentFile && currentFile.name) || '발주서.xlsx';
+ return name.replace(/\.(xlsx|xlsm|xls)$/i, '');
+ }
+
+ if (downloadFullBtn) {
+ downloadFullBtn.addEventListener('click', function () {
+ if (downloadFullBtn.disabled) return;
+ // 전체 발주 파일은 업로드한 파일과 같은 이름으로 받는다
+ download('full', baseName() + '.xlsx', '전체 발주');
+ });
+ }
+ if (downloadGiftBtn) {
+ downloadGiftBtn.addEventListener('click', function () {
+ if (downloadGiftBtn.disabled) return;
+ download('gift', baseName() + '_선착순사은품_대상주문.xlsx', '사은품 대상 주문');
+ });
+ }
+
+ setDownloadEnabled(false);
+ }
+
+ // ------------------------------------------------------------------
+ // 기능 2) 행사 주문 추출
+ // 주문 CSV → K열(주문상품명) 첫 " -" 앞부분으로 상품 선택 →
+ // 해당상품만/해당주문 조건으로 행을 걸러 엑셀로 내려받는다.
+ // ------------------------------------------------------------------
+ function initEventExtract() {
+ const dropzone = document.getElementById('ee-dropzone');
+ const fileInput = document.getElementById('ee-file-input');
+ const pickBtn = document.getElementById('btn-ee-pick-file');
+ const resetBtn = document.getElementById('btn-ee-reset');
+ const fileInfo = document.getElementById('ee-file-info');
+ const fileError = document.getElementById('ee-file-error');
+ const productListBox = document.getElementById('ee-product-list');
+ const productSearch = document.getElementById('ee-product-search');
+ const selectAllBtn = document.getElementById('btn-ee-select-all');
+ const selectNoneBtn = document.getElementById('btn-ee-select-none');
+ const selectedInfo = document.getElementById('ee-selected-info');
+ const warningBox = document.getElementById('ee-warning-box');
+ const resultInfo = document.getElementById('ee-result-info');
+ const downloadBtn = document.getElementById('btn-ee-download');
+ const modeRadios = document.querySelectorAll('input[name="ee-mode"]');
+ if (!dropzone || !fileInput) return;
+
+ let currentFile = null;
+ let productItems = []; // [{name, row_count, order_count}]
+ const selectedNames = new Set();
+ let searchKeyword = '';
+
+ function setLoading(isLoading, text) {
+ setOverlay(isLoading, text);
+ downloadBtn.disabled = isLoading;
+ downloadBtn.style.opacity = isLoading ? '0.6' : '1';
+ }
+
+ // 조건이 바뀌면 직전 다운로드 결과 안내는 지운다
+ function clearResult() {
+ resultInfo.innerHTML = '';
+ }
+
+ function currentMode() {
+ const checked = document.querySelector('input[name="ee-mode"]:checked');
+ return checked ? checked.value : 'product';
+ }
+
+ function showFileError(message) {
+ if (!fileError) return;
+ fileError.style.display = message ? 'block' : 'none';
+ fileError.textContent = message || '';
+ }
+
+ function resetAll() {
+ currentFile = null;
+ productItems = [];
+ selectedNames.clear();
+ searchKeyword = '';
+ fileInput.value = '';
+ if (productSearch) productSearch.value = '';
+ fileInfo.style.display = 'none';
+ fileInfo.innerHTML = '';
+ showFileError('');
+ productListBox.innerHTML = 'CSV를 업로드하면 상품 목록이 표시됩니다.
';
+ selectedInfo.textContent = '';
+ resultInfo.innerHTML = '';
+ warningBox.style.display = 'none';
+ warningBox.innerHTML = '';
+ clearResult();
+ }
+
+ // ---- 업로드 & 분석 ----
+ async function handleFile(file) {
+ if (!file) return;
+ if (!file.name.toLowerCase().endsWith('.csv')) {
+ showFileError('CSV 파일(.csv)만 업로드할 수 있습니다.');
+ return;
+ }
+
+ showFileError('');
+ currentFile = file;
+ selectedNames.clear();
+ searchKeyword = '';
+ if (productSearch) productSearch.value = '';
+ clearResult();
+ resultInfo.innerHTML = '';
+ warningBox.style.display = 'none';
+
+ setLoading(true, 'CSV를 분석하는 중...');
+ try {
+ const form = new FormData();
+ form.append('file', file);
+ const res = await fetch('/api/mall-event/event-extract/analyze', { method: 'POST', body: form });
+ if (!res.ok) {
+ showFileError(await readError(res, 'CSV를 분석하지 못했습니다.'));
+ currentFile = null;
+ return;
+ }
+ const data = await res.json();
+ productItems = data.product_items || [];
+
+ fileInfo.style.display = 'block';
+ fileInfo.innerHTML =
+ '📄 ' + escapeHtml(file.name) + '
' +
+ '인코딩: ' + escapeHtml(data.encoding) + ' · 헤더 ' + data.header_row + '행 · ' + data.column_count + '개 열
' +
+ '데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
+ Number(data.total_orders).toLocaleString() + '건 · 상품 ' + productItems.length + '종
';
+
+ if (data.warnings && data.warnings.length) {
+ warningBox.style.display = 'block';
+ warningBox.innerHTML = data.warnings.map(function (w) {
+ return '⚠ ' + escapeHtml(w);
+ }).join('
');
+ }
+
+ renderProductList();
+ } catch (err) {
+ console.error(err);
+ showFileError('업로드 중 오류가 발생했습니다.');
+ currentFile = null;
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ bindDropzone(dropzone, fileInput, pickBtn, handleFile);
+ if (resetBtn) resetBtn.addEventListener('click', resetAll);
+
+ // ---- 상품 목록 ----
+ function renderProductList() {
+ if (!productItems.length) {
+ productListBox.innerHTML = 'K열(주문상품명)에서 상품을 찾지 못했습니다.
';
+ updateSelectedInfo();
+ return;
+ }
+
+ const visible = productItems.filter(function (item) {
+ return !searchKeyword || normalize(item.name).includes(searchKeyword);
+ });
+
+ if (!visible.length) {
+ productListBox.innerHTML = '검색 결과가 없습니다.
';
+ updateSelectedInfo();
+ return;
+ }
+
+ productListBox.innerHTML = visible.map(function (item) {
+ const checked = selectedNames.has(item.name) ? ' checked' : '';
+ return '' +
+ '';
+ }).join('');
+
+ updateSelectedInfo();
+ }
+
+ function updateSelectedInfo() {
+ if (!selectedNames.size) {
+ selectedInfo.textContent = productItems.length ? '선택된 상품이 없습니다.' : '';
+ return;
+ }
+ const names = Array.from(selectedNames);
+ const preview = names.slice(0, 2).join(', ');
+ const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
+ selectedInfo.innerHTML = '선택 ' + names.length + '개 — ' + escapeHtml(preview + more);
+ }
+
+ productListBox.addEventListener('change', function (e) {
+ const check = e.target.closest('.ee-product-check');
+ if (!check) return;
+ if (check.checked) selectedNames.add(check.value);
+ else selectedNames.delete(check.value);
+ updateSelectedInfo();
+ clearResult();
+ });
+
+ if (productSearch) {
+ productSearch.addEventListener('input', function () {
+ searchKeyword = normalize(productSearch.value);
+ renderProductList();
+ });
+ }
+ if (selectAllBtn) {
+ selectAllBtn.addEventListener('click', function () {
+ productItems.forEach(function (item) {
+ if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
+ });
+ renderProductList();
+ clearResult();
+ });
+ }
+ if (selectNoneBtn) {
+ selectNoneBtn.addEventListener('click', function () {
+ selectedNames.clear();
+ renderProductList();
+ clearResult();
+ });
+ }
+
+ // ---- 추출 조건 라디오 ----
+ modeRadios.forEach(function (radio) {
+ radio.addEventListener('change', function () {
+ modeRadios.forEach(function (r) {
+ const box = r.closest('label');
+ if (!box) return;
+ const on = r.checked;
+ box.style.borderColor = on ? '#bee3f8' : '#e2e8f0';
+ box.style.background = on ? '#ebf8ff' : '#fff';
+ const title = box.querySelector('span > span');
+ if (title) title.style.color = on ? '#2b6cb0' : '#2d3748';
+ });
+ clearResult();
+ });
+ });
+
+ // ---- 조건 수집 ----
+ function buildFormData() {
+ if (!currentFile) {
+ alert('먼저 주문 CSV 파일을 업로드해주세요.');
+ return null;
+ }
+ if (!selectedNames.size) {
+ alert('추출할 상품을 최소 1개 선택해주세요.');
+ return null;
+ }
+ const form = new FormData();
+ form.append('file', currentFile);
+ form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
+ form.append('mode', currentMode());
+ return form;
+ }
+
+ // ---- 다운로드 (중간 확인 단계 없이 바로 파일 생성) ----
+ downloadBtn.addEventListener('click', async function () {
+ if (downloadBtn.disabled) return;
+ const form = buildFormData();
+ if (!form) return;
+
+ const modeLabel = currentMode() === 'product' ? '해당상품만 추출' : '해당 주문 추출';
+ const suffix = currentMode() === 'product' ? '_행사추출_해당상품만' : '_행사추출_해당주문';
+ const fallback = ((currentFile && currentFile.name) || '주문').replace(/\.csv$/i, '') + suffix + '.xlsx';
+
+ warningBox.style.display = 'none';
+ warningBox.innerHTML = '';
+ clearResult();
+
+ setLoading(true, '추출 파일을 만드는 중...');
+ try {
+ const res = await fetch('/api/mall-event/event-extract/download', { method: 'POST', body: form });
+ if (!res.ok) {
+ alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
+ return;
+ }
+
+ // 서버가 헤더로 실어 보낸 추출 결과를 화면에 남긴다
+ const rows = Number(res.headers.get('X-Extract-Rows') || 0);
+ const orders = Number(res.headers.get('X-Extract-Orders') || 0);
+ const matched = Number(res.headers.get('X-Extract-Matched-Rows') || 0);
+ const total = Number(res.headers.get('X-Extract-Total-Rows') || 0);
+
+ await saveBlob(res, fallback);
+
+ resultInfo.innerHTML =
+ '✓ 다운로드 완료
' +
+ '조건: ' + modeLabel + '
' +
+ '상품이 들어있는 행 ' + matched.toLocaleString() + '행
' +
+ '추출 결과 ' + rows.toLocaleString() + '행' +
+ ' · 주문 ' + orders.toLocaleString() + '건
' +
+ '전체 ' + total.toLocaleString() + '행 중
';
+ } catch (err) {
+ console.error(err);
+ alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ clearResult();
+ }
+})();
diff --git a/static/js/milkrun_gsheet.js b/static/js/milkrun_gsheet.js
new file mode 100644
index 0000000..2502638
--- /dev/null
+++ b/static/js/milkrun_gsheet.js
@@ -0,0 +1,363 @@
+// 쿠팡 밀크런 캘린더
+//
+// 화면이 열리면 구글 드라이브의 밀크런 출고리스트를 자동으로 읽어
+// 시트 이름(YYYYMMDD)을 달력에 표시한다.
+// 달력에서 날짜를 고르면 그 시트의 제품코드/제품명/수량을 읽어
+// 붙여넣기 입력을 채우고 곧바로 분석까지 실행한다.
+// (시트 주소는 서버에 설정되어 있어 화면에서 입력하지 않는다)
+(function () {
+ 'use strict';
+
+ const DOW = ['일', '월', '화', '수', '목', '금', '토'];
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const refreshBtn = document.getElementById('btn-gsheet-refresh');
+ const messageBox = document.getElementById('gsheet-message');
+ const fileInfo = document.getElementById('gsheet-file-info');
+ const calendarBox = document.getElementById('gsheet-calendar');
+ const otherBox = document.getElementById('gsheet-other-sheets');
+ if (!calendarBox) return;
+
+ // 붙여넣기 입력 쪽 요소 (분석은 기존 로직을 그대로 재사용한다)
+ const pasteInput = document.getElementById('milkrun-input');
+ const analyzeBtn = document.getElementById('btn-milkrun-analyze');
+
+ let sheetsByDate = {}; // 'YYYY-MM-DD' -> 시트 이름
+ let otherSheets = []; // 날짜로 못 읽은 시트 이름
+ let viewYear = null;
+ let viewMonth = null; // 0-based
+ let selectedDate = null;
+ let busy = false;
+
+ function escapeHtml(str) {
+ return String(str == null ? '' : str)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+
+ let messageTimer = null;
+
+ function showMessage(text, kind, autoHideMs) {
+ if (messageTimer) {
+ clearTimeout(messageTimer);
+ messageTimer = null;
+ }
+ if (!text) {
+ messageBox.style.display = 'none';
+ messageBox.innerHTML = '';
+ return;
+ }
+ const palette = {
+ error: { bg: '#fff5f5', border: '#feb2b2', color: '#c53030' },
+ info: { bg: '#ebf8ff', border: '#bee3f8', color: '#2b6cb0' },
+ success: { bg: '#f0fff4', border: '#9ae6b4', color: '#276749' },
+ // 눈에 잘 띄어야 하는 안내 (오늘 출고 없음 등)
+ alert: { bg: '#fffbeb', border: '#f6ad55', color: '#c05621' }
+ };
+ const c = palette[kind] || palette.info;
+ messageBox.style.display = 'block';
+ messageBox.style.background = c.bg;
+ messageBox.style.border = '2px solid ' + c.border;
+ messageBox.style.color = c.color;
+ messageBox.innerHTML = text;
+
+ if (autoHideMs) {
+ messageTimer = setTimeout(function () {
+ messageBox.style.display = 'none';
+ messageBox.innerHTML = '';
+ messageTimer = null;
+ }, autoHideMs);
+ }
+ }
+
+ function dateKeyOf(date) {
+ return date.getFullYear() + '-' +
+ String(date.getMonth() + 1).padStart(2, '0') + '-' +
+ String(date.getDate()).padStart(2, '0');
+ }
+
+ // 달력은 [본문 + 로딩 덮개] 두 겹으로 만들어 둔다.
+ // 본문만 다시 그리므로 덮개가 지워지지 않는다.
+ function ensureCalendarShell() {
+ if (calendarBox.dataset.ready === '1') return;
+ calendarBox.style.position = 'relative';
+ calendarBox.innerHTML =
+ '' +
+ '' +
+ '
' +
+ '
데이터 읽는 중...
' +
+ '
';
+ calendarBox.dataset.ready = '1';
+ }
+
+ function setBusy(isBusy, label) {
+ busy = isBusy;
+ if (refreshBtn) {
+ refreshBtn.disabled = isBusy;
+ refreshBtn.style.opacity = isBusy ? '0.6' : '1';
+ refreshBtn.textContent = isBusy ? '읽는 중...' : '새로고침';
+ }
+
+ ensureCalendarShell();
+ const overlay = document.getElementById('cal-overlay');
+ const overlayText = document.getElementById('cal-overlay-text');
+ const body = document.getElementById('cal-body');
+ if (overlay) overlay.style.display = isBusy ? 'flex' : 'none';
+ if (overlayText && label) overlayText.textContent = label;
+ if (body) {
+ // 읽는 동안에는 날짜를 누를 수 없게 한다
+ body.style.pointerEvents = isBusy ? 'none' : '';
+ body.style.opacity = isBusy ? '0.45' : '';
+ }
+ }
+
+ // ---- 시트 이름 → 날짜 ----
+ // '20260807(금)' 처럼 앞 8자리가 날짜인 이름을 달력에 올린다
+ function parseSheetDate(name) {
+ const m = String(name).match(/(\d{4})(\d{2})(\d{2})/);
+ if (!m) return null;
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
+ const date = new Date(y, mo - 1, d);
+ if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
+ return { key: m[1] + '-' + m[2] + '-' + m[3], year: y, month: mo - 1, day: d };
+ }
+
+ function indexSheets(sheets) {
+ sheetsByDate = {};
+ otherSheets = [];
+ let latest = null;
+ sheets.forEach(function (sheet) {
+ const parsed = parseSheetDate(sheet.name);
+ if (!parsed) {
+ otherSheets.push(sheet.name);
+ return;
+ }
+ sheetsByDate[parsed.key] = sheet.name;
+ if (!latest || parsed.key > latest.key) latest = parsed;
+ });
+ return latest;
+ }
+
+ // ---- 달력 ----
+ function renderCalendar(placeholderText) {
+ calendarBox.style.display = 'block';
+ ensureCalendarShell();
+ const body = document.getElementById('cal-body');
+
+ if (viewYear === null) {
+ const now = new Date();
+ viewYear = now.getFullYear();
+ viewMonth = now.getMonth();
+ }
+
+ const first = new Date(viewYear, viewMonth, 1);
+ const startDow = first.getDay();
+ const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
+ const monthPrefix = viewYear + '-' + String(viewMonth + 1).padStart(2, '0');
+ const monthCount = Object.keys(sheetsByDate).filter(function (key) {
+ return key.indexOf(monthPrefix) === 0;
+ }).length;
+
+ let html = '' +
+ '' +
+ '
' +
+ '
' + viewYear + '년 ' + (viewMonth + 1) + '월' +
+ ' · 출고 ' + monthCount + '일
' +
+ '
' +
+ '
' +
+ '';
+
+ DOW.forEach(function (label, index) {
+ const color = index === 0 ? '#e53e3e' : (index === 6 ? '#3182ce' : '#718096');
+ html += '
' + label + '
';
+ });
+
+ for (let i = 0; i < startDow; i++) html += '
';
+
+ const todayKey = dateKeyOf(new Date());
+ for (let day = 1; day <= daysInMonth; day++) {
+ const key = monthPrefix + '-' + String(day).padStart(2, '0');
+ const sheetName = sheetsByDate[key];
+ const isSelected = selectedDate === key;
+ let style = 'text-align:center; padding:5px 0; border-radius:4px; font-size:0.78rem;';
+ // 오늘 날짜는 테두리로 표시해 기준점을 알 수 있게 한다
+ if (key === todayKey) style += ' outline:2px solid #ed8936; outline-offset:-2px;';
+ let attrs = '';
+
+ if (sheetName) {
+ attrs = ' class="cal-day" data-date="' + key + '" title="' + escapeHtml(sheetName) + '"';
+ style += isSelected
+ ? ' background:#2b6cb0; color:#fff; font-weight:700; cursor:pointer;'
+ : ' background:#bee3f8; color:#2b6cb0; font-weight:600; cursor:pointer;';
+ } else {
+ style += ' color:#cbd5e0;';
+ }
+ html += '
' + day + '
';
+ }
+
+ html += '
';
+
+ if (placeholderText) {
+ html += '' +
+ escapeHtml(placeholderText) + '
';
+ }
+ body.innerHTML = html;
+
+ body.querySelectorAll('.cal-nav').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ const date = new Date(viewYear, viewMonth + Number(btn.dataset.move), 1);
+ viewYear = date.getFullYear();
+ viewMonth = date.getMonth();
+ renderCalendar();
+ });
+ });
+ body.querySelectorAll('.cal-day').forEach(function (cell) {
+ cell.addEventListener('click', function () {
+ if (busy) return;
+ selectDate(cell.dataset.date);
+ });
+ });
+ }
+
+ function renderOtherSheets() {
+ if (!otherSheets.length) {
+ otherBox.style.display = 'none';
+ otherBox.innerHTML = '';
+ return;
+ }
+ otherBox.style.display = 'block';
+ otherBox.innerHTML = '날짜로 읽지 못한 시트 ' + otherSheets.length + '개: ' +
+ otherSheets.slice(0, 5).map(escapeHtml).join(', ') +
+ (otherSheets.length > 5 ? ' 외' : '');
+ }
+
+ // ---- 시트 목록 불러오기 ----
+ async function loadSheetTabs(forceRefresh) {
+ selectedDate = null;
+ showMessage('구글 시트에서 출고일을 읽는 중...', 'info');
+ setBusy(true, '출고일을 읽는 중...');
+
+ try {
+ const res = await fetch('/api/coupang-milkrun/sheet-tabs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh: !!forceRefresh })
+ });
+
+ if (!res.ok) {
+ let detail = '출고일을 불러오지 못했습니다. (서버 응답 ' + res.status + ')';
+ try {
+ const data = await res.json();
+ if (data && data.detail) detail = data.detail;
+ } catch (e) { /* 본문이 JSON이 아니면 기본 메시지 */ }
+ showMessage(escapeHtml(detail), 'error');
+ renderCalendar('출고일을 불러오지 못했습니다');
+ return;
+ }
+
+ const data = await res.json();
+ const sheets = data.sheets || [];
+ indexSheets(sheets);
+
+ fileInfo.style.display = 'block';
+ fileInfo.innerHTML =
+ '' + escapeHtml(data.file_name || '쿠팡 밀크런 출고리스트') + '' +
+ ' · 출고일 ' + Object.keys(sheetsByDate).length + '일' +
+ (data.cached ? ' · 최근 결과' : '') + '';
+
+ // 항상 오늘 날짜를 기준으로 본다
+ const today = new Date();
+ const todayKey = dateKeyOf(today);
+ viewYear = today.getFullYear();
+ viewMonth = today.getMonth();
+
+ renderCalendar();
+ renderOtherSheets();
+
+ if (sheetsByDate[todayKey]) {
+ // 오늘 출고가 있으면 바로 읽어서 분석까지 진행 (메시지는 selectDate가 남긴다)
+ await selectDate(todayKey);
+ } else {
+ showMessage(
+ '오늘 출고할 밀크런은 없습니다.',
+ 'alert', 2000);
+ }
+ } catch (err) {
+ console.error(err);
+ showMessage('출고일 조회 중 오류가 발생했습니다.', 'error');
+ renderCalendar('출고일을 불러오지 못했습니다');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ // ---- 날짜 선택 → 값 읽기 → 분석 ----
+ async function selectDate(dateKey) {
+ const sheetName = sheetsByDate[dateKey];
+ if (!sheetName) return;
+
+ selectedDate = dateKey;
+ renderCalendar();
+ showMessage('' + escapeHtml(sheetName) + ' 출고리스트를 읽는 중...', 'info');
+ setBusy(true, '출고리스트를 읽는 중...');
+
+ try {
+ const res = await fetch('/api/coupang-milkrun/sheet-rows', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sheet: sheetName })
+ });
+
+ if (!res.ok) {
+ let detail = '출고리스트를 읽지 못했습니다. (서버 응답 ' + res.status + ')';
+ try {
+ const data = await res.json();
+ if (data && data.detail) detail = data.detail;
+ } catch (e) { /* 무시 */ }
+ showMessage(escapeHtml(detail), 'error');
+ return;
+ }
+
+ const data = await res.json();
+ const items = data.items || [];
+ if (!items.length) {
+ showMessage('' + escapeHtml(sheetName) + '에서 제품코드/수량을 찾지 못했습니다. ' +
+ '시트 형식을 확인해주세요.', 'error');
+ return;
+ }
+
+ if (pasteInput) pasteInput.value = data.text || '';
+ if (analyzeBtn) analyzeBtn.click(); // 기존 분석 로직 재사용
+
+ const skipped = data.skipped
+ ? ' (건너뛴 줄 ' + data.skipped + '개)' : '';
+ showMessage('' + escapeHtml(sheetName) + ' · ' + items.length +
+ '줄을 불러와 분석했습니다.' + skipped, 'success');
+ } catch (err) {
+ console.error(err);
+ showMessage('출고리스트를 읽는 중 오류가 발생했습니다.', 'error');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ // ---- 시작 ----
+ if (refreshBtn) refreshBtn.addEventListener('click', function () { loadSheetTabs(true); });
+
+ // 밀크런 탭을 누를 때마다 구글 시트를 다시 읽는다 (시트가 추가/수정됐을 수 있으므로)
+ const milkrunMenu = document.querySelector('[data-tab="milkrun-tab"]');
+ if (milkrunMenu) {
+ milkrunMenu.addEventListener('click', function () {
+ if (!busy) loadSheetTabs(true);
+ });
+ }
+
+ // 달력 틀을 먼저 보여주고(덮개가 '읽는 중'을 표시), 출고일은 뒤이어 채운다
+ renderCalendar();
+ loadSheetTabs(false);
+ });
+})();
diff --git a/templates/index.html b/templates/index.html
index 1514061..d29c02f 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,1056 +1,4219 @@
-
+
-
-
-
-
+
+
+
[No.1 King] CS 통합 관리앱 (미라클주방)
-
-
+
+
-
+
-
+
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+ 상품 분류
+ 제목을 클릭하면 하위 상품이 표시됩니다.
-
-
+
+
+
+
+
선택 상품
+ 0종
+
+
+
+
+ 주문 금액
+ 0원
+
+
+
+ 입금 금액
+ 0원
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💬최근 전송 메시지
+ 전체 삭제
+
+
+
+
+
+
+
+ 📝오늘 수동발주 내역
+
+
+
+ 0건 /
+ 0원
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+ 🛒 카페24 (자사몰)
+
+
+
+
+
+
+ 1. 신규 주문 내역 다운로드
+
+
+
+
+ 배송준비중(N20) 상태인 자사몰 주문 내역을 다운로드합니다.
+
+
+
+
+
+
-
+ id="cafe24ProgressWrapper"
+ class="hidden"
+ style="margin-top: 15px"
+ >
+
+ 수집 준비 중...
+ 0%
+
+
-
- 🛒 카페24 (자사몰)
-
-
-
-
-
1. 신규 주문 내역
- 다운로드
-
-
-
배송준비중(N20) 상태인 자사몰 주문 내역을 다운로드합니다.
-
-
-
-
-
-
-
-
-
- 수집 준비
- 중...
- 0%
-
-
-
-
-
-
⬇
-
-
-
-
2. 송장 일괄 등록
-
-
송장번호가 입력된 엑셀 파일을 업로드합니다.
-
-
-
-
-
-
-
-
-
-
- 🟩 스마트스토어
-
-
-
-
1. 신규 주문 내역 다운로드
-
- 발주확인 요망
-
-
결제완료 상태인 주문 내역을 다운로드합니다.
-
-
-
-
-
-
-
-
-
-
-
-
-
- 수집 준비
- 중...
- 0%
-
-
-
-
-
-
⬇
-
-
-
-
2. 송장 일괄 등록
-
-
송장번호가 입력된 엑셀 파일을 업로드합니다.
-
-
-
-
-
-
-
-
-
- ?
- 준비
- 중...
-
-
-
-
- ?
- 준비
- 중...
-
+ id="cafe24ProgressBar"
+ style="
+ width: 0%;
+ height: 100%;
+ background-color: #4299e1;
+ transition: width 0.3s ease;
+ "
+ >
+
-
+
-
+
+
+ 🟩 스마트스토어
+
+
+
+
+
+ 1. 신규 주문 내역 다운로드
+
+
+ 발주확인 요망
+
+
+
+ 결제완료 상태인 주문 내역을 다운로드합니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 수집 준비 중...
+ 0%
+
+
+
+
+
+
+ ⬇
+
+
+
+
+
2. 송장 일괄 등록
+
+
+ 송장번호가 입력된 엑셀 파일을 업로드합니다.
+
+
+
+
+
+
+
+
+
+
+ ?
+ 준비 중...
+
+
+
+
+ ?
+ 준비 중...
+
+
+
+
+
-
-
-
-
-
-
구글 시트 아랫줄에 기록
- 중입니다...
-
-
-
-
-
-
-
-
-
-
-
머리말
-
- 꼬리말(입금 금액은 {최종금액} 으로 입력)
-
-
-
-
-
-
-
폰트 선택
-
-
-
-
+
+
+
+
+
+
머리말
+
+
+ 꼬리말(입금 금액은 {최종금액} 으로 입력)
+
+
+
+
+
+
+
+
폰트 선택
+
+
+
+
-
배송비 설정
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
단품 코드
-
-
-
-
-
-
- |
- 아이템코드 ▲ |
-
- 사방넷 코드 ↕ |
-
- 이름 ↕
- |
- 관리 |
-
-
-
-
-
-
-
-
-
-
-
세트 코드
-
-
-
-
-
-
- |
- 아이템코드 ▲ |
-
- 세트 이름 ↕ |
-
- 구성 단품 목록 |
- 관리 |
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ 단품 코드
+
+
+
+
-
+
+
+
+
+
+
+ |
+ 아이템코드
+ ▲
+ |
+
+ 사방넷 코드
+ ↕
+ |
+
+ 이름
+ ↕
+ |
+ 관리 |
+
+
+
+
+
+
+
+
+
+
+ 세트 코드
+
+
+
+
+
+
+
+
+
+
+
+ |
+ 아이템코드
+ ▲
+ |
+
+ 세트 이름
+ ↕
+ |
+ 구성 단품 목록 |
+ 관리 |
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
기능
+
+
+
+
+
+ 기능은 계속 추가됩니다.
+
+
+
+
+
+
+
+
+
1. 발주서 업로드
+
+
+
+
📥
+
+ 엑셀 파일을 여기로 끌어다 놓으세요
+
+
+ 또는 아래 버튼으로 직접 선택 (.xlsx)
+
+
+
+
+
+
+
+ 카페24에서 받은 발주주문서를 그대로 올리면 됩니다. Q열(주문목록)의 첫 "/" 앞부분을
+ 기준으로 상품 목록을 만듭니다.
+
+
+
+
+
+
+
+ 2. 사은품이 지급되는 주문
+
+
+
+
+
+
+
+
+
+ 발주서를 업로드하면 주문 목록이 표시됩니다.
+
+
+
+
+
+
+
+
+ 3. 사은품 조건
+
+
+
+
+
+
+
+
+
+ 명
+
+
+
+
+
+ 코드표 세트 중 이름에 "사은품"이 들어간 것만 표시됩니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+
+ 주문번호
+ |
+
+ 주문날짜
+ |
+
+ 새 C열
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
밀크런 캘린더
+
+
+
+
+
+
+
+
+
+
+
+
붙여넣기 입력
+
+
+
+
+
+
+ 쿠팡 밀크런 내용을 그대로 붙여넣으세요 (제품코드 / 제품명 / 수량). "MT-7000_2"처럼
+ 코드 끝에 "_숫자"가 붙으면 해당 숫자만큼 곱해서 계산합니다.
+
+
+
+
+
+
+
+
라인별 분해 확인
+
+
+
+
+
+
+ |
+ 입력 코드
+ |
+
+ 입력 수량
+ |
+ 분해 결과 |
+
+
+
+
+
+
+
+
+
+
낱개 코드 합계
+
+
+
+
+
+
+
+
+
+
+ |
+ 사방넷 코드
+ |
+
+ 낱개 코드
+ |
+
+ 상품 이름
+ |
+
+ 합계 수량
+ |
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- 신청 목록
-
-
-
-
-
-
-
-
-
-
-
-
-
송장일괄 입고
-
-
- 송장 앞 숫자 4자리 를
제외하고 입력 하세요.
-
-
-
-
-
-
-
-
-
-
-
-
-
- 입고 목록
-
-
-
-
-
- |
- 입고날짜 |
-
- 수령인 |
-
- 휴대폰번호 |
-
- 송장번호 |
-
- 쇼핑몰 |
-
- 입고상태 |
- 비고 |
-
- 관리 |
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
송장일괄 입고
+
+
+ 송장 앞 숫자 4자리
+ 를
제외하고 입력 하세요.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 입고 목록
+
+
+
+
+
+ |
+ 입고날짜
+ |
+
+ 수령인
+ |
+
+ 휴대폰번호
+ |
+
+ 송장번호
+ |
+
+ 쇼핑몰
+ |
+
+ 입고상태
+ |
+
+ 비고
+ |
+
+ 관리
+ |
+
+
+
+
+
+
+
+
+
- {% include 'version_history.html' %}
-
+ {% include 'version_history.html' %}
+
-
+
+
+
-
- 클립보드로 주문 내용이 복사되었습니다.
+
+ 클립보드로 주문 내용이 복사되었습니다.
-
-
-
- 🛒 발주 입력 확인
-
아래 상품 중에서 총 금액 계산에서
제외할 상품을 선택 하고 [입력] 버튼을 눌러주세요.
-
-
-
-
- 최종 금액: 0원
-
-
-
-
-
-
-
-
+
+
+
+ 🛒 발주 입력 확인
+
+
+ 아래 상품 중에서 총 금액 계산에서
제외할 상품을 선택
+ 하고 [입력] 버튼을 눌러주세요.
+
+
+
+
+ 최종 금액:
+ 0원
+
+
+
+
+
+
+
+
+
-
-
-
- 📦 단품 추가
-
-
-
-
-
-
-
-
-
+
-
-
-
- 🍱 세트 추가
-
-
-
-
-
-
-
-
-
세트 구성품 등록
-
-
-
-
-
+
+
+
+ 🍱 세트 추가
+
+
+
+
+
+
+
+
+
세트 구성품 등록
+
+
-
-
-
-
+
+
+
+
+
+
+
-
+
+
-
- 🎉
-
업데이트 안내
-
-
-
- 프로그램이 버전으로 업데이트
- 되었습니다.
- Ver History에서 내용을 확인 해주세요!!
-
-
-
-
-
-
+ style="
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-bottom: 2px solid #3182ce;
+ padding-bottom: 10px;
+ "
+ >
+
🎉
+
업데이트 안내
-
-
-
\ No newline at end of file
+
+ 프로그램이
+
+ 버전으로 업데이트 되었습니다.
+ Ver History에서 내용을 확인 해주세요!!
+
+
+
+
+
+
+
+
+
+