Files
jongryangje/app/Views/bag/designated_shop_sale.php
taekyoungc a8afaf4af2 style: 표/패널 UI 전면 통일 + 화면설명 드로어·글씨크기·탭 개선
표 디자인
- 모든 표를 가벼운 스타일로 통일(.data-table 경량화: 작은 회색 헤더·연한 구분선·hover)
- 표/패널 바깥 테두리 둥글게(rounded-lg) 일괄 적용, 표 래퍼에 패딩 카드(p-4) 통일
- 표 헤더·데이터 정렬을 전 화면 좌측 기준으로 통일
  - .data-table th/td text-align:left (전역), 흩어진 center/right 정렬 정리
  - 재디자인 Tailwind 표(포장단위·단가·기본코드·담당자·업체·판매대행소·무료대상자·지정판매소)도 셀 좌측화
- 기본정보관리 등 나머지 소메뉴 표를 기본 코드 관리 스타일(가벼운 표·상태 pill)로 재디자인

워크스페이스/공통
- "이 화면 설명" → 새 탭 대신 우측 드로어 팝업(현재 화면과 동시에 보기, Esc·드래그 폭조절)
- 상단바 글씨 크기 조절(A−/A+), 작업 내용에 zoom 적용
- 탭 최대치 도달 시 자동 삭제 대신 안내 토스트, "모두 닫기"(업무 현황 탭은 보존)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:26:36 +09:00

521 lines
22 KiB
PHP

<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 판매</span>
</section>
<div class="border border-gray-300 rounded-lg p-3 mt-2 bg-white space-y-3">
<form id="sale-save-form" action="<?= base_url('bag/sale/designated/save') ?>" method="POST">
<?= csrf_field() ?>
<input type="hidden" name="so_idx" id="save-so-idx" value=""/>
<input type="hidden" name="ds_idx" id="save-ds-idx" value=""/>
<input type="hidden" name="scans_json" id="save-scans-json" value="[]"/>
<div class="flex flex-wrap items-end gap-2">
<div class="relative">
<label class="block text-xs font-bold text-gray-700 mb-1">판매소 검색</label>
<input id="shop-search" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-[36rem] max-w-full" placeholder="코드/상호/대표자/전화/주소"/>
<datalist id="shop-search-list" class="hidden">
<?php foreach (($shops ?? []) as $shop): ?>
<option value="<?= esc(trim(($shop->ds_shop_no ?? '') . ' ' . ($shop->ds_name ?? '') . ' ' . ($shop->ds_rep_name ?? '') . ' ' . ($shop->ds_tel ?? '') . ' ' . ($shop->ds_addr ?? ''))) ?>"></option>
<?php endforeach; ?>
</datalist>
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[36rem] max-w-full max-h-56 overflow-auto border border-gray-300 rounded-lg bg-white shadow-lg z-20"></div>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">봉투코드 입력(스캔)</label>
<input id="barcode-input" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-80" placeholder="박스/팩/낱장 바코드"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">판매일</label>
<div class="border border-gray-300 bg-gray-100 rounded px-2 py-1.5 text-sm w-36"><?= esc(date('Y-m-d')) ?></div>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">판매 저장</button>
</div>
</form>
<div id="scan-message" class="text-sm text-gray-600"></div>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300 rounded-lg">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left px-3 py-1.5 w-28">판매소 코드</th><td id="shop-info-code" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">상호</th><td id="shop-info-name" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표자명</th><td id="shop-info-rep" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표전화</th><td id="shop-info-tel" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">주소</th><td id="shop-info-addr" class="px-3 py-1.5">-</td></tr>
</table>
</section>
<section class="xl:col-span-3 border border-gray-300 rounded-lg">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">주문 접수 리스트</div>
<div class="max-h-56 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14 text-center">번호</th>
<th class="text-left">판매소</th>
<th class="w-28 text-center">접수일</th>
<th class="w-28 text-center">배달일</th>
<th class="w-16 text-center">상태</th>
</tr>
</thead>
<tbody id="order-list-body"></tbody>
</table>
</div>
</section>
</div>
<section id="dev-saleable-panel" class="hidden border border-amber-400 bg-amber-50/50 p-3 rounded-sm">
<p class="text-xs text-amber-900 mb-2 leading-relaxed">
<strong class="text-amber-950">[개발용 임시]</strong>
<strong>주문 접수 리스트</strong>에서 주문을 선택하면, 그 주문의 지정판매소(<code class="bg-amber-100 px-1 rounded">so_ds_idx</code>) 기준으로
판매 테스트에 쓸 수 있는 바코드 후보를 표시합니다.
(① 해당 판매소에 연결된 <code class="bg-amber-100 px-1 rounded">bag_sale_scan_code</code> 중 <code class="bg-amber-100 px-1 rounded">in_stock</code>,
② 같은 판매소의 <strong>전화·정상 주문</strong> 품목 봉투코드(수령완료 포함, 주문 리스트와 동일 범위) 및 <strong>선택한 주문</strong> 품목에 맞는 <code class="bg-amber-100 px-1 rounded">bag_receiving_pack_code</code> <code class="bg-amber-100 px-1 rounded">in_stock</code> 팩 코드.
입고 행의 <strong>수량</strong>은 팩에 담긴 <code class="bg-amber-100 px-1 rounded">brpc_sheet_qty</code>(낱장 수)입니다.)
<strong>개발 완료 후 이 블록과 API 라우트를 제거</strong>해 주세요.
</p>
<div class="max-h-52 overflow-auto border border-amber-300 bg-white">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="w-28 text-left">출처</th>
<th class="w-40 text-center">바코드(대표)</th>
<th class="text-left">봉투 종류</th>
<th class="w-16 text-center">포장</th>
<th class="w-10 text-right">수량</th>
<th class="w-12 text-center">주문</th>
<th class="w-14 text-center">상태</th>
<th class="text-left">비고(낱장범위 등)</th>
</tr>
</thead>
<tbody id="dev-saleable-tbody">
<tr><td colspan="8" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>
</tbody>
</table>
</div>
</section>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
<section class="border border-gray-300 rounded-lg">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 내역</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14 text-center">선택</th>
<th class="text-left">봉투 종류</th>
<th class="w-20 text-right">접수량</th>
<th class="w-20 text-right">판매량</th>
<th class="w-20 text-right">단가</th>
<th class="w-24 text-right">판매금액</th>
</tr>
</thead>
<tbody id="sale-items-body">
<tr><td colspan="6" class="text-center text-gray-400 py-6">주문을 선택해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="border border-gray-300 rounded-lg">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 상세 내역</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="text-left">봉투 종류</th>
<th class="w-28 text-center">봉투 코드</th>
<th class="w-16 text-right">수량</th>
<th class="w-16 text-center">포장단위</th>
</tr>
</thead>
<tbody id="scan-detail-body">
<tr><td colspan="4" class="text-center text-gray-400 py-6">바코드를 스캔해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
<script>
(() => {
const shops = <?= json_encode(array_map(static function ($s): array {
return [
'ds_idx' => (int) ($s->ds_idx ?? 0),
'ds_shop_no' => (string) ($s->ds_shop_no ?? ''),
'ds_name' => (string) ($s->ds_name ?? ''),
'ds_rep_name' => (string) ($s->ds_rep_name ?? ''),
'ds_tel' => (string) ($s->ds_tel ?? ''),
'ds_addr' => trim((string) ($s->ds_addr ?? '') . ' ' . (string) ($s->ds_addr_detail ?? '')),
];
}, $shops ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const scanApi = '<?= base_url('bag/sale/designated/scan') ?>';
const devSaleableApi = '<?= base_url('bag/sale/designated/dev-saleable-barcodes') ?>';
const csrfName = '<?= csrf_token() ?>';
const csrfHash = '<?= csrf_hash() ?>';
const shopSearch = document.getElementById('shop-search');
const shopSuggest = document.getElementById('shop-search-suggest');
const barcodeInput = document.getElementById('barcode-input');
const orderListBody = document.getElementById('order-list-body');
const saleItemsBody = document.getElementById('sale-items-body');
const scanDetailBody = document.getElementById('scan-detail-body');
const saveForm = document.getElementById('sale-save-form');
const saveSoIdx = document.getElementById('save-so-idx');
const saveDsIdx = document.getElementById('save-ds-idx');
const saveScansJson = document.getElementById('save-scans-json');
const scanMessage = document.getElementById('scan-message');
const devSaleablePanel = document.getElementById('dev-saleable-panel');
const devSaleableTbody = document.getElementById('dev-saleable-tbody');
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
let selectedShop = null;
let selectedOrder = null;
let selectedBagCode = '';
const pendingScans = [];
const shopMap = new Map(shops.map((s) => [String(s.ds_idx), s]));
function setMessage(msg, isError = false) {
scanMessage.textContent = msg || '';
scanMessage.className = isError ? 'text-sm text-red-600' : 'text-sm text-emerald-700';
}
function mergedShopText(shop) {
return [shop.ds_shop_no, shop.ds_name, shop.ds_rep_name, shop.ds_tel, shop.ds_addr]
.filter(Boolean)
.join(' ');
}
function hideSuggest() {
if (shopSuggest) {
shopSuggest.classList.add('hidden');
shopSuggest.innerHTML = '';
}
}
function renderSuggest(query) {
if (!shopSuggest) return;
const q = String(query || '').trim().toLowerCase();
const matched = (q
? shops.filter((s) => mergedShopText(s).toLowerCase().includes(q))
: shops
).slice(0, 30);
if (matched.length === 0) {
hideSuggest();
return;
}
shopSuggest.innerHTML = matched.map((s) => `
<button type="button" class="shop-suggest-item w-full text-left px-2 py-1.5 hover:bg-blue-50 text-xs border-b border-gray-100 whitespace-normal break-all" data-ds-idx="${s.ds_idx}">
${mergedShopText(s)}
</button>
`).join('');
shopSuggest.classList.remove('hidden');
}
function hideDevSaleablePanel() {
if (devSaleablePanel) devSaleablePanel.classList.add('hidden');
if (devSaleableTbody) {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>';
}
}
async function loadDevSaleableBarcodes(dsIdx) {
if (!devSaleablePanel || !devSaleableTbody) return;
const idx = Number(dsIdx || 0);
if (!idx) {
hideDevSaleablePanel();
return;
}
devSaleablePanel.classList.remove('hidden');
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>';
try {
let devUrl = `${devSaleableApi}?ds_idx=${encodeURIComponent(String(idx))}`;
if (selectedOrder && Number(selectedOrder.so_idx || 0) > 0) {
devUrl += `&so_idx=${encodeURIComponent(String(selectedOrder.so_idx))}`;
}
const res = await fetch(devUrl, { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok) {
devSaleableTbody.innerHTML = `<tr><td colspan="8" class="text-center text-red-600 py-4">${data.message || '조회 실패'}</td></tr>`;
return;
}
const rows = Array.isArray(data.rows) ? data.rows : [];
if (rows.length === 0) {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">표시할 바코드가 없습니다.</td></tr>';
return;
}
devSaleableTbody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-1">${r.source || ''}</td>
<td class="text-center font-mono">${r.code || ''}</td>
<td class="text-left pl-1">${r.bag_code || ''} ${r.bag_name || ''}</td>
<td class="text-center">${r.unit || ''}</td>
<td class="text-right pr-1">${nf(r.qty || 0)}</td>
<td class="text-center">${r.so_idx ? String(r.so_idx) : '-'}</td>
<td class="text-center">${r.state || ''}</td>
<td class="text-left pl-1">${r.extra || ''}</td>
</tr>
`).join('');
} catch {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-red-600 py-4">네트워크 오류</td></tr>';
}
}
function updateShopInfo(shop) {
document.getElementById('shop-info-code').textContent = shop?.ds_shop_no || '-';
document.getElementById('shop-info-name').textContent = shop?.ds_name || '-';
document.getElementById('shop-info-rep').textContent = shop?.ds_rep_name || '-';
document.getElementById('shop-info-tel').textContent = shop?.ds_tel || '-';
document.getElementById('shop-info-addr').textContent = shop?.ds_addr || '-';
}
function renderOrderList() {
const rows = (selectedShop ? orders.filter((o) => Number(o.so_ds_idx) === Number(selectedShop.ds_idx)) : orders);
if (rows.length === 0) {
orderListBody.innerHTML = '<tr><td colspan="5" class="text-center py-6 text-gray-400">조건에 맞는 주문이 없습니다.</td></tr>';
return;
}
orderListBody.innerHTML = rows.map((o) => `
<tr class="order-row cursor-pointer hover:bg-blue-50 ${selectedOrder && Number(selectedOrder.so_idx) === Number(o.so_idx) ? 'bg-blue-100' : ''}" data-order-id="${o.so_idx}">
<td class="text-center">${o.so_idx}</td>
<td class="text-left pl-2">${o.so_ds_name || ''}</td>
<td class="text-center">${o.so_order_date || ''}</td>
<td class="text-center">${o.so_delivery_date || ''}</td>
<td class="text-center">${o.so_status === 'cancelled' ? '주문 취소' : (Number(o.so_received || 0) === 1 ? '판매 완료' : '판매 진행')}</td>
</tr>
`).join('');
}
function pendingQtyForBag(code) {
return pendingScans.filter((s) => s.bag_code === code).reduce((sum, s) => sum + (Number(s.qty) || 0), 0);
}
function renderSaleItems() {
if (!selectedOrder) {
saleItemsBody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-400 py-6">주문을 선택해 주세요.</td></tr>';
return;
}
const items = Array.isArray(selectedOrder.items) ? selectedOrder.items : [];
if (items.length === 0) {
saleItemsBody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-400 py-6">품목 정보가 없습니다.</td></tr>';
return;
}
saleItemsBody.innerHTML = items.map((it) => {
const bagCode = String(it.soi_bag_code || '');
const sold = Number(it.sold_qty || 0) + pendingQtyForBag(bagCode);
const unitPrice = Number(it.soi_unit_price || 0);
const amount = sold * unitPrice;
const checked = selectedBagCode === bagCode ? 'checked' : '';
return `
<tr>
<td class="text-center"><input type="radio" name="pick-bag" value="${bagCode}" ${checked}></td>
<td class="text-left pl-2">${bagCode} ${it.soi_bag_name || ''}</td>
<td class="text-right pr-2">${nf(it.soi_qty || 0)}</td>
<td class="text-right pr-2">${nf(sold)}</td>
<td class="text-right pr-2">${nf(unitPrice)}</td>
<td class="text-right pr-2">${nf(amount)}</td>
</tr>
`;
}).join('');
}
function renderScanDetails() {
const rows = pendingScans.filter((s) => !selectedBagCode || s.bag_code === selectedBagCode);
if (rows.length === 0) {
scanDetailBody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-400 py-6">바코드를 스캔해 주세요.</td></tr>';
return;
}
scanDetailBody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-2">${r.bag_code} ${r.bag_name || ''}</td>
<td class="text-center">${r.barcode}</td>
<td class="text-right pr-2">${nf(r.qty)}</td>
<td class="text-center">${r.unit}</td>
</tr>
`).join('');
}
function selectOrder(orderId) {
selectedOrder = orders.find((o) => Number(o.so_idx) === Number(orderId)) || null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = selectedOrder ? String(selectedOrder.so_idx) : '';
saveDsIdx.value = selectedOrder ? String(selectedOrder.so_ds_idx || '') : (selectedShop ? String(selectedShop.ds_idx) : '');
saveScansJson.value = '[]';
// 주문이 선택되면 그 주문의 지정판매소를 「지정판매소 정보」 표·검색 input 에도 자동 반영한다.
if (selectedOrder) {
const matchedShop = shopMap.get(String(selectedOrder.so_ds_idx)) || null;
if (matchedShop) {
selectedShop = matchedShop;
if (shopSearch) shopSearch.value = mergedShopText(matchedShop);
updateShopInfo(matchedShop);
}
}
renderOrderList();
renderSaleItems();
renderScanDetails();
if (selectedOrder && Number(selectedOrder.so_ds_idx || 0) > 0) {
loadDevSaleableBarcodes(selectedOrder.so_ds_idx);
} else {
hideDevSaleablePanel();
}
}
async function submitScan() {
const code = (barcodeInput.value || '').trim();
if (!code) return;
if (!selectedOrder) {
setMessage('주문 접수 리스트에서 주문을 먼저 선택해 주세요.', true);
return;
}
const payload = new URLSearchParams();
payload.set(csrfName, csrfHash);
payload.set('so_idx', String(selectedOrder.so_idx));
payload.set('barcode', code);
const pendingByBag = {};
pendingScans.forEach((s) => {
const k = String(s.bag_code || '');
pendingByBag[k] = (pendingByBag[k] || 0) + (Number(s.qty || 0));
});
payload.set('pending_by_bag', JSON.stringify(pendingByBag));
const res = await fetch(scanApi, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: payload.toString(),
});
const data = await res.json();
if (!data.ok) {
setMessage(data.message || '스캔 처리 실패', true);
return;
}
if (!selectedBagCode) {
selectedBagCode = String(data.bag_code || '');
}
pendingScans.push({
barcode: code,
bag_code: data.bag_code,
bag_name: data.bag_name,
qty: Number(data.qty || 0),
unit: data.unit,
pack_ids: Array.isArray(data.pack_ids) ? data.pack_ids : [],
});
saveScansJson.value = JSON.stringify(pendingScans);
barcodeInput.value = '';
setMessage(`등록 완료: ${data.bag_code} / ${data.unit} / 수량 ${nf(data.qty)}`);
renderSaleItems();
renderScanDetails();
}
shopSearch?.addEventListener('change', (e) => {
const q = String(e.target.value || '').trim().toLowerCase();
selectedShop = shops.find((s) => {
const merged = [s.ds_shop_no, s.ds_name, s.ds_rep_name, s.ds_tel, s.ds_addr].join(' ').toLowerCase();
return merged.includes(q);
}) || null;
selectedOrder = null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = '';
saveDsIdx.value = selectedShop ? String(selectedShop.ds_idx) : '';
saveScansJson.value = '[]';
updateShopInfo(selectedShop);
hideDevSaleablePanel();
renderOrderList();
renderSaleItems();
renderScanDetails();
setMessage('');
hideSuggest();
});
shopSearch?.addEventListener('input', (e) => {
renderSuggest(e.target.value || '');
});
shopSearch?.addEventListener('focus', (e) => {
renderSuggest(e.target.value || '');
});
shopSearch?.addEventListener('click', (e) => {
renderSuggest(e.target.value || '');
});
shopSuggest?.addEventListener('click', (e) => {
const btn = e.target.closest('.shop-suggest-item');
if (!btn) return;
const dsIdx = Number(btn.dataset.dsIdx || 0);
const shop = shops.find((s) => Number(s.ds_idx) === dsIdx) || null;
if (!shop) return;
shopSearch.value = mergedShopText(shop);
selectedShop = shop;
selectedOrder = null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = '';
saveDsIdx.value = selectedShop ? String(selectedShop.ds_idx) : '';
saveScansJson.value = '[]';
updateShopInfo(selectedShop);
hideDevSaleablePanel();
renderOrderList();
renderSaleItems();
renderScanDetails();
setMessage('');
hideSuggest();
});
document.addEventListener('click', (e) => {
if (!shopSuggest || !shopSearch) return;
if (e.target === shopSearch || shopSuggest.contains(e.target)) return;
hideSuggest();
});
barcodeInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitScan();
}
});
orderListBody?.addEventListener('click', (e) => {
const tr = e.target.closest('.order-row');
if (!tr) return;
selectOrder(tr.dataset.orderId);
});
saleItemsBody?.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="pick-bag"]');
if (!radio) return;
selectedBagCode = radio.value || '';
renderScanDetails();
});
saveForm?.addEventListener('submit', (e) => {
if (!selectedOrder) {
e.preventDefault();
alert('주문을 먼저 선택해 주세요.');
return;
}
if (pendingScans.length === 0) {
e.preventDefault();
alert('없는 바코드이거나 유효한 스캔 내역이 없습니다.');
return;
}
});
renderOrderList();
renderSaleItems();
renderScanDetails();
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>