feat: 전화접수 관리 — 스캔 현황·포장량·포장 명세 보기 UI

전화접수 관리 화면에 스캔 현황(포장량 합계, 최근 스캔), 포장 명세 보기 모달 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-07-16 15:45:28 +09:00
parent 5e8e81f404
commit 6c421153e4

View File

@@ -121,26 +121,25 @@
<div class="mt-3"> <div class="mt-3">
<div class="flex items-center justify-between px-1 mb-1 text-sm"> <div class="flex items-center justify-between px-1 mb-1 text-sm">
<span class="font-semibold text-gray-700">포장 명세</span> <span class="font-semibold text-gray-700">스캔 현황</span>
<span class="flex items-center gap-2">
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span> <span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
<button type="button" id="btn-packing-modal" class="border border-gray-300 text-gray-600 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50 disabled:opacity-50" disabled>포장 명세 보기</button>
</span>
</div> </div>
<div id="packing-scroll" class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<table class="w-full data-table text-sm"> <!-- 최근 스캔 2건 -->
<thead> <div class="border border-gray-300 rounded-lg overflow-hidden">
<tr> <div class="px-2 py-1 bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-600">최근 스캔</div>
<th class="w-10 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th> <div id="recent-scans" class="p-2 text-xs text-gray-400 min-h-[3.5rem]">스캔 내역이 없습니다.</div>
<th class="w-40 text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-16 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
<th class="w-16 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr>
</thead>
<tbody id="packing-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">주문을 선택해 주세요.</td></tr>
</tbody>
</table>
</div> </div>
</div><!-- /포장 명세 --> <!-- 앞으로 찍을 목록(품목별 남은 수량) -->
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
<div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
</div>
</div>
</div><!-- /스캔 현황 -->
</form> </form>
<!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) --> <!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
@@ -150,6 +149,36 @@
</section> </section>
</div> </div>
<!-- 포장 명세(전체 봉투코드) 팝업 -->
<div id="packing-modal-overlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<span class="text-sm font-bold text-gray-800">포장 명세 — <span id="packing-modal-title">-</span></span>
<button type="button" id="packing-modal-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">&times;</button>
</div>
<div class="p-3 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="w-40 text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
<th class="w-16 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr>
</thead>
<tbody id="packing-modal-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>
</tbody>
</table>
</div>
<div class="border-t border-gray-200 px-4 py-2 flex items-center justify-between">
<span class="text-xs text-gray-500">총 <span id="packing-modal-count">0</span>건 · 포장량 <span id="packing-modal-total">0</span></span>
<button type="button" id="packing-modal-close2" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">닫기</button>
</div>
</div>
</div>
<!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) --> <!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) -->
<div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm"> <div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm">
<p class="text-[11px] text-amber-900 mb-1 leading-relaxed"> <p class="text-[11px] text-amber-900 mb-1 leading-relaxed">
@@ -464,9 +493,6 @@
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신 // 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
if (String(selectedId) === String(data.so_idx)) { if (String(selectedId) === String(data.so_idx)) {
renderPacking(order); renderPacking(order);
// 새로 스캔한 봉투가 항상 맨 위(최신)에 보이도록 스크롤을 맨 위로 이동
const packingScrollEl = document.getElementById('packing-scroll');
if (packingScrollEl) packingScrollEl.scrollTop = 0;
const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]'); const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]');
if (cell) { if (cell) {
cell.textContent = nf(Number(data.packed_qty || 0)); cell.textContent = nf(Number(data.packed_qty || 0));
@@ -580,32 +606,93 @@
applySplit(currentPct()); applySplit(currentPct());
} }
// ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ────────── // ── 스캔 현황(최근 2건 + 앞으로 찍을 목록) + 포장 명세 팝업 ──────────
const packingBody = document.getElementById('packing-body');
const packingTotal = document.getElementById('packing-total'); const packingTotal = document.getElementById('packing-total');
const recentScansEl = document.getElementById('recent-scans');
const remainingListEl = document.getElementById('remaining-list');
const btnPackingModal = document.getElementById('btn-packing-modal');
const packingModalOverlay = document.getElementById('packing-modal-overlay');
const packingModalBody = document.getElementById('packing-modal-body');
const packingModalTitle = document.getElementById('packing-modal-title');
const packingModalCount = document.getElementById('packing-modal-count');
const packingModalTotal = document.getElementById('packing-modal-total');
let packingModalOrderId = null;
function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); } function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
// 포장 명세 = 실제 스캔(=판매/포장)된 봉투코드 내역. 최근 스캔이 위로 오도록 역순 표시.
function renderPacking(order) { // 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
if (!packingBody) return; function scanFullRows(order) {
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : []; const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
let no = 0; let no = 0, total = 0;
let totalQty = 0;
const rows = scans.map((s) => { const rows = scans.map((s) => {
no++; no++;
const code = s.bag_code || ''; const code = s.bag_code || '', name = s.bag_name || '', qty = parseInt(s.qty || 0, 10) || 0;
const name = s.bag_name || ''; total += qty;
const qty = parseInt(s.qty || 0, 10) || 0;
totalQty += qty;
return '<tr><td class="text-center">' + no + '</td>' return '<tr><td class="text-center">' + no + '</td>'
+ '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>' + '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>' + '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
+ '<td class="text-right pr-2">' + nf(qty) + '</td>' + '<td class="text-right pr-2">' + nf(qty) + '</td>'
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>'; + '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
}); });
packingBody.innerHTML = rows.length ? rows.join('') return { html: rows.join(''), count: no, total: total };
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
if (packingTotal) packingTotal.textContent = nf(totalQty);
} }
function fillPackingModal(order) {
const full = scanFullRows(order);
if (packingModalBody) packingModalBody.innerHTML = full.count ? full.html
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
if (packingModalCount) packingModalCount.textContent = nf(full.count);
if (packingModalTotal) packingModalTotal.textContent = nf(full.total);
}
// 앞으로 찍을 목록 = 품목별 남은(접수량 포장량)
function renderRemaining(order) {
if (!remainingListEl) return;
const items = Array.isArray(order.items) ? order.items : [];
const rows = items.map((it) => {
const qty = parseInt(it.soi_qty || 0, 10) || 0;
const packed = parseInt(it.soi_packed_qty || 0, 10) || 0;
if (qty <= 0) return '';
const remain = Math.max(0, qty - packed);
const name = (String(it.soi_bag_code || '') + ' ' + String(it.soi_bag_name || '')).trim();
if (remain <= 0) {
return '<div class="flex items-center justify-between text-emerald-600 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap">✓ 완료</span></div>';
}
return '<div class="flex items-center justify-between text-gray-700 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap font-semibold text-amber-700">' + nf(remain) + ' 매</span></div>';
}).filter(Boolean);
remainingListEl.innerHTML = rows.length ? rows.join('') : '<span class="text-gray-400">남은 수량이 없습니다.</span>';
}
// 스캔 현황 갱신(최근 2건 + 남은 목록 + 포장량 + 열린 팝업)
function renderPacking(order) {
const scans = Array.isArray(order.scans) ? order.scans : [];
const full = scanFullRows(order);
if (packingTotal) packingTotal.textContent = nf(full.total);
if (btnPackingModal) btnPackingModal.disabled = false;
if (recentScansEl) {
const last = scans.slice(-2).reverse();
recentScansEl.innerHTML = last.length
? last.map((s, i) => '<div class="flex items-center justify-between py-0.5 ' + (i === 0 ? 'font-semibold text-emerald-700' : 'text-gray-500') + '">'
+ '<span class="truncate mr-2 font-mono">' + (i === 0 ? '▶ ' : '') + packEsc(s.code || '') + '</span>'
+ '<span class="whitespace-nowrap text-gray-500">' + packEsc(s.bag_name || '') + ' · ' + nf(parseInt(s.qty || 0, 10) || 0) + '</span></div>').join('')
: '<span class="text-gray-400">스캔 내역이 없습니다.</span>';
}
renderRemaining(order);
if (packingModalOverlay && !packingModalOverlay.classList.contains('hidden') && String(packingModalOrderId) === String(order.so_idx)) {
fillPackingModal(order);
}
}
// 포장 명세 팝업 열기/닫기
function openPackingModal() {
const order = orderMap.get(String(selectedId));
if (!order) return;
packingModalOrderId = selectedId;
if (packingModalTitle) packingModalTitle.textContent = '접수 #' + displayNo(order) + ' · ' + (order.so_ds_name || '');
fillPackingModal(order);
packingModalOverlay.classList.remove('hidden');
packingModalOverlay.classList.add('flex');
}
function closePackingModal() { packingModalOverlay.classList.add('hidden'); packingModalOverlay.classList.remove('flex'); }
if (btnPackingModal) btnPackingModal.addEventListener('click', openPackingModal);
document.getElementById('packing-modal-close')?.addEventListener('click', closePackingModal);
document.getElementById('packing-modal-close2')?.addEventListener('click', closePackingModal);
if (packingModalOverlay) packingModalOverlay.addEventListener('click', (e) => { if (e.target === packingModalOverlay) closePackingModal(); });
// ── 접수 리스트 렌더 + 헤더 클릭 정렬 ────────────────────────── // ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────
let sortKey = 'so_idx', sortDir = 'desc', selectedId = null; let sortKey = 'so_idx', sortDir = 'desc', selectedId = null;
@@ -668,6 +755,7 @@
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100')); listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100'); tr.classList.add('bg-blue-100');
renderDetail(selectedId); renderDetail(selectedId);
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
}); });
detailBody?.addEventListener('input', (e) => { detailBody?.addEventListener('input', (e) => {