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>
This commit is contained in:
2026-08-31 23:56:17 +09:00
parent a2a5d53d9b
commit 065e81bf97
11 changed files with 9830 additions and 1392 deletions
+853 -30
View File
@@ -107,13 +107,47 @@ p {
}
.nav-links li {
/* 탭 글자 크기 */
padding: 11px 10px;
/* 탭 글자 크기 — 사이드바(130px)에서 모든 메뉴가 한 줄에 들어가는 크기.
메뉴는 전부 이 크기 하나로 통일한다. */
padding: 11px 8px;
cursor: pointer;
font-weight: 200;
font-size: 1.0rem;
font-size: 0.8rem;
transition: var(--transition);
border-left: 3px solid transparent;
/* 어떤 경우에도 두 줄로 넘어가지 않게 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 메뉴 아이콘: 글자 크기와 무관하게 원래 크기(1rem)를 유지한다.
1em이 아니라 rem으로 고정해야 메뉴 글자를 줄여도 아이콘이 같이 작아지지 않는다.
width는 글자 크기가 아니라 아이콘 칸 너비로, 메뉴 글자 시작선을 맞추는 용도. */
.nav-links li .nav-icon {
font-size: 1rem;
line-height: 1;
display: inline-block;
width: 1.25rem;
margin-right: 5px;
text-align: center;
font-style: normal;
vertical-align: -0.1em;
flex-shrink: 0;
/* 흑백 글리프로 떨어지지 않도록 컬러 이모지 폰트를 우선 지정 */
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Segoe UI Symbol", sans-serif;
}
/* '준비중' 같은 상태 뱃지도 좁은 사이드바에 맞춰 축소 */
.nav-links li .nav-badge {
font-size: 0.5rem;
background: #e53e3e;
color: #fff;
padding: 1px 2px;
border-radius: 4px;
margin-left: 3px;
flex-shrink: 0;
white-space: nowrap;
}
.nav-links li:hover {
@@ -158,52 +192,787 @@ p {
.main-grid {
display: grid;
grid-template-columns: 1fr 525px;
grid-template-rows: minmax(0, 1fr);
gap: 10px;
height: calc(100vh - 45px);
overflow: hidden;
}
/* ★★★ CSS Multi-Column 레이아웃 적용 ★★★ */
/* 빈틈없이 차례대로 컬럼을 채우는 가장 강력한 속성 */
.left-col {
.order-workspace {
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-content: flex-start;
overflow: hidden;
}
.order-category-panel {
padding: 8px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(203, 213, 224, 0.9);
border-radius: 8px;
}
.order-category-heading {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #2d3748;
}
.order-category-heading strong {
font-size: 0.82rem;
}
.order-category-heading span {
color: #4a5568;
font-size: 0.68rem;
}
.order-group-buttons {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.order-group-button {
min-height: 28px;
padding: 4px 9px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #cbd5e0;
border-radius: 4px;
background: #fff;
color: #2d3748;
cursor: pointer;
font-size: 0.75rem;
font-weight: 700;
}
.order-group-button:hover,
.order-group-button.has-selection,
.order-group-button.is-active {
border-color: var(--primary-color);
background: #ebf8ff;
}
.order-group-count {
min-width: 18px;
height: 18px;
padding: 0 4px;
display: none;
align-items: center;
justify-content: center;
border-radius: 9px;
background: var(--primary-color);
color: #fff;
font-size: 0.65rem;
line-height: 1;
}
.order-group-button.has-selection .order-group-count {
display: inline-flex;
}
.order-workspace-body {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(270px, 38%);
gap: 10px;
margin-top: 8px;
}
.order-summary-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.order-summary-column .order-selected-panel {
flex: 1;
}
.new-cs-options-slot {
display: none;
}
.order-items-inline-host {
min-width: 0;
min-height: 0;
padding: 8px;
overflow: hidden;
border: 1px solid #cbd5e0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.32);
}
.order-items-inline-empty {
height: 100%;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
color: #4a5568;
font-size: 0.78rem;
text-align: center;
}
.left-col::-webkit-scrollbar {
width: 5px;
.order-items-inline-empty span:first-child {
font-size: 2rem;
}
.left-col::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
.order-selected-panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 9px;
border: 1px solid #cbd5e0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.82);
overflow: hidden;
}
.order-selected-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 6px;
border-bottom: 2px solid var(--primary-color);
}
.order-selected-heading h3 {
margin: 0;
padding: 0;
border: 0;
font-size: 0.85rem;
}
.order-selected-heading span {
color: #2b6cb0;
font-size: 0.72rem;
font-weight: 700;
}
.order-selected-items {
min-height: 0;
flex: 1;
padding-top: 5px;
overflow-y: auto;
}
.order-selected-empty {
padding: 20px 5px;
color: #718096;
font-size: 0.72rem;
text-align: center;
}
.order-selected-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
padding: 6px 3px;
border-bottom: 1px solid #e2e8f0;
font-size: 0.72rem;
}
.order-selected-remove {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 3px;
background: transparent;
color: #e53e3e;
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.order-selected-remove:hover {
background: #fff5f5;
}
.order-selected-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.order-selected-qty {
width: 58px;
height: 24px;
padding: 1px 3px;
border: 1px solid #cbd5e0;
border-radius: 4px;
background: #fff;
color: #2b6cb0;
font-size: 0.7rem;
font-weight: 700;
white-space: nowrap;
}
.order-selected-price {
min-width: 68px;
color: #2d3748;
font-weight: 700;
text-align: right;
white-space: nowrap;
}
.order-selected-totals {
padding: 8px 2px;
border-top: 2px solid #cbd5e0;
flex-shrink: 0;
}
.order-selected-totals > div {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 1px;
font-size: 0.78rem;
}
.order-selected-totals strong {
color: #e53e3e;
font-size: 0.85rem;
}
.order-selected-totals > .order-summary-adjustments {
display: block;
padding: 0;
}
.order-summary-adjustments > div {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 1px;
color: #4a5568;
font-size: 0.72rem;
}
.order-summary-adjustments b {
color: #2d3748;
}
.order-selected-totals > .order-summary-final {
margin-top: 3px;
padding-top: 5px;
border-top: 1px dashed #cbd5e0;
}
.order-preview-button {
width: 100%;
padding: 7px;
flex-shrink: 0;
}
.order-items-inline-host .order-item-group {
width: 100%;
height: 100%;
display: none;
flex-direction: column;
padding: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.9);
}
.order-items-inline-host .order-item-group.is-active {
display: flex;
}
.order-item-group .order-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 6px;
border-bottom: 2px solid var(--primary-color);
flex-shrink: 0;
}
.order-item-group .order-popup-header h3 {
margin: 0;
padding: 0;
border: 0;
font-size: 0.9rem;
}
.order-item-group .item-list {
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 3px 8px;
padding: 8px 3px 2px 0;
overflow-y: auto;
}
.order-item-subgroup {
display: contents;
}
.order-item-subgroup-title {
display: none;
}
.classic-product-column {
display: contents;
}
.order-item-group .item-row {
min-width: 0;
background: #f7fafc;
}
.sms-preview-modal {
position: fixed;
z-index: 2600;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.52);
}
.sms-preview-modal.is-open {
display: flex;
}
.sms-preview-dialog {
width: min(520px, calc(100vw - 30px));
height: min(760px, calc(100vh - 40px));
display: flex;
flex-direction: column;
padding: 12px;
overflow: hidden;
background: #fff;
}
.sms-preview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary-color);
flex-shrink: 0;
}
.sms-preview-heading h3 {
margin: 0;
padding: 0;
border: 0;
font-size: 1rem;
}
#sms-preview {
min-height: 0;
flex: 1;
margin-top: 10px;
overflow-y: auto;
}
.right-col {
display: grid;
grid-template-columns: 260px 250px;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 15px;
height: 100%;
}
.right-panel-col1 {
display: flex;
flex-direction: column;
gap: 8px;
display: contents;
}
.right-panel-col2 {
display: contents;
}
.right-col .customer-info {
grid-column: 2;
grid-row: 1;
min-height: 0;
}
.right-col .history-box {
grid-column: 1;
grid-row: 1;
height: auto !important;
min-height: 0;
margin-bottom: 0 !important;
}
.right-col .manual-history-box {
grid-column: 1;
grid-row: 2;
height: auto !important;
min-height: 0;
}
.right-col .sms-options {
grid-column: 2;
grid-row: 2;
align-self: end;
}
/* New CS 전용 배치: 기존 CS 작업 화면과 입력 요소를 공유합니다. */
#order-tab.order-view-new .main-grid {
grid-template-columns: minmax(0, 1fr) 260px;
}
#order-tab.order-view-new .order-category-heading strong {
font-size: 0.98rem;
}
#order-tab.order-view-new .order-category-heading span {
font-size: 0.82rem;
}
#order-tab.order-view-new .order-group-button {
min-height: 34px;
padding: 5px 11px;
font-size: 0.9rem;
}
#order-tab.order-view-new .order-group-count {
min-width: 21px;
height: 21px;
border-radius: 11px;
font-size: 0.78rem;
}
#order-tab.order-view-new .order-workspace-body {
grid-template-columns: minmax(0, 1fr) 370px;
}
#order-tab.order-view-new .order-summary-column {
width: 370px;
gap: 10px;
}
#order-tab.order-view-new .new-cs-options-slot {
display: block;
flex: 0 0 auto;
}
#order-tab.order-view-new .new-cs-options-slot .sms-options {
width: 370px;
}
#order-tab.order-view-new .right-col {
display: block;
min-width: 0;
}
#order-tab.order-view-new .right-panel-col1 {
height: 100%;
display: grid;
grid-template-rows: 370px minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
#order-tab.order-view-new .right-col .customer-info,
#order-tab.order-view-new .right-col .history-box,
#order-tab.order-view-new .right-col .manual-history-box {
grid-column: 1;
min-height: 0;
height: auto !important;
margin: 0 !important;
}
#order-tab.order-view-new .right-col .customer-info {
grid-row: 1;
height: 370px !important;
overflow-y: auto;
}
#order-tab.order-view-new .customer-info > h3 {
margin-bottom: 4px !important;
}
#order-tab.order-view-new .customer-info .form-group {
margin-bottom: 3px;
}
#order-tab.order-view-new .customer-info .form-group input {
padding-top: 3px;
padding-bottom: 3px;
}
#order-tab.order-view-new .customer-info .radio-group.mt-1,
#order-tab.order-view-new .customer-info .action-buttons.mt-1 {
margin-top: 2px;
}
#order-tab.order-view-new .customer-info .btn {
padding-top: 5px;
padding-bottom: 5px;
}
#order-tab.order-view-new .right-col .history-box {
grid-row: 2;
}
#order-tab.order-view-new .right-col .manual-history-box {
grid-row: 3;
}
#order-tab.order-view-new #cust-address {
height: 100px;
min-height: 100px;
resize: vertical;
}
#order-tab.order-view-new .order-item-group .item-list {
grid-template-columns: repeat(var(--subgroup-count, 1), minmax(0, 250px));
align-items: start;
justify-content: start;
gap: 12px;
overflow-x: auto;
}
#order-tab.order-view-new .order-item-subgroup {
width: 100%;
min-width: 180px;
max-width: 250px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 3px;
padding: 6px;
overflow: hidden;
border: 1px solid #cbd5e0;
border-radius: 7px;
background: #f8fafc;
box-shadow: 0 1px 3px rgba(45, 55, 72, 0.08);
}
#order-tab.order-view-new .order-item-subgroup-title {
min-height: 28px;
display: flex;
align-items: center;
margin: -6px -6px 3px;
padding: 5px 8px;
overflow: hidden;
border-bottom: 1px solid #bee3f8;
border-left: 4px solid #3182ce;
background: #ebf8ff;
color: #2d3748;
font-size: 0.78rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
#order-tab.order-view-new .order-item-subgroup-title-text {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#order-tab.order-view-new .order-item-subgroup-edit {
width: 24px;
height: 22px;
flex: 0 0 24px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 5px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
font-size: 0.78rem;
cursor: pointer;
}
#order-tab.order-view-new .order-item-subgroup-edit:hover,
#order-tab.order-view-new .order-item-subgroup-edit:focus-visible {
border-color: #90cdf4;
background: #fff;
outline: none;
}
#order-tab.order-view-new .order-item-subgroup-title-input {
width: 100%;
height: 24px;
padding: 2px 6px;
border: 1px solid #3182ce;
border-radius: 4px;
background: #fff;
color: #1a202c;
font-size: 0.78rem;
font-weight: 700;
outline: none;
}
#order-tab.order-view-new .order-preview-button {
border: 1px solid #cbd5e0;
background: #e2e8f0;
color: #4a5568;
}
#order-tab.order-view-new .order-preview-button:hover {
background: #cbd5e0;
}
/* CS 작업: 상품 전체가 펼쳐지는 기존 UI */
#order-tab.order-view-classic .order-category-panel,
#order-tab.order-view-classic .order-summary-column {
display: none;
}
#order-tab.order-view-classic .order-workspace-body {
height: 100%;
display: block;
margin-top: 0;
}
#order-tab.order-view-classic .order-items-inline-host {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(6, max-content);
align-content: start;
align-items: start;
gap: 8px;
padding: 0;
overflow: auto;
border: 0;
border-radius: 0;
background: transparent;
}
#order-tab.order-view-classic .classic-product-column {
width: max-content;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
#order-tab.order-view-classic .order-items-inline-empty {
display: none !important;
}
#order-tab.order-view-classic .order-items-inline-host .order-item-group {
width: max-content;
max-width: none;
height: auto;
display: flex;
padding: 8px;
}
#order-tab.order-view-classic .order-item-group .item-list,
#order-tab.order-view-classic .order-item-subgroup {
display: flex;
flex-direction: column;
gap: 1px;
}
#order-tab.order-view-classic .order-item-subgroup + .order-item-subgroup {
margin-top: 8px;
padding-top: 8px;
border-top: 1.5px dashed #1a202c;
}
#order-tab.order-view-classic .right-col {
display: grid;
grid-template-columns: 260px 250px;
grid-template-rows: minmax(0, 1fr);
gap: 15px;
}
#order-tab.order-view-classic .right-panel-col1,
#order-tab.order-view-classic .right-panel-col2 {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
#order-tab.order-view-classic .right-panel-col1 {
grid-column: 1;
grid-row: 1;
}
#order-tab.order-view-classic .right-panel-col2 {
grid-column: 2;
grid-row: 1;
}
#order-tab.order-view-classic .right-col .customer-info {
width: 100%;
height: 330px !important;
flex: 0 0 330px;
}
#order-tab.order-view-classic .right-col .history-box {
width: 100%;
height: 260px !important;
flex: 0 0 260px;
}
#order-tab.order-view-classic .right-col .manual-history-box {
width: 100%;
height: auto !important;
flex: 1 1 0;
}
#order-tab.order-view-classic .sms-preview-modal {
position: static;
width: 100%;
height: auto;
flex: 1 1 0;
min-height: 0;
display: block;
padding: 8px;
overflow: hidden;
border: 1px solid var(--card-border);
border-radius: 8px;
background: var(--card-bg);
box-shadow: var(--card-shadow);
}
#order-tab.order-view-classic .sms-preview-dialog {
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
box-shadow: none;
}
#order-tab.order-view-classic .sms-preview-heading {
display: none;
}
#order-tab.order-view-classic #sms-preview {
height: 100%;
margin: 0;
}
#order-tab.order-view-classic .right-col .sms-options {
width: 100%;
flex: 0 0 auto;
align-self: auto;
}
.right-col::-webkit-scrollbar {
@@ -732,6 +1501,11 @@ p {
border-left-color: transparent;
border-bottom-color: var(--primary-color);
}
/* 가로 스크롤 메뉴에서는 아이콘 칸을 좁혀 폭을 아낀다 */
.nav-links li .nav-icon {
width: auto;
margin-right: 4px;
}
/* 3. Main Grid & Left Col (Product List) */
.main-grid {
@@ -740,16 +1514,13 @@ p {
height: auto;
gap: 15px;
}
.left-col {
height: 45vh; /* Fixed height with internal scroll */
flex-direction: column;
flex-wrap: nowrap;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 5px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
.order-workspace {
height: auto;
min-height: 80vh;
}
.order-workspace-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(45vh, 1fr) minmax(35vh, auto);
}
.item-group-card {
width: 100%;
@@ -764,6 +1535,9 @@ p {
gap: 15px;
}
.right-panel-col1, .right-panel-col2 {
display: flex;
flex-direction: column;
gap: 15px;
width: 100%;
height: auto;
}
@@ -1096,4 +1870,53 @@ p {
.toggle-switch input:checked + .switch-label:before {
transform: translateX(26px);
}
}
/* 반품 송장 일괄 입력: 편집기처럼 입력 줄과 함께 움직이는 줄 번호 */
.bulk-tracking-editor {
--bulk-tracking-border: #cbd5e0;
position: relative;
width: 100%;
}
.bulk-tracking-line-numbers {
position: absolute;
z-index: 1;
top: 1px;
bottom: 1px;
left: 1px;
width: 38px;
padding: 10px 7px 10px 4px;
overflow: hidden;
border-right: 1px solid var(--bulk-tracking-border);
border-radius: 3px 0 0 3px;
background: #f7fafc;
color: #718096;
font-family: monospace;
font-size: 13px;
line-height: 20px;
text-align: right;
white-space: pre;
user-select: none;
pointer-events: none;
}
.bulk-tracking-input {
display: block;
width: 100%;
height: 200px;
padding: 10px 10px 10px 48px;
overflow: auto;
border: 1px solid var(--bulk-tracking-border);
border-radius: 4px;
resize: vertical;
font-family: monospace;
font-size: 13px;
line-height: 20px;
white-space: pre;
}
.bulk-tracking-input:focus {
outline: none;
border-color: var(--bulk-tracking-border);
}
+1243 -120
View File
File diff suppressed because it is too large Load Diff
+841
View File
@@ -0,0 +1,841 @@
// 자사몰 행사 탭 - 카페24 주문/발주 파일 가공
//
// 업로드 → 조건 선택 → 미리보기 → 가공된 엑셀 다운로드 흐름을 담당한다.
// 기능이 늘어나면 initFirstComeGift() 처럼 기능별 초기화 함수를 추가하고
// 왼쪽 '기능' 목록 버튼(.me-feature-btn)과 패널(#me-panel-<feature>)을 연결한다.
(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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 = '<span style="color:#975a16;">조건이 바뀌었습니다. "사은품 적용 결과 보기"를 다시 눌러주세요.</span>';
}
// ---- 파일 업로드 ----
function resetAll() {
currentFile = null;
orderItems = [];
selectedNames.clear();
searchKeyword = '';
fileInput.value = '';
if (orderSearch) orderSearch.value = '';
fileInfo.style.display = 'none';
fileInfo.innerHTML = '';
showFileError('');
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">발주서를 업로드하면 주문 목록이 표시됩니다.</div>';
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 =
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
'<div style="color:#718096;">시트: ' + escapeHtml(data.sheet_name) + ' · 헤더 ' + data.header_row + '행</div>' +
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + orderItems.length + '종</div>';
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 = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">Q열(주문목록)에서 상품을 찾지 못했습니다.</div>';
updateSelectedInfo();
return;
}
const visible = orderItems.filter(function (item) {
return !searchKeyword || normalize(item.name).includes(searchKeyword);
});
if (!visible.length) {
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
updateSelectedInfo();
return;
}
orderListBox.innerHTML = visible.map(function (item) {
const checked = selectedNames.has(item.name) ? ' checked' : '';
return '' +
'<label class="me-order-row" style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
'<input type="checkbox" class="me-order-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
'</label>';
}).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 = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + 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 = '<option value="">이름에 "' + GIFT_NAME_KEYWORD +
'"이 들어간 세트가 코드표에 없습니다</option>';
return;
}
sets.sort(function (a, b) {
return String(a.name || '').localeCompare(String(b.name || ''), 'ko');
});
giftSelect.innerHTML = '<option value="">사은품을 선택하세요</option>' + sets.map(function (s) {
return '<option value="' + escapeHtml(s.item_code) + '" data-name="' + escapeHtml(s.name || '') + '">' +
escapeHtml(s.name || '(이름 없음)') + ' — ' + escapeHtml(s.item_code) + '</option>';
}).join('');
} catch (err) {
console.error(err);
giftSelect.innerHTML = '<option value="">세트 코드를 불러오지 못했습니다 (DB 연결 확인)</option>';
}
}
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('<br>');
} else {
warningBox.style.display = 'none';
warningBox.innerHTML = '';
}
const added = data.added || [];
if (!added.length) {
resultTbody.innerHTML = '<tr><td colspan="4" style="padding:14px; text-align:center; color:#a0aec0;">사은품이 적용된 주문이 없습니다.</td></tr>';
resultInfo.innerHTML = '<span style="color:#c53030;">적용 대상 0건</span>';
setDownloadEnabled(false);
return;
}
resultTbody.innerHTML = added.map(function (row, idx) {
return '<tr>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; color:#a0aec0;">' + (idx + 1) + '</td>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap;">' + escapeHtml(row.order_no) + '</td>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#4a5568;">' + escapeHtml(row.order_date) + '</td>' +
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#2b6cb0;">' + escapeHtml(row.seq) + '</td>' +
'</tr>';
}).join('');
resultInfo.innerHTML = '조건 충족 주문 <b>' + Number(data.matched_order_count).toLocaleString() + '건</b> 중 ' +
'<b style="color:#3182ce;">' + Number(data.applied_count).toLocaleString() + '건</b>에 사은품 행 추가';
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 = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">CSV를 업로드하면 상품 목록이 표시됩니다.</div>';
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 =
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
'<div style="color:#718096;">인코딩: ' + escapeHtml(data.encoding) + ' · 헤더 ' + data.header_row + '행 · ' + data.column_count + '개 열</div>' +
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + productItems.length + '종</div>';
if (data.warnings && data.warnings.length) {
warningBox.style.display = 'block';
warningBox.innerHTML = data.warnings.map(function (w) {
return '⚠ ' + escapeHtml(w);
}).join('<br>');
}
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 = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">K열(주문상품명)에서 상품을 찾지 못했습니다.</div>';
updateSelectedInfo();
return;
}
const visible = productItems.filter(function (item) {
return !searchKeyword || normalize(item.name).includes(searchKeyword);
});
if (!visible.length) {
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
updateSelectedInfo();
return;
}
productListBox.innerHTML = visible.map(function (item) {
const checked = selectedNames.has(item.name) ? ' checked' : '';
return '' +
'<label style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
'<input type="checkbox" class="ee-product-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
'<span style="color:#718096; white-space:nowrap; font-size:0.78rem;">' + Number(item.row_count).toLocaleString() + '행</span>' +
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
'</label>';
}).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 = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + 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 =
'<div style="color:#2f855a; font-weight:600;">✓ 다운로드 완료</div>' +
'<div>조건: <b>' + modeLabel + '</b></div>' +
'<div>상품이 들어있는 행 <b>' + matched.toLocaleString() + '행</b></div>' +
'<div>추출 결과 <b style="color:#3182ce;">' + rows.toLocaleString() + '행</b>' +
' · 주문 <b style="color:#3182ce;">' + orders.toLocaleString() + '건</b></div>' +
'<div style="color:#718096; font-size:0.78rem;">전체 ' + total.toLocaleString() + '행 중</div>';
} catch (err) {
console.error(err);
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
});
clearResult();
}
})();
+363
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 =
'<div id="cal-body"></div>' +
'<div id="cal-overlay" style="display:none; position:absolute; inset:0; z-index:5;' +
' background:rgba(255,255,255,0.82); border-radius:4px;' +
' align-items:center; justify-content:center; gap:8px; flex-direction:column;">' +
'<div style="width:22px; height:22px; border:3px solid #cbd5e0; border-top-color:#3182ce;' +
' border-radius:50%; animation:milkrun-spin 0.8s linear infinite;"></div>' +
'<div id="cal-overlay-text" style="font-size:0.82rem; font-weight:600; color:#2d3748;">데이터 읽는 중...</div>' +
'</div>';
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 = '' +
'<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:6px;">' +
'<button type="button" class="cal-nav" data-move="-1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">◀</button>' +
'<div style="font-size:0.88rem; font-weight:600; color:#2d3748;">' + viewYear + '년 ' + (viewMonth + 1) + '월' +
'<span style="color:#a0aec0; font-weight:400; font-size:0.75rem;"> · 출고 ' + monthCount + '일</span></div>' +
'<button type="button" class="cal-nav" data-move="1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">▶</button>' +
'</div>' +
'<div style="display:grid; grid-template-columns:repeat(7,1fr); gap:2px;">';
DOW.forEach(function (label, index) {
const color = index === 0 ? '#e53e3e' : (index === 6 ? '#3182ce' : '#718096');
html += '<div style="text-align:center; font-size:0.7rem; color:' + color + '; padding:2px 0;">' + label + '</div>';
});
for (let i = 0; i < startDow; i++) html += '<div></div>';
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 += '<div' + attrs + ' style="' + style + '">' + day + '</div>';
}
html += '</div>';
if (placeholderText) {
html += '<div style="text-align:center; color:#a0aec0; font-size:0.75rem; padding:6px 0 2px;">' +
escapeHtml(placeholderText) + '</div>';
}
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 =
'<b>' + escapeHtml(data.file_name || '쿠팡 밀크런 출고리스트') + '</b>' +
' <span style="color:#a0aec0;">· 출고일 ' + Object.keys(sheetsByDate).length + '일' +
(data.cached ? ' · 최근 결과' : '') + '</span>';
// 항상 오늘 날짜를 기준으로 본다
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(
'<span style="font-size:0.95rem; font-weight:700;">오늘 출고할 밀크런은 없습니다.</span>',
'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('<b>' + escapeHtml(sheetName) + '</b> 출고리스트를 읽는 중...', '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('<b>' + escapeHtml(sheetName) + '</b>에서 제품코드/수량을 찾지 못했습니다. ' +
'시트 형식을 확인해주세요.', 'error');
return;
}
if (pasteInput) pasteInput.value = data.text || '';
if (analyzeBtn) analyzeBtn.click(); // 기존 분석 로직 재사용
const skipped = data.skipped
? ' <span style="color:#975a16;">(건너뛴 줄 ' + data.skipped + '개)</span>' : '';
showMessage('<b>' + escapeHtml(sheetName) + '</b> · ' + 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);
});
})();