feat: 사용자 피드백(문의사항) 일괄 반영

- #5/#6/#12 전화 주문 접수: 판매수량 입력 중 엔터키 → 다음 봉투 종류 수량칸으로 이동
- #7 주문접수 관리: 배달일을 접수일 이전으로 변경 불가(클라이언트 min + 서버 검증)
- #9 주문접수 관리: 주문수정저장·영수증출력·주문취소 버튼을 접수품목내역 타이틀 옆으로 이동
- #10 주문접수 관리: 접수리스트 하단에 건수·금액 합계 표시(취소 제외)
- #13 전화 주문 접수: 접수표 행·열 간격 축소(한 화면 표시)
- #14 주문접수 관리: 포장명세 봉투종류~봉투코드 컬럼 간격 조정
- #16 판매대장: 품목 필터 순서 변경(일반용→재사용→공공용→음식물→폐기물)
- #18 재고 현황: 종류~계 사이 간격 축소(표 최대폭 제한)
- #20/#21 주문접수 관리: 화면명 '주문접수 관리'로 변경, 조회 기준 배달일→접수일,
  기본 조회일=오늘(초기화 시 당일), 접수번호 매일 1번부터 시작
- #23 담당자 구분 라벨 '구·군' → '지자체'
This commit is contained in:
taekyoungc
2026-07-02 11:53:31 +09:00
parent f76892a6ce
commit 5ef50344af
7 changed files with 81 additions and 30 deletions

View File

@@ -29,7 +29,7 @@ class Manager extends BaseController
{ {
return [ return [
'company' => '제작업체', 'company' => '제작업체',
'district' => '구·군', 'district' => '지자체',
'agency' => '대행소', 'agency' => '대행소',
]; ];
} }

View File

@@ -91,13 +91,14 @@ class SalesReport extends BaseController
// (카테고리 필터까지 적용된 현재 결과 기준) // (카테고리 필터까지 적용된 현재 결과 기준)
$sizeCatLabels = [ $sizeCatLabels = [
'general' => '일반용 봉투', 'general' => '일반용 봉투',
'food' => '음식물 봉투',
'waste' => '폐기물 봉투',
'reuse' => '재사용 봉투', 'reuse' => '재사용 봉투',
'public_use' => '공공용 봉투', 'public_use' => '공공용 봉투',
'food' => '음식물 봉투',
'waste' => '폐기물 봉투',
]; ];
// 스티커·공동주택용(apt)·용기(container)는 판매대장 크기 필터에서 제외 // 스티커·공동주택용(apt)·용기(container)는 판매대장 크기 필터에서 제외
$sizeCatOrder = ['general', 'food', 'waste', 'reuse', 'public_use']; // 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
$sizeCatOrder = ['general', 'reuse', 'public_use', 'food', 'waste'];
$sizeByCat = []; // catKey => [size => true] $sizeByCat = []; // catKey => [size => true]
foreach ($filtered as $row) { foreach ($filtered as $row) {

View File

@@ -7048,9 +7048,13 @@ SQL;
$unitMap[$code] = $unit; $unitMap[$code] = $unit;
} }
// 접수번호 — 매일 1번부터 시작(당일 접수 건수 + 1)
$receiptNo = 1; $receiptNo = 1;
if ($lgIdx) { if ($lgIdx) {
$receiptNo = (int) model(ShopOrderModel::class)->where('so_lg_idx', $lgIdx)->countAllResults() + 1; $receiptNo = (int) model(ShopOrderModel::class)
->where('so_lg_idx', $lgIdx)
->where('so_order_date', date('Y-m-d'))
->countAllResults() + 1;
} }
return $this->render('전화 주문 접수', 'bag/order_phone', compact('shops', 'bagCodes', 'priceMap', 'unitMap', 'receiptNo')); return $this->render('전화 주문 접수', 'bag/order_phone', compact('shops', 'bagCodes', 'priceMap', 'unitMap', 'receiptNo'));
@@ -7067,10 +7071,16 @@ SQL;
return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.');
} }
// 일자별·기간별 조회 (배달일 so_delivery_date 기준) // 일자별·기간별 조회 (접수일 so_order_date 기준) — 파라미터 없이 진입하면 기본값 = 오늘
$startDate = trim((string) ($this->request->getGet('start_date') ?? '')); $startRaw = $this->request->getGet('start_date');
$endDate = trim((string) ($this->request->getGet('end_date') ?? '')); $endRaw = $this->request->getGet('end_date');
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1; $startDate = trim((string) ($startRaw ?? ''));
$endDate = trim((string) ($endRaw ?? ''));
if ($startRaw === null && $endRaw === null) {
$startDate = date('Y-m-d');
$endDate = date('Y-m-d');
}
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
if ($isYmd($startDate) && $isYmd($endDate) && $startDate > $endDate) { if ($isYmd($startDate) && $isYmd($endDate) && $startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate]; [$startDate, $endDate] = [$endDate, $startDate];
} }
@@ -7082,10 +7092,10 @@ SQL;
$builder->where('so_channel', 'phone'); $builder->where('so_channel', 'phone');
} }
if ($isYmd($startDate)) { if ($isYmd($startDate)) {
$builder->where('so_delivery_date >=', $startDate); $builder->where('so_order_date >=', $startDate);
} }
if ($isYmd($endDate)) { if ($isYmd($endDate)) {
$builder->where('so_delivery_date <=', $endDate . ' 23:59:59'); $builder->where('so_order_date <=', $endDate);
} }
$orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll(); $orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll();
@@ -7181,7 +7191,7 @@ SQL;
]; ];
} }
return $this->render('전화접수 관리', 'bag/order_phone_manage', [ return $this->render('주문접수 관리', 'bag/order_phone_manage', [
'orders' => $payload, 'orders' => $payload,
'startDate' => $isYmd($startDate) ? $startDate : '', 'startDate' => $isYmd($startDate) ? $startDate : '',
'endDate' => $isYmd($endDate) ? $endDate : '', 'endDate' => $isYmd($endDate) ? $endDate : '',
@@ -7343,8 +7353,15 @@ SQL;
'so_total_amount' => $sumAmt, 'so_total_amount' => $sumAmt,
]; ];
// 배달일 수정 — 입력값이 유효한 날짜면 변경 반영(수정일자로 변경 처리) // 배달일 수정 — 입력값이 유효한 날짜면 변경 반영(수정일자로 변경 처리)
// 단, 접수일 이전 날짜로는 변경 불가.
$deliveryDate = trim((string) ($this->request->getPost('so_delivery_date') ?? '')); $deliveryDate = trim((string) ($this->request->getPost('so_delivery_date') ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryDate) === 1) { if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryDate) === 1) {
$orderDate = substr((string) ($order->so_order_date ?? ''), 0, 10);
if ($orderDate !== '' && $deliveryDate < $orderDate) {
$db->transRollback();
return redirect()->back()->with('error', '배달일은 접수일(' . $orderDate . ') 이후로만 변경할 수 있습니다.');
}
$orderUpdate['so_delivery_date'] = $deliveryDate; $orderUpdate['so_delivery_date'] = $deliveryDate;
} }
$orderModel->update($soIdx, $orderUpdate); $orderModel->update($soIdx, $orderUpdate);

View File

@@ -19,12 +19,13 @@ $printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판
$printDate = date('Y-m-d'); $printDate = date('Y-m-d');
$printExtraLines = $printSubtitleLines ?? []; $printExtraLines = $printSubtitleLines ?? [];
// 스티커·공동주택용(apt)·용기(container)는 품목 필터에서 제외 // 스티커·공동주택용(apt)·용기(container)는 품목 필터에서 제외
$catKeys = ['general', 'food', 'reuse', 'public_use', 'waste']; // 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
$catKeys = ['general', 'reuse', 'public_use', 'food', 'waste'];
$catLabels = [ $catLabels = [
'general' => '일반용 봉투', 'general' => '일반용 봉투',
'food' => '음식물 봉투',
'reuse' => '재사용 봉투', 'reuse' => '재사용 봉투',
'public_use' => '공공용 봉투', 'public_use' => '공공용 봉투',
'food' => '음식물 봉투',
'waste' => '폐기물 봉투', 'waste' => '폐기물 봉투',
]; ];

View File

@@ -48,7 +48,8 @@ foreach ($subtotals as $subtotal) {
<div class="mt-2 border border-gray-300 rounded-lg bg-white p-2 print:p-0 print:border-0"> <div class="mt-2 border border-gray-300 rounded-lg bg-white p-2 print:p-0 print:border-0">
<div class="overflow-auto"> <div class="overflow-auto">
<table class="w-full data-table text-sm"> <!-- 종류~계 사이 간격 축소 — 표 폭을 내용에 맞게 제한 (피드백 #18) -->
<table class="w-full max-w-4xl data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-36 text-center">품 목 구 분</th> <th class="w-36 text-center">품 목 구 분</th>

View File

@@ -146,8 +146,11 @@ unset($g);
table.phone-batch-table thead tr:last-child th { border-bottom: 1px solid #e5e7eb; } /* 수량/금액 등 하위 헤더 */ table.phone-batch-table thead tr:last-child th { border-bottom: 1px solid #e5e7eb; } /* 수량/금액 등 하위 헤더 */
table.phone-batch-table thead tr:first-child th[rowspan] { border-bottom: 1px solid #e5e7eb; } /* 구분·봉투종류·단가 */ table.phone-batch-table thead tr:first-child th[rowspan] { border-bottom: 1px solid #e5e7eb; } /* 구분·봉투종류·단가 */
table.phone-batch-table tbody td { border-bottom: 1px solid #f1f3f5; } table.phone-batch-table tbody td { border-bottom: 1px solid #f1f3f5; }
/* 행·열 간격 축소 — 한 화면에 전체 품목이 보이도록 (피드백 #13) */
table.phone-batch-table th, table.phone-batch-table td { padding: 0.2rem 0.35rem; }
table.phone-batch-table .item-qty-input { padding-top: 0.1rem; padding-bottom: 0.1rem; }
</style> </style>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto"> <div class="border border-gray-300 rounded-lg p-2 overflow-auto">
<table class="w-full data-table text-sm phone-batch-table"> <table class="w-full data-table text-sm phone-batch-table">
<thead> <thead>
<tr> <tr>
@@ -406,6 +409,15 @@ unset($g);
} }
}); });
// 판매수량 입력 중 엔터키 → 다음 봉투 종류의 수량 입력칸으로 이동
orderRows?.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' || !e.target.classList.contains('item-qty-input')) return;
e.preventDefault();
const inputs = Array.from(orderRows.querySelectorAll('.item-qty-input'));
const next = inputs[inputs.indexOf(e.target) + 1];
if (next) { next.focus(); next.select(); }
});
form?.addEventListener('submit', function (e) { form?.addEventListener('submit', function (e) {
let hasItem = false; let hasItem = false;
document.querySelectorAll('.order-row').forEach((row) => { document.querySelectorAll('.order-row').forEach((row) => {

View File

@@ -1,10 +1,10 @@
<?php $startDate = (string) ($startDate ?? ''); $endDate = (string) ($endDate ?? ''); ?> <?php $startDate = (string) ($startDate ?? ''); $endDate = (string) ($endDate ?? ''); ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <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"> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">전화 주문 접수 관리</span> <span class="text-sm font-bold text-gray-700">주문접수 관리</span>
</div> </div>
<form method="get" action="<?= base_url('bag/order/phone/manage') ?>" class="mt-2 flex flex-wrap items-center gap-2 text-sm"> <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"/> <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> <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"/> <input type="date" lang="ko" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
@@ -37,6 +37,10 @@
<tbody id="order-list-body"></tbody> <tbody id="order-list-body"></tbody>
</table> </table>
</div> </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> </section>
<section id="detail-card" class="xl:col-span-4 border border-gray-300 rounded-lg bg-white overflow-hidden"> <section id="detail-card" class="xl:col-span-4 border border-gray-300 rounded-lg bg-white overflow-hidden">
@@ -63,7 +67,14 @@
</div> </div>
</div> </div>
<div class="px-1 text-sm font-semibold text-gray-700">접수 품목 내역</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="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]"> <div class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
<thead> <thead>
@@ -104,10 +115,10 @@
<thead> <thead>
<tr> <tr>
<th class="w-12 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th> <th class="w-12 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th> <th class="w-64 text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="w-28 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th> <th class="w-28 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-20 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th> <th class="w-20 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> <th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr> </tr>
</thead> </thead>
<tbody id="packing-body"> <tbody id="packing-body">
@@ -116,16 +127,11 @@
</table> </table>
</div> </div>
</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> </form>
<form id="order-cancel-form" method="POST" class="px-3 pb-3"> <!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
<form id="order-cancel-form" method="POST" class="hidden">
<?= csrf_field() ?> <?= csrf_field() ?>
<button type="submit" id="btn-cancel-order" class="border border-red-300 text-red-600 px-5 py-1.5 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
</form> </form>
</section> </section>
</div> </div>
@@ -174,6 +180,9 @@
var v = order && order.so_delivery_date ? String(order.so_delivery_date) : ''; 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.value = /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : '';
dd.disabled = !order || order.so_status === 'cancelled'; 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'); }
} }
} }
@@ -300,8 +309,11 @@
if (!listScrollEl || !detailCardEl) return; if (!listScrollEl || !detailCardEl) return;
const listCard = listScrollEl.closest('section'); const listCard = listScrollEl.closest('section');
const header = listCard ? listCard.firstElementChild : null; const header = listCard ? listCard.firstElementChild : null;
const summary = document.getElementById('list-summary');
listScrollEl.style.height = '0px'; // 리스트를 접어 상세 카드의 본래 높이를 측정 listScrollEl.style.height = '0px'; // 리스트를 접어 상세 카드의 본래 높이를 측정
const target = detailCardEl.offsetHeight - (header ? header.offsetHeight : 0); const target = detailCardEl.offsetHeight
- (header ? header.offsetHeight : 0)
- (summary ? summary.offsetHeight : 0);
listScrollEl.style.height = Math.max(160, target) + 'px'; listScrollEl.style.height = Math.max(160, target) + 'px';
} }
window.addEventListener('resize', syncListHeight); window.addEventListener('resize', syncListHeight);
@@ -326,7 +338,7 @@
+ '<td class="text-left pl-2">' + packEsc(code) + ' ' + packEsc(name) + '</td>' + '<td class="text-left pl-2">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-center text-gray-400">-</td>' + '<td class="text-center text-gray-400">-</td>'
+ '<td class="text-right pr-2">' + nf(qty) + '</td>' + '<td class="text-right pr-2">' + nf(qty) + '</td>'
+ '<td class="text-center">' + unit + '</td></tr>'); + '<td class="text-left pl-2">' + unit + '</td></tr>');
}; };
items.forEach((it) => { items.forEach((it) => {
const code = it.soi_bag_code || ''; const code = it.soi_bag_code || '';
@@ -389,6 +401,13 @@
: '<tr><td colspan="10" 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 = ''; }); document.querySelectorAll('.sort-th .sort-ind').forEach((s) => { s.textContent = ''; });
if (th) th.querySelector('.sort-ind').textContent = sortDir === 'asc' ? ' ▲' : ' ▼'; 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) => { document.querySelectorAll('.sort-th').forEach((th) => {