Files
jongryangje/app/Views/bag/order_phone_manage.php
taekyoungc 215d87f991 feat: 워크스페이스 탭 자동복원 + 주문 입금확인 토글 + 메뉴 개명(전화접수→주문접수)
종량제3 세션 작업.
- 워크스페이스 탭 계정별 저장·자동복원(member_workspace_tabs 자동생성, pref/workspace-tabs)
- 주문접수 관리 입금확인(so_paid) 수동 토글(phoneOrderMarkPaid, order/phone/manage/paid)
- 메뉴 '전화 접수 관리' → '주문접수관리' 개명(피드백 #35) + 시드/개명 SQL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:04:45 +09:00

840 lines
46 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php $startDate = (string) ($startDate ?? ''); $endDate = (string) ($endDate ?? ''); ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">주문접수 관리</span>
</div>
<form method="get" action="<?= base_url('bag/order/phone/manage') ?>" class="mt-2 flex flex-wrap items-center gap-2 text-sm">
<label class="font-bold text-gray-700">접수일</label>
<input type="date" lang="ko" name="start_date" value="<?= esc($startDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<span class="text-gray-400">~</span>
<input type="date" lang="ko" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm">조회</button>
<?php if ($startDate !== '' || $endDate !== ''): ?>
<a href="<?= base_url('bag/order/phone/manage') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
<?php endif; ?>
</form>
</section>
<div id="order-split" class="flex flex-col xl:flex-row gap-3 mt-2 items-stretch">
<section id="list-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col min-w-0 xl:shrink-0">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">접수 리스트(전화)</div>
<div id="list-scroll" class="overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12 text-center sort-th cursor-pointer select-none" data-sort="so_idx" data-type="num">번호<span class="sort-ind"></span></th>
<th class="w-24 text-center sort-th cursor-pointer select-none" data-sort="so_order_date" data-type="str">접수일<span class="sort-ind"></span></th>
<th class="w-24 text-center sort-th cursor-pointer select-none" data-sort="so_delivery_date" data-type="str">배달일<span class="sort-ind"></span></th>
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_channel" data-type="str">구분<span class="sort-ind"></span></th>
<th class="text-left sort-th cursor-pointer select-none" data-sort="so_ds_name" data-type="str">판매소<span class="sort-ind"></span></th>
<th class="w-16 text-center sort-th cursor-pointer select-none" data-sort="so_payment_type" data-type="str">결제<span class="sort-ind"></span></th>
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_received" data-type="num">포장<span class="sort-ind"></span></th>
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_paid" data-type="num">입금<span class="sort-ind"></span></th>
<th class="w-24 text-right sort-th cursor-pointer select-none" data-sort="so_total_amount" data-type="num">총금액<span class="sort-ind"></span></th>
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_status" data-type="str">취소<span class="sort-ind"></span></th>
</tr>
</thead>
<tbody id="order-list-body"></tbody>
</table>
</div>
<div id="list-summary" class="px-3 py-2 border-t border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700 flex flex-wrap items-center justify-between gap-2">
<span>건수 <span id="list-sum-count" class="text-blue-700">0</span>건</span>
<span>금액 <span id="list-sum-amount" class="text-blue-700">0</span>원</span>
</div>
</section>
<!-- 드래그 가능한 너비 조절 손잡이 (xl 이상 좌우 배치일 때만 표시) -->
<div id="split-handle" class="hidden xl:flex items-center justify-center cursor-col-resize select-none shrink-0" style="width:8px;touch-action:none;" role="separator" aria-orientation="vertical" title="드래그하여 표 너비를 조절합니다">
<div class="w-1.5 h-16 rounded-full bg-gray-300 hover:bg-blue-400 transition-colors"></div>
</div>
<section id="detail-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden min-w-0 xl:flex-1">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
<form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3">
<?= csrf_field() ?>
<input type="hidden" name="so_idx" id="detail-so-idx" value=""/>
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1">
<div><span class="font-semibold text-gray-700 inline-block w-20">접수번호</span> <span id="detail-so-no">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">판매소코드</span> <span id="detail-shop-code">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">판매소명</span> <span id="detail-shop-name">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">담당자</span> <span id="detail-shop-rep">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">전화번호</span> <span id="detail-shop-tel">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">결제수단</span> <span id="detail-payment">-</span></div>
<div class="md:col-span-2"><span class="font-semibold text-gray-700 inline-block w-20">주소</span> <span id="detail-shop-addr">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">접수일</span> <span id="detail-order-date">-</span></div>
<div class="flex items-center"><span class="font-semibold text-gray-700 inline-block w-20">배달일</span>
<input type="date" lang="ko" name="so_delivery_date" id="detail-delivery-date-input" class="border border-gray-300 rounded px-2 py-0.5 text-sm"/>
</div>
<div><span class="font-semibold text-gray-700 inline-block w-20">상태</span> <span id="detail-status">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">입금상태</span> <span id="detail-paid-status" class="font-semibold">-</span></div>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 px-1">
<span class="text-sm font-semibold text-gray-700">접수 품목 내역</span>
<div class="flex items-center gap-2">
<button type="button" id="btn-toggle-paid" class="border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50" disabled>입금확인</button>
<button type="submit" id="btn-save" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
<button type="button" id="btn-receipt" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button>
<button type="submit" id="btn-cancel-order" form="order-cancel-form" class="border border-red-300 text-red-600 px-3 py-1 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
</div>
</div>
<div class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14 text-center 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-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">단가</th>
<th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">접수량</th>
<th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장량</th>
<th class="w-28 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">접수금액</th>
<th class="w-40 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장단위(박스/팩/낱장)</th>
</tr>
</thead>
<tbody id="detail-items-body">
<tr>
<td colspan="7" class="text-center py-6 text-gray-400">왼쪽 리스트에서 주문을 선택해 주세요.</td>
</tr>
</tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="3" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="detail-sum-qty">0</td>
<td class="text-right px-2 py-1" id="detail-sum-packed">0</td>
<td class="text-right px-2 py-1" id="detail-sum-amount">0</td>
<td class="text-right px-2 py-1" id="detail-sum-pack">박스=0, 팩=0, 낱장=0</td>
</tr>
</tfoot>
</table>
</div>
<!-- 봉투코드 스캔 → 즉시 판매·포장 처리 (선택된 주문의 판매소로 판매) -->
<div class="mt-3 border border-gray-300 rounded-lg p-2 bg-gray-50">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-gray-700 whitespace-nowrap"><i class="fa-solid fa-barcode text-emerald-600 mr-1"></i>봉투코드 스캔</span>
<input type="text" id="scan-input" autocomplete="off" placeholder="봉투 바코드를 스캔/입력 후 Enter" class="border border-gray-300 rounded px-2 py-1 text-sm flex-1 min-w-[12rem]" disabled/>
<span id="scan-msg" class="text-xs text-gray-500"></span>
</div>
</div>
<div class="mt-3">
<div class="flex items-center justify-between px-1 mb-1 text-sm">
<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>
<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 class="grid grid-cols-1 md:grid-cols-2 gap-2">
<!-- 최근 스캔 2건 -->
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-2 py-1 bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-600">최근 스캔</div>
<div id="recent-scans" class="p-2 text-xs text-gray-400 min-h-[3.5rem]">스캔 내역이 없습니다.</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 id="order-cancel-form" method="POST" class="hidden">
<?= csrf_field() ?>
</form>
</section>
</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 제거) -->
<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">
<strong class="text-amber-950">[개발용 임시]</strong> 선택 주문의 판매소 기준 판매 테스트용 바코드 후보. <strong>바코드를 클릭하면 봉투코드 스캔칸에 입력</strong>됩니다. 개발 완료 후 이 블록과 API를 제거하세요.
</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-44 text-center">바코드(대표)</th>
<th class="text-left">봉투 종류</th>
<th class="w-20 text-center">포장</th>
<th class="w-12 text-right">수량</th>
<th class="w-14 text-center">상태</th>
<th class="text-left">비고(낱장범위 등)</th>
</tr>
</thead>
<tbody id="dev-saleable-tbody">
<tr><td colspan="7" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>
</tbody>
</table>
</div>
</div>
<script>
(() => {
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orderMap = new Map(orders.map((o) => [String(o.so_idx), o]));
// 표시용 접수번호: 접수일(so_order_date)별로 매일 1번부터 순차 부여(그 날의 가장 오래된 건 = 1).
// 정렬과 무관하게 고정.
const dateKey = (o) => String(o && o.so_order_date ? o.so_order_date : '').slice(0, 10);
const displayNoMap = new Map();
(() => {
const perDate = {};
orders.slice().sort((a, b) => Number(a.so_idx || 0) - Number(b.so_idx || 0)).forEach((o) => {
const k = dateKey(o);
perDate[k] = (perDate[k] || 0) + 1;
displayNoMap.set(String(o.so_idx), perDate[k]);
});
})();
const displayNo = (o) => displayNoMap.get(String(o && o.so_idx)) || '';
// 봉투코드 스캔(판매/포장) — CSRF 토큰(쿠키 방식, 매 요청 갱신)
// base_url() 절대경로를 현재 문서(동일 출처) 기준 경로로 변환 → 워크스페이스 iframe/다른 호스트 접근에도 안전
const scanApi = new URL('<?= base_url('bag/order/phone/manage/scan') ?>', window.location.href).pathname;
// [개발용 임시] 판매 가능 바코드 후보 API (지정판매소 판매의 dev API 재사용)
const devSaleableApi = new URL('<?= base_url('bag/sale/designated/dev-saleable-barcodes') ?>', window.location.href).pathname;
const csrfName = '<?= csrf_token() ?>';
let csrfHash = '<?= csrf_hash() ?>';
const listBody = document.getElementById('order-list-body');
const form = document.getElementById('order-detail-form');
const cancelForm = document.getElementById('order-cancel-form');
const detailBody = document.getElementById('detail-items-body');
const inputSoIdx = document.getElementById('detail-so-idx');
const btnSave = document.getElementById('btn-save');
const btnCancel = document.getElementById('btn-cancel-order');
const btnReceipt = document.getElementById('btn-receipt');
const btnTogglePaid = document.getElementById('btn-toggle-paid');
const paidApi = new URL('<?= base_url('bag/order/phone/manage/paid') ?>', window.location.href).pathname;
const receiptBase = '<?= base_url('bag/order/phone/receipt') ?>';
btnReceipt?.addEventListener('click', () => {
const id = inputSoIdx.value;
if (!id) return;
const o = orderMap.get(String(id));
// 이미 포장(수령)이 완료된 건의 영수증 재발행 시 안내
if (o && Number(o.so_received) === 1) {
if (!confirm('포장이 완료된 건입니다.\n그래도 영수증을 다시 출력할까요?')) return;
}
window.open(receiptBase + '/' + encodeURIComponent(id), '_blank');
});
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
function paintPaidButton(order) {
if (!btnTogglePaid) return;
const paid = order && Number(order.so_paid) === 1;
btnTogglePaid.textContent = paid ? '입금확인 취소' : '입금확인';
btnTogglePaid.className = paid
? 'border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50'
: 'border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50';
}
btnTogglePaid?.addEventListener('click', async () => {
const id = inputSoIdx.value;
if (!id) return;
const o = orderMap.get(String(id));
if (!o) return;
const nextPaid = Number(o.so_paid) === 1 ? 0 : 1;
if (nextPaid === 0 && !confirm('입금확인을 취소하면 이 주문은 포장(스캔)이 차단됩니다.\n계속할까요?')) return;
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('so_idx', String(id));
body.set('paid', String(nextPaid));
btnTogglePaid.disabled = true;
try {
const res = await fetch(paidApi, { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body });
const data = await res.json();
if (data && data.csrf) csrfHash = data.csrf;
if (!data || !data.ok) { alert((data && data.message) || '처리에 실패했습니다.'); btnTogglePaid.disabled = false; return; }
o.so_paid = nextPaid;
setHeader(o);
paintPaidButton(o);
btnTogglePaid.disabled = false;
renderList(); // 리스트의 입금 표시 갱신
} catch (e) {
alert('네트워크 오류로 처리하지 못했습니다.');
btnTogglePaid.disabled = false;
}
});
function setHeader(order) {
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
t('detail-so-no', order ? displayNo(order) : '');
t('detail-shop-code', order ? order.ds_shop_no : '');
t('detail-shop-name', order ? order.so_ds_name : '');
t('detail-shop-rep', order ? order.ds_rep_name : '');
t('detail-shop-tel', order ? order.ds_tel : '');
t('detail-payment', order ? order.so_payment_type : '');
t('detail-shop-addr', order ? order.ds_addr : '');
t('detail-order-date', order ? order.so_order_date : '');
t('detail-status', order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '');
var ps = document.getElementById('detail-paid-status');
if (ps) {
var paid = order && Number(order.so_paid) === 1;
ps.textContent = order ? (paid ? '입금완료' : '미입금') : '-';
ps.style.color = order ? (paid ? '#059669' : '#e11d48') : '';
}
// 배달일 — 수정 가능한 date 입력
var dd = document.getElementById('detail-delivery-date-input');
if (dd) {
var v = order && order.so_delivery_date ? String(order.so_delivery_date) : '';
dd.value = /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : '';
dd.disabled = !order || order.so_status === 'cancelled';
// 배달일은 접수일 이후로만 선택 가능
var od = order && order.so_order_date ? String(order.so_order_date).slice(0, 10) : '';
if (/^\d{4}-\d{2}-\d{2}$/.test(od)) { dd.min = od; } else { dd.removeAttribute('min'); }
}
}
function calcRow(tr) {
const qtyInput = tr.querySelector('.item-qty-input');
const qty = Math.max(0, parseInt(qtyInput.value || '0', 10) || 0);
qtyInput.value = String(qty);
const unitPrice = parseInt(tr.dataset.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(tr.dataset.boxSheets || '0', 10) || 0;
const packSheets = parseInt(tr.dataset.packSheets || '0', 10) || 0;
let box = 0;
let pack = 0;
let sheet = qty;
if (boxSheets > 0) {
box = Math.floor(qty / boxSheets);
const remain = qty % boxSheets;
if (packSheets > 0) {
pack = Math.floor(remain / packSheets);
sheet = remain % packSheets;
} else {
sheet = remain;
}
} else if (packSheets > 0) {
pack = Math.floor(qty / packSheets);
sheet = qty % packSheets;
}
const packedCell = tr.querySelector('.item-packed-cell');
const packed = packedCell ? Math.max(0, parseInt((packedCell.textContent || '0').replace(/[^0-9]/g, '') || '0', 10) || 0) : 0;
const amount = qty * unitPrice;
tr.querySelector('.item-amount-cell').textContent = nf(amount);
tr.querySelector('.item-pack-cell').textContent = `박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}`;
return { qty, packed, amount, box, pack, sheet };
}
function recalcTotals() {
let sumQty = 0;
let sumPacked = 0;
let sumAmount = 0;
let sumBox = 0;
let sumPack = 0;
let sumSheet = 0;
detailBody.querySelectorAll('tr.order-item-row').forEach((tr) => {
const r = calcRow(tr);
sumQty += r.qty;
sumPacked += r.packed;
sumAmount += r.amount;
sumBox += r.box;
sumPack += r.pack;
sumSheet += r.sheet;
});
document.getElementById('detail-sum-qty').textContent = nf(sumQty);
document.getElementById('detail-sum-packed').textContent = nf(sumPacked);
document.getElementById('detail-sum-amount').textContent = nf(sumAmount);
document.getElementById('detail-sum-pack').textContent = `박스=${nf(sumBox)}, 팩=${nf(sumPack)}, 낱장=${nf(sumSheet)}`;
}
function renderDetail(orderId) {
const order = orderMap.get(String(orderId));
if (!order) return;
inputSoIdx.value = String(order.so_idx || '');
setHeader(order);
cancelForm.action = `<?= base_url('bag/order/phone/manage/cancel') ?>/${order.so_idx}`;
const isCancelled = order.so_status === 'cancelled';
btnSave.disabled = isCancelled;
btnCancel.disabled = isCancelled;
if (btnReceipt) btnReceipt.disabled = false;
if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); }
renderPacking(order); // 포장 명세 탭 동기화
const items = Array.isArray(order.items) ? order.items : [];
if (items.length === 0) {
detailBody.innerHTML = '<tr><td colspan="7" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>';
recalcTotals();
return;
}
detailBody.innerHTML = items.map((item, idx) => {
const itemId = String(item.soi_idx || '');
const bagName = `${item.soi_bag_code || ''} ${item.soi_bag_name || ''}`.trim();
const qty = parseInt(item.soi_qty || 0, 10) || 0;
const packedQty = parseInt(item.soi_packed_qty || 0, 10) || 0;
const unitPrice = parseInt(item.soi_unit_price || 0, 10) || 0;
const amount = parseInt(item.soi_amount || 0, 10) || 0;
const box = parseInt(item.soi_box_count || 0, 10) || 0;
const pack = parseInt(item.soi_pack_count || 0, 10) || 0;
const sheet = parseInt(item.soi_sheet_count || 0, 10) || 0;
const boxSheets = parseInt(item.box_sheets || 0, 10) || 0;
const packSheets = parseInt(item.pack_sheets || 0, 10) || 0;
return `
<tr class="order-item-row" data-unit-price="${unitPrice}" data-box-sheets="${boxSheets}" data-pack-sheets="${packSheets}">
<td class="text-center">${idx + 1}</td>
<td class="text-left pl-2">${bagName}</td>
<td class="text-right pr-2">${nf(unitPrice)}</td>
<td class="text-right pr-2">
<input type="number" min="0" class="item-qty-input border border-gray-300 rounded px-2 py-1 w-24 text-right" name="item_qty[${itemId}]" value="${qty}" ${isCancelled ? 'disabled' : ''}/>
</td>
<td class="text-right pr-2 item-packed-cell font-semibold ${packedQty >= qty && qty > 0 ? 'text-emerald-600' : 'text-gray-700'}" data-bag-code="${esc(item.soi_bag_code || '')}">${nf(packedQty)}</td>
<td class="text-right pr-2 item-amount-cell">${nf(amount)}</td>
<td class="text-right pr-2 item-pack-cell">박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}</td>
</tr>
`;
}).join('');
recalcTotals();
// 봉투코드 스캔 입력: 정상 주문일 때만 활성화
const scanInput = document.getElementById('scan-input');
if (scanInput) {
scanInput.disabled = isCancelled;
const msg = document.getElementById('scan-msg');
if (msg) msg.textContent = '';
}
// [개발용 임시] 선택 주문 판매소 기준 후보 바코드 표
loadDevSaleable(order.so_ds_idx, order.so_idx);
syncListHeight(); // 상세 카드 높이 확정 후 접수 리스트 높이를 맞춘다
}
// [개발용 임시] 스캔 가능 바코드 후보 로딩(선택 주문 판매소·주문 기준)
const devPanelEl = document.getElementById('dev-saleable-panel');
const devTbodyEl = document.getElementById('dev-saleable-tbody');
async function loadDevSaleable(dsIdx, soIdx) {
if (!devPanelEl || !devTbodyEl) return;
const idx = Number(dsIdx || 0);
if (!idx) { devPanelEl.classList.add('hidden'); return; }
devPanelEl.classList.remove('hidden');
devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>';
try {
let url = devSaleableApi + '?ds_idx=' + encodeURIComponent(String(idx));
if (Number(soIdx || 0) > 0) url += '&so_idx=' + encodeURIComponent(String(soIdx));
const res = await fetch(url, { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok) { devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-red-600 py-4">' + esc(data.message || '조회 실패') + '</td></tr>'; return; }
const rows = Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) { devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">표시할 바코드가 없습니다.</td></tr>'; return; }
devTbodyEl.innerHTML = rows.map((r) => {
const code = String(r.code || '');
const codeCell = (code && code !== '-')
? '<button type="button" class="dev-pick text-blue-600 hover:underline font-mono" data-code="' + esc(code) + '">' + esc(code) + '</button>'
: '<span class="text-gray-400">' + esc(code) + '</span>';
return '<tr>'
+ '<td class="text-left pl-1">' + esc(r.source || '') + '</td>'
+ '<td class="text-center">' + codeCell + '</td>'
+ '<td class="text-left pl-1">' + esc((r.bag_code || '') + ' ' + (r.bag_name || '')) + '</td>'
+ '<td class="text-center">' + esc(r.unit || '') + '</td>'
+ '<td class="text-right pr-1">' + nf(r.qty || 0) + '</td>'
+ '<td class="text-center">' + esc(r.state || '') + '</td>'
+ '<td class="text-left pl-1">' + esc(r.extra || '') + '</td>'
+ '</tr>';
}).join('');
} catch (e) {
devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-red-600 py-4">네트워크 오류</td></tr>';
}
}
// 후보 바코드 클릭 → 스캔칸에 입력(개발 편의)
devTbodyEl?.addEventListener('click', (e) => {
const btn = e.target.closest('.dev-pick');
if (!btn) return;
const inp = document.getElementById('scan-input');
if (inp && !inp.disabled) { inp.value = btn.getAttribute('data-code') || ''; inp.focus(); }
});
// 봉투코드 스캔 → 즉시 판매·포장 처리 후 화면 갱신
const scanInputEl = document.getElementById('scan-input');
const scanMsgEl = document.getElementById('scan-msg');
function setScanMsg(text, isErr) {
if (!scanMsgEl) return;
scanMsgEl.textContent = text || '';
scanMsgEl.className = 'text-xs ' + (isErr ? 'text-red-600 font-semibold' : 'text-emerald-600');
}
async function submitScan() {
if (!scanInputEl) return;
const code = (scanInputEl.value || '').trim();
if (!code) return;
if (!selectedId) { setScanMsg('먼저 접수 리스트에서 주문을 선택해 주세요.', true); return; }
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('so_idx', String(selectedId));
body.set('barcode', code);
let data;
try {
const res = await fetch(scanApi, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
credentials: 'same-origin',
body: body.toString(),
});
data = await res.json();
} catch (e) {
setScanMsg('통신 오류가 발생했습니다.', true);
return;
}
if (data && data.csrf) csrfHash = data.csrf; // 다음 스캔용 토큰 갱신
if (!data || !data.ok) {
setScanMsg((data && data.message) || '스캔 처리 실패', true);
scanInputEl.select();
return;
}
// 선택된 주문 객체에 스캔 반영(재선택 시 유지)
const order = orderMap.get(String(data.so_idx));
if (order) {
if (!Array.isArray(order.scans)) order.scans = [];
order.scans.push({ bag_code: data.bag_code, bag_name: data.bag_name, code: data.code, unit: data.unit, qty: Number(data.qty || 0) });
const it = (order.items || []).find((x) => String(x.soi_bag_code) === String(data.bag_code));
if (it) it.soi_packed_qty = Number(data.packed_qty || 0);
order.so_received = Number(data.so_received || 0);
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
if (String(selectedId) === String(data.so_idx)) {
renderPacking(order);
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) {
cell.textContent = nf(Number(data.packed_qty || 0));
const row = cell.closest('tr');
const qtyInp = row ? row.querySelector('.item-qty-input') : null;
const rq = qtyInp ? (parseInt(qtyInp.value || '0', 10) || 0) : 0;
cell.classList.toggle('text-emerald-600', Number(data.packed_qty || 0) >= rq && rq > 0);
cell.classList.toggle('text-gray-700', !(Number(data.packed_qty || 0) >= rq && rq > 0));
}
recalcTotals();
}
// 접수 리스트의 '포장' 표시(O/X) 갱신
renderList();
// [개발용 임시] 후보 목록 갱신(방금 판매된 팩 제외)
loadDevSaleable(order.so_ds_idx, order.so_idx);
}
scanInputEl.value = '';
scanInputEl.focus();
setScanMsg('등록: ' + data.bag_code + ' / ' + data.unit + ' / ' + nf(data.qty) + ' (잔량 ' + nf(data.remain) + ')', false);
}
scanInputEl?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); submitScan(); }
});
// 접수 리스트 스크롤 높이를 지정판매소 정보(상세) 카드 높이에 맞춘다.
// offsetHeight는 화면 zoom(텍스트 줄이기)과 무관한 레이아웃 px라, 배율과 상관없이 두 카드가 같은 높이가 되고 하단 공백이 생기지 않는다.
const listScrollEl = document.getElementById('list-scroll');
const detailCardEl = document.getElementById('detail-card');
function syncListHeight() {
if (!listScrollEl || !detailCardEl) return;
const listCard = listScrollEl.closest('section');
const header = listCard ? listCard.firstElementChild : null;
const summary = document.getElementById('list-summary');
listScrollEl.style.height = '0px'; // 리스트를 접어 상세 카드의 본래 높이를 측정
const target = detailCardEl.offsetHeight
- (header ? header.offsetHeight : 0)
- (summary ? summary.offsetHeight : 0);
listScrollEl.style.height = Math.max(160, target) + 'px';
}
window.addEventListener('resize', syncListHeight);
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') syncListHeight(); });
if ('ResizeObserver' in window && detailCardEl) {
new ResizeObserver(function () { syncListHeight(); }).observe(detailCardEl);
}
// ── 접수 리스트 ↔ 지정판매소 정보 사이 너비 조절(드래그 스플리터) ──────
// 손잡이를 접수 리스트 쪽(왼쪽)으로 끌면 리스트 폭이 줄고 지정판매소 정보 폭이 넓어진다.
const splitEl = document.getElementById('order-split');
const listCardEl = document.getElementById('list-card');
const splitHandleEl = document.getElementById('split-handle');
const SPLIT_KEY = 'jrj_order_split_pct';
const SPLIT_MIN = 25, SPLIT_MAX = 75, SPLIT_DEFAULT = 55.5; // 기본값 ≈ 기존 5:4 비율
const xlMedia = window.matchMedia('(min-width: 1280px)');
function clampPct(p) { return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, p)); }
function currentPct() {
const saved = parseFloat(localStorage.getItem(SPLIT_KEY) || '');
return clampPct(isFinite(saved) ? saved : SPLIT_DEFAULT);
}
function applySplit(pct) {
if (!listCardEl) return;
if (xlMedia.matches) {
// 좌우 배치일 때만 폭 지정(세로로 쌓이는 좁은 화면에서는 전체폭 사용)
listCardEl.style.flex = '0 0 ' + pct + '%';
listCardEl.style.maxWidth = pct + '%';
} else {
listCardEl.style.flex = '';
listCardEl.style.maxWidth = '';
}
syncListHeight();
}
if (splitHandleEl && splitEl && listCardEl) {
let dragging = false, rafId = 0, pendingPct = null;
function onMove(clientX) {
const rect = splitEl.getBoundingClientRect();
if (rect.width <= 0) return;
pendingPct = clampPct((clientX - rect.left) / rect.width * 100);
if (!rafId) {
rafId = requestAnimationFrame(function () {
rafId = 0;
if (pendingPct != null) applySplit(pendingPct);
});
}
}
function pointerMove(e) { if (dragging) { e.preventDefault(); onMove(e.clientX); } }
function pointerUp() {
if (!dragging) return;
dragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
window.removeEventListener('pointermove', pointerMove);
window.removeEventListener('pointerup', pointerUp);
if (pendingPct != null) localStorage.setItem(SPLIT_KEY, String(Math.round(pendingPct * 10) / 10));
}
splitHandleEl.addEventListener('pointerdown', function (e) {
if (!xlMedia.matches) return; // 좁은 화면에서는 비활성
e.preventDefault();
dragging = true;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
window.addEventListener('pointermove', pointerMove);
window.addEventListener('pointerup', pointerUp);
});
// 더블클릭 시 기본 비율로 초기화
splitHandleEl.addEventListener('dblclick', function () {
localStorage.removeItem(SPLIT_KEY);
applySplit(SPLIT_DEFAULT);
});
// 브레이크포인트 전환 시 폭 지정 적용/해제
(xlMedia.addEventListener ? xlMedia.addEventListener('change', function () { applySplit(currentPct()); })
: xlMedia.addListener(function () { applySplit(currentPct()); }));
applySplit(currentPct());
}
// ── 스캔 현황(최근 2건 + 앞으로 찍을 목록) + 포장 명세 팝업 ──────────
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])); }
// 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
function scanFullRows(order) {
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
let no = 0, total = 0;
const rows = scans.map((s) => {
no++;
const code = s.bag_code || '', name = s.bag_name || '', qty = parseInt(s.qty || 0, 10) || 0;
total += qty;
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 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
+ '<td class="text-right pr-2">' + nf(qty) + '</td>'
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
});
return { html: rows.join(''), count: no, total: total };
}
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;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
function rowHtml(o) {
const cancelled = o.so_status === 'cancelled';
const dDate = o.so_delivery_date ? String(o.so_delivery_date).slice(0, 10) : '';
const channel = (o.so_channel && o.so_channel !== 'phone') ? o.so_channel : '전화';
const packed = Number(o.so_received) === 1;
return `<tr class="order-list-row cursor-pointer hover:bg-blue-50 ${cancelled ? 'bg-gray-50 text-gray-400' : ''} ${String(o.so_idx) === String(selectedId) ? 'bg-blue-100' : ''}" data-order-id="${o.so_idx}">
<td class="text-center">${displayNo(o)}</td>
<td class="text-center">${esc(o.so_order_date)}</td>
<td class="text-center">${esc(dDate)}</td>
<td class="text-center">${esc(channel)}</td>
<td class="text-left pl-2">${esc(o.so_ds_name)}</td>
<td class="text-center">${esc(o.so_payment_type || '-')}</td>
<td class="text-center" style="text-align:center;">${packed ? '<span style="color:#2563eb;font-weight:700;">O</span>' : '<span class="text-gray-400">X</span>'}</td>
<td class="text-center" style="text-align:center;">${Number(o.so_paid) === 1 ? '<span style="color:#e11d48;font-size:11px;" title="입금">●</span>' : ''}</td>
<td class="text-right pr-2">${nf(o.so_total_amount || 0)}</td>
<td class="text-center">${cancelled ? '취소' : '정상'}</td>
</tr>`;
}
function renderList() {
const th = document.querySelector('.sort-th[data-sort="' + sortKey + '"]');
const type = th ? th.dataset.type : 'str';
const arr = orders.slice().sort((a, b) => {
let va = a[sortKey], vb = b[sortKey];
if (type === 'num') { va = Number(va || 0); vb = Number(vb || 0); return sortDir === 'asc' ? va - vb : vb - va; }
va = String(va || ''); vb = String(vb || '');
return sortDir === 'asc' ? va.localeCompare(vb, 'ko') : vb.localeCompare(va, 'ko');
});
listBody.innerHTML = arr.length
? arr.map(rowHtml).join('')
: '<tr><td colspan="10" class="text-center py-8 text-gray-400">전화 주문 데이터가 없습니다.</td></tr>';
document.querySelectorAll('.sort-th .sort-ind').forEach((s) => { s.textContent = ''; });
if (th) th.querySelector('.sort-ind').textContent = sortDir === 'asc' ? ' ▲' : ' ▼';
// 하단 합계 — 취소 주문은 제외
const normals = arr.filter((o) => o.so_status !== 'cancelled');
const sumCountEl = document.getElementById('list-sum-count');
const sumAmountEl = document.getElementById('list-sum-amount');
if (sumCountEl) sumCountEl.textContent = nf(normals.length);
if (sumAmountEl) sumAmountEl.textContent = nf(normals.reduce((s, o) => s + (Number(o.so_total_amount) || 0), 0));
}
document.querySelectorAll('.sort-th').forEach((th) => {
th.addEventListener('click', () => {
const k = th.dataset.sort;
if (sortKey === k) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortKey = k; sortDir = 'asc'; }
renderList();
});
});
listBody?.addEventListener('click', (e) => {
const tr = e.target.closest('.order-list-row');
if (!tr) return;
selectedId = tr.dataset.orderId;
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100');
renderDetail(selectedId);
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
});
detailBody?.addEventListener('input', (e) => {
if (e.target.closest('.item-qty-input') || e.target.closest('.item-packed-input')) {
recalcTotals();
}
});
cancelForm?.addEventListener('submit', (e) => {
if (!confirm('해당 주문을 취소 처리하시겠습니까? (삭제되지 않고 상태만 취소로 변경됩니다)')) {
e.preventDefault();
}
});
form?.addEventListener('submit', (e) => {
if (!inputSoIdx.value) {
e.preventDefault();
alert('수정할 주문을 먼저 선택해 주세요.');
}
});
renderList();
const firstRow = listBody?.querySelector('.order-list-row');
if (firstRow) {
selectedId = firstRow.dataset.orderId;
firstRow.classList.add('bg-blue-100');
renderDetail(selectedId);
}
syncListHeight();
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>