여러 엑셀 파일 업로드 시 업로드 날짜 지정 기능 및 낱개 출고 자동분석 다운로드 기능 구현

This commit is contained in:
2026-05-26 23:01:22 +09:00
parent ce183f057d
commit ae2a624f41
5 changed files with 550 additions and 94 deletions
+150 -1
View File
@@ -1835,4 +1835,153 @@ input[type="checkbox"] {
.nav-profile-area .btn-logout-text span { display: none; }
}
/* Force UI Update */
/* ==========================================================================
Upload Date Picker Custom Calendar Styles
========================================================================== */
#upload-date-modal .modal-content {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.modal-instruction-text {
font-size: 0.88rem;
color: #64748b;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.custom-calendar-picker {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1rem;
background: #ffffff;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02);
margin-bottom: 1.25rem;
}
.calendar-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.calendar-nav .icon-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #475569;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.calendar-nav .icon-btn:hover {
background: #e2e8f0;
color: #0f172a;
}
.calendar-title {
font-size: 0.98rem;
font-weight: 700;
color: #1e293b;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
font-weight: 700;
font-size: 0.78rem;
color: #64748b;
margin-bottom: 0.5rem;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 0.5rem;
}
.calendar-weekdays .weekday.holiday {
color: #dc2626; /* Sunday red */
}
.calendar-weekdays .weekday.sat {
color: #2563eb; /* Saturday blue */
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
row-gap: 0.35rem;
text-align: center;
}
.calendar-day {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0 auto;
font-size: 0.85rem;
font-weight: 500;
color: #334155;
border-radius: 50%;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.calendar-day:hover:not(.other-month):not(.selected) {
background-color: #f1f5f9;
}
.calendar-day.other-month {
color: #cbd5e1;
cursor: default;
}
.calendar-day.sat:not(.other-month):not(.selected) {
color: #2563eb; /* Saturday blue */
}
.calendar-day.holiday:not(.other-month):not(.selected) {
color: #dc2626; /* Sunday / Korean holiday red */
}
.calendar-day.selected {
background-color: var(--primary-color) !important;
color: #ffffff !important;
font-weight: 700;
transform: scale(1.1);
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.3);
}
.selected-date-preview {
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 8px;
padding: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.88rem;
}
.selected-date-preview span {
color: #64748b;
}
.selected-date-preview strong {
color: var(--primary-color);
font-weight: 700;
font-size: 0.95rem;
}
+45
View File
@@ -406,6 +406,51 @@
</div>
</div>
<!-- Upload Date Picker Modal -->
<div id="upload-date-modal" class="modal-overlay">
<div class="modal-content glass-panel" style="max-width: 400px;">
<div class="modal-header">
<h2><i class="fa-regular fa-calendar-check"></i> 업로드 날짜 지정</h2>
<button type="button" class="modal-close" id="upload-date-close">&times;</button>
</div>
<div class="modal-body">
<p class="modal-instruction-text">발주 데이터의 주문 날짜로 입력될 날짜를 선택해주세요.</p>
<!-- Custom Calendar Picker Container -->
<div class="custom-calendar-picker">
<div class="calendar-nav">
<button type="button" id="cal-prev-month" class="icon-btn"><i class="fa-solid fa-chevron-left"></i></button>
<span id="cal-month-year-display" class="calendar-title">2026년 05월</span>
<button type="button" id="cal-next-month" class="icon-btn"><i class="fa-solid fa-chevron-right"></i></button>
</div>
<div class="calendar-weekdays">
<div class="weekday holiday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday sat"></div>
</div>
<div id="calendar-days-grid" class="calendar-days">
<!-- Days will be rendered via JS -->
</div>
</div>
<div class="selected-date-preview">
<span>선택된 날짜:</span>
<strong id="selected-date-text">선택 안 됨</strong>
</div>
</div>
<div class="modal-footer button-row">
<button id="btn-cancel-upload-date" class="btn btn-secondary">취소</button>
<button id="btn-submit-upload-date" class="btn btn-primary" disabled>
<i class="fa-solid fa-cloud-arrow-up"></i> 업로드 실행
</button>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container"></div>
+179 -59
View File
@@ -270,82 +270,202 @@ document.addEventListener('DOMContentLoaded', () => {
uploadProgressModal.style.display = 'none';
});
fileUpload.addEventListener('change', async (e) => {
// --- Upload Date picker calendar modal elements ---
const uploadDateModal = document.getElementById('upload-date-modal');
const uploadDateClose = document.getElementById('upload-date-close');
const btnCancelUploadDate = document.getElementById('btn-cancel-upload-date');
const btnSubmitUploadDate = document.getElementById('btn-submit-upload-date');
const calPrevMonth = document.getElementById('cal-prev-month');
const calNextMonth = document.getElementById('cal-next-month');
const calMonthYearDisplay = document.getElementById('cal-month-year-display');
const calendarDaysGrid = document.getElementById('calendar-days-grid');
const selectedDateText = document.getElementById('selected-date-text');
let pendingFiles = null;
let calendarActiveDate = new Date();
let calendarSelectedDate = null;
function isKoreanHoliday(year, month, date) {
const mm = String(month + 1).padStart(2, '0');
const dd = String(date).padStart(2, '0');
const mmdd = `${mm}-${dd}`;
const solarHolidays = [
"01-01", "03-01", "05-05", "06-06", "08-15", "10-03", "10-09", "12-25"
];
if (solarHolidays.includes(mmdd)) return true;
if (year === 2026) {
const holidays2026 = [
"02-16", "02-17", "02-18", "02-19",
"05-25",
"09-24", "09-25", "09-26", "09-28"
];
return holidays2026.includes(mmdd);
}
if (year === 2025) {
const holidays2025 = [
"01-27", "01-28", "01-29", "01-30",
"05-06",
"10-05", "10-06", "10-07", "10-08"
];
return holidays2025.includes(mmdd);
}
return false;
}
function renderCalendar() {
const year = calendarActiveDate.getFullYear();
const month = calendarActiveDate.getMonth();
calMonthYearDisplay.textContent = `${year}${String(month + 1).padStart(2, '0')}`;
calendarDaysGrid.innerHTML = '';
const firstDayIndex = new Date(year, month, 1).getDay();
const lastDay = new Date(year, month + 1, 0).getDate();
const prevLastDay = new Date(year, month, 0).getDate();
for (let i = firstDayIndex - 1; i >= 0; i--) {
const dayDiv = document.createElement('div');
dayDiv.className = 'calendar-day other-month';
dayDiv.textContent = prevLastDay - i;
calendarDaysGrid.appendChild(dayDiv);
}
const weekdays = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"];
for (let day = 1; day <= lastDay; day++) {
const dayDiv = document.createElement('div');
dayDiv.className = 'calendar-day';
dayDiv.textContent = day;
const currentDate = new Date(year, month, day);
const dayOfWeek = currentDate.getDay();
if (dayOfWeek === 0 || isKoreanHoliday(year, month, day)) {
dayDiv.classList.add('holiday');
} else if (dayOfWeek === 6) {
dayDiv.classList.add('sat');
}
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (calendarSelectedDate === dateStr) {
dayDiv.classList.add('selected');
}
dayDiv.addEventListener('click', () => {
calendarSelectedDate = dateStr;
calendarDaysGrid.querySelectorAll('.calendar-day.selected').forEach(el => {
el.classList.remove('selected');
});
dayDiv.classList.add('selected');
selectedDateText.textContent = `${year}${month + 1}${day}일 (${weekdays[dayOfWeek]})`;
btnSubmitUploadDate.disabled = false;
});
calendarDaysGrid.appendChild(dayDiv);
}
const totalCells = firstDayIndex + lastDay;
const nextDaysToFill = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);
for (let day = 1; day <= nextDaysToFill; day++) {
const dayDiv = document.createElement('div');
dayDiv.className = 'calendar-day other-month';
dayDiv.textContent = day;
calendarDaysGrid.appendChild(dayDiv);
}
}
calPrevMonth.addEventListener('click', () => {
calendarActiveDate.setMonth(calendarActiveDate.getMonth() - 1);
renderCalendar();
});
calNextMonth.addEventListener('click', () => {
calendarActiveDate.setMonth(calendarActiveDate.getMonth() + 1);
renderCalendar();
});
const closeUploadDateModal = () => {
uploadDateModal.classList.remove('show');
pendingFiles = null;
fileUpload.value = '';
};
if (uploadDateClose) uploadDateClose.addEventListener('click', closeUploadDateModal);
if (btnCancelUploadDate) btnCancelUploadDate.addEventListener('click', closeUploadDateModal);
fileUpload.addEventListener('change', (e) => {
const files = e.target.files;
if (!files || files.length === 0) return;
logUiEvent('파일 업로드 파일 선택', {
count: files.length,
filenames: Array.from(files).map(f => f.name)
});
pendingFiles = files;
calendarActiveDate = new Date();
calendarSelectedDate = null;
btnSubmitUploadDate.disabled = true;
selectedDateText.textContent = '선택 안 됨';
renderCalendar();
uploadDateModal.classList.add('show');
});
btnSubmitUploadDate.addEventListener('click', async () => {
if (!pendingFiles || !calendarSelectedDate) return;
btnSubmitUploadDate.disabled = true;
const originalHtml = btnSubmitUploadDate.innerHTML;
btnSubmitUploadDate.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 업로드 및 분석 중...';
const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
for (let i = 0; i < pendingFiles.length; i++) {
formData.append('files', pendingFiles[i]);
}
formData.append('upload_token', uploadToken);
resetUploadProgressModal();
uploadProgressModal.style.display = 'flex';
fileUpload.disabled = true;
const eventSource = new EventSource(appUrl(`/api/orders/progress?upload_token=${encodeURIComponent(uploadToken)}`));
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.status !== 'idle') {
setUploadProgress(data);
}
if (data.status === 'completed' || data.status === 'error') {
eventSource.close();
fileUpload.disabled = false;
fileUpload.value = '';
if (data.status === 'completed') {
showToast(data.message || '업로드와 검색 인덱스 반영이 완료되었습니다.', 'success');
} else {
showToast(data.message || '업로드 실패', 'error');
}
}
} catch (err) {
console.error('Upload progress parse error', err);
}
};
formData.append('custom_date', calendarSelectedDate);
try {
const res = await fetch(appUrl(`/api/orders/upload`), {
const response = await fetch(appUrl(`/api/orders/upload`), {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
if (!['completed', 'error'].includes(latestUploadProgress?.status)) {
setUploadProgress({
...(latestUploadProgress || {}),
status: data.status || 'queued',
message: data.message || '업로드 작업이 시작되었습니다.'
});
if (response.ok) {
let downloadFilename = '낱개상품출고.xls';
const disposition = response.headers.get('Content-Disposition');
if (disposition && disposition.indexOf('filename*=') !== -1) {
const matches = disposition.split("filename*=UTF-8''");
if (matches.length > 1) {
downloadFilename = decodeURIComponent(matches[1]);
}
}
const fileBlob = await response.blob();
const downloadUrl = window.URL.createObjectURL(fileBlob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = downloadFilename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(downloadUrl);
showToast('업로드 완료 및 출고 파일 다운로드 완료', 'success');
uploadDateModal.classList.remove('show');
} else {
showToast(data.detail || 'Upload failed', 'error');
setUploadProgress({
status: 'error',
message: data.detail || '업로드 실패'
});
eventSource.close();
fileUpload.disabled = false;
fileUpload.value = '';
let errMsg = '업로드 분석 실패';
try {
const data = await response.json();
errMsg = data.detail || errMsg;
} catch(_) {}
showToast(errMsg, 'error');
}
} catch (error) {
showToast('네트워크 오류가 발생했습니다.', 'error');
setUploadProgress({
status: 'error',
message: '네트워크 오류가 발생했습니다.'
});
eventSource.close();
fileUpload.disabled = false;
} finally {
btnSubmitUploadDate.disabled = false;
btnSubmitUploadDate.innerHTML = originalHtml;
fileUpload.value = '';
pendingFiles = null;
}
});