feat: 전화접수 관리·주문 개선 + 매뉴얼 화면 바로가기

- 전화접수 관리: 배달일 기준 조회, 컬럼 보강(구분/포장 O·X/결제/수령/입금/총금액/취소),
  헤더 정렬, 배달일 수정 저장, 포장 명세·포장량 수정, 입금자 영수증 출력
- 전화 주문 접수: 행추가 → 일괄표(카테고리 그룹·포장 헤더)
- 매뉴얼 소제목에 '화면 열기' 바로가기(실제 GET 라우트만 연결)
- shop_order_item.soi_packed_qty 컬럼 추가(SQL)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-06-25 14:31:21 +09:00
parent 8e767ff234
commit acce968f9c
8 changed files with 435 additions and 143 deletions

View File

@@ -4,7 +4,7 @@
<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>
<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"/>
@@ -25,9 +25,13 @@
<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-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-16 text-center sort-th cursor-pointer select-none" data-sort="so_status" 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>
@@ -68,19 +72,21 @@
<th class="text-left">품목</th>
<th class="w-24 text-right">단가</th>
<th class="w-24 text-right">접수량</th>
<th class="w-24 text-right">포장량</th>
<th class="w-28 text-right">접수금액</th>
<th class="w-40 text-right">포장단위(박스/팩/낱장)</th>
</tr>
</thead>
<tbody id="detail-items-body">
<tr>
<td colspan="6" class="text-center py-6 text-gray-400">왼쪽 리스트에서 주문을 선택해 주세요.</td>
<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>
@@ -88,8 +94,32 @@
</table>
</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="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
</div>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12 text-center">No</th>
<th class="text-left">봉투종류</th>
<th class="w-28 text-center">봉투코드</th>
<th class="w-20 text-right">수량</th>
<th class="w-16 text-center">포장</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 class="flex gap-2">
<button type="submit" id="btn-save" class="bg-btn-search text-white px-5 py-1.5 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-5 py-1.5 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button>
</div>
</form>
@@ -112,6 +142,18 @@
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 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);
@@ -161,14 +203,18 @@
sheet = qty % packSheets;
}
const packedInput = tr.querySelector('.item-packed-input');
const packed = packedInput ? Math.max(0, parseInt(packedInput.value || '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, amount, box, pack, 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;
@@ -177,6 +223,7 @@
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;
@@ -184,6 +231,7 @@
});
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)}`;
}
@@ -199,10 +247,13 @@
const isCancelled = order.so_status === 'cancelled';
btnSave.disabled = isCancelled;
btnCancel.disabled = isCancelled;
if (btnReceipt) btnReceipt.disabled = false;
renderPacking(order); // 포장 명세 탭 동기화
const items = Array.isArray(order.items) ? order.items : [];
if (items.length === 0) {
detailBody.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>';
detailBody.innerHTML = '<tr><td colspan="7" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>';
recalcTotals();
return;
}
@@ -211,6 +262,7 @@
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;
@@ -227,6 +279,9 @@
<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">
<input type="number" min="0" class="item-packed-input border border-gray-300 rounded px-2 py-1 w-24 text-right" name="item_packed[${itemId}]" value="${packedQty}" ${isCancelled ? 'disabled' : ''}/>
</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>
@@ -236,6 +291,47 @@
recalcTotals();
}
// ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ──────────
const packingBody = document.getElementById('packing-body');
const packingTotal = document.getElementById('packing-total');
function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
function renderPacking(order) {
if (!packingBody) return;
const items = Array.isArray(order.items) ? order.items : [];
const rows = [];
let no = 0;
const addRow = (code, name, qty, unit) => {
no++;
rows.push('<tr><td class="text-center">' + no + '</td>'
+ '<td class="text-left pl-2">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-center text-gray-400">-</td>'
+ '<td class="text-right pr-2">' + nf(qty) + '</td>'
+ '<td class="text-center">' + unit + '</td></tr>');
};
items.forEach((it) => {
const code = it.soi_bag_code || '';
const name = it.soi_bag_name || '';
let qty = parseInt(it.soi_qty || 0, 10) || 0;
const boxSheets = parseInt(it.box_sheets || 0, 10) || 0;
const packSheets = parseInt(it.pack_sheets || 0, 10) || 0;
let box = 0, pack = 0, sheet = qty;
if (boxSheets > 0) {
box = Math.floor(qty / boxSheets);
const rem = qty % boxSheets;
if (packSheets > 0) { pack = Math.floor(rem / packSheets); sheet = rem % packSheets; }
else { sheet = rem; }
} else if (packSheets > 0) {
pack = Math.floor(qty / packSheets); sheet = qty % packSheets;
}
for (let i = 0; i < box; i++) { addRow(code, name, boxSheets, '박스'); }
for (let i = 0; i < pack; i++) { addRow(code, name, packSheets, '팩'); }
if (sheet > 0) { addRow(code, name, sheet, '낱장'); }
});
packingBody.innerHTML = rows.length ? rows.join('')
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">포장할 품목이 없습니다.</td></tr>';
if (packingTotal) packingTotal.textContent = String(no);
}
// ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────
let sortKey = 'so_idx', sortDir = 'desc', selectedId = null;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
@@ -243,11 +339,17 @@
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">${o.so_idx}</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>`;
@@ -264,7 +366,7 @@
});
listBody.innerHTML = arr.length
? arr.map(rowHtml).join('')
: '<tr><td colspan="6" class="text-center py-8 text-gray-400">전화 주문 데이터가 없습니다.</td></tr>';
: '<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' ? ' ▲' : ' ▼';
}
@@ -287,7 +389,7 @@
});
detailBody?.addEventListener('input', (e) => {
if (e.target.closest('.item-qty-input')) {
if (e.target.closest('.item-qty-input') || e.target.closest('.item-packed-input')) {
recalcTotals();
}
});