diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 3c2cda9..e044d58 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -65,6 +65,7 @@ $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void $routes->get('manual/(:segment)', 'Bag::manualPage/$1'); $routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon) $routes->post('pref/font-scale', 'Bag::saveFontScale'); // 계정별·메뉴별 글자크기 저장 + $routes->post('pref/workspace-tabs', 'Bag::saveWorkspaceTabs'); // 계정별 워크스페이스 탭 상태 저장(자동 복원) }); $routes->get('bag/number-lookup', 'Bag::numberLookup'); @@ -81,6 +82,7 @@ $routes->get('bag/order/phone', 'Bag::phoneOrderCreate'); $routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage'); $routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate'); $routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan'); +$routes->post('bag/order/phone/manage/paid', 'Bag::phoneOrderMarkPaid'); $routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1'); $routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1'); $routes->get('bag/order/change', 'Bag::orderChange'); diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index 9f6b1d9..03ad219 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -274,6 +274,70 @@ SQL); return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]); } + /** member_workspace_tabs 테이블 보장(없으면 생성) */ + private function ensureWorkspaceTabsTable(\CodeIgniter\Database\BaseConnection $db): void + { + if ($db->tableExists('member_workspace_tabs')) { + return; + } + $db->query(<<<'SQL' +CREATE TABLE IF NOT EXISTS `member_workspace_tabs` ( + `mwt_mb_idx` INT UNSIGNED NOT NULL, + `mwt_state` MEDIUMTEXT NULL, + `mwt_updated` DATETIME NOT NULL, + PRIMARY KEY (`mwt_mb_idx`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL); + } + + /** 계정별 워크스페이스 탭 상태 조회(자동 복원용). 없으면 'null'. */ + public function loadWorkspaceTabsState(int $mbIdx): string + { + if ($mbIdx <= 0) { + return 'null'; + } + $db = \Config\Database::connect(); + if (! $db->tableExists('member_workspace_tabs')) { + return 'null'; + } + $row = $db->table('member_workspace_tabs')->select('mwt_state')->where('mwt_mb_idx', $mbIdx)->get()->getRowArray(); + $s = (string) ($row['mwt_state'] ?? ''); + return $s !== '' ? $s : 'null'; + } + + /** 계정별 워크스페이스 탭 상태 저장 (자동 복원) */ + public function saveWorkspaceTabs(): ResponseInterface + { + $mbIdx = (int) (session()->get('mb_idx') ?? 0); + if ($mbIdx <= 0) { + return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]); + } + $state = (string) ($this->request->getPost('state') ?? ''); + // 크기 제한 + JSON 유효성 + if (strlen($state) > 30000) { + return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]); + } + if ($state !== '') { + json_decode($state); + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]); + } + } + + $db = \Config\Database::connect(); + $this->ensureWorkspaceTabsTable($db); + $now = date('Y-m-d H:i:s'); + $exists = $db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx)->countAllResults(); + if ($exists > 0) { + $db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx) + ->update(['mwt_state' => $state, 'mwt_updated' => $now]); + } else { + $db->table('member_workspace_tabs')->insert(['mwt_mb_idx' => $mbIdx, 'mwt_state' => $state, 'mwt_updated' => $now]); + } + + return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]); + } + // ────────────────────────────────────────────── // 기본정보관리 (단가·포장 단위 진입 허브) // ────────────────────────────────────────────── @@ -7896,6 +7960,37 @@ SQL; return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.'); } + /** + * 주문접수 관리 — 입금확인(so_paid) 수동 토글(JSON 응답). + * 입금확인이 완료되어야 포장(스캔)이 가능하다. + */ + public function phoneOrderMarkPaid() + { + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.', 'csrf' => csrf_hash()]); + } + + $soIdx = (int) ($this->request->getPost('so_idx') ?? 0); + $paid = ((int) ($this->request->getPost('paid') ?? 0)) === 1 ? 1 : 0; + if ($soIdx <= 0) { + return $this->response->setJSON(['ok' => false, 'message' => '주문 값이 올바르지 않습니다.', 'csrf' => csrf_hash()]); + } + + $orderModel = model(ShopOrderModel::class); + $order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first(); + if (! $order) { + return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 찾을 수 없습니다.', 'csrf' => csrf_hash()]); + } + if ((string) ($order->so_status ?? '') === 'cancelled') { + return $this->response->setJSON(['ok' => false, 'message' => '취소된 주문은 입금상태를 변경할 수 없습니다.', 'csrf' => csrf_hash()]); + } + + $orderModel->update($soIdx, ['so_paid' => $paid]); + + return $this->response->setJSON(['ok' => true, 'so_idx' => $soIdx, 'paid' => $paid, 'csrf' => csrf_hash()]); + } + /** * 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답). * 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 + @@ -7921,6 +8016,11 @@ SQL; return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]); } + // 입금확인(so_paid=1)이 안 된 주문은 포장(판매) 스캔 불가. + if ((int) ($order->so_paid ?? 0) !== 1) { + return $this->response->setJSON(['ok' => false, 'message' => '입금확인이 완료되지 않은 주문입니다. 입금확인 후 포장하세요.', 'csrf' => csrf_hash()]); + } + $scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode); if (! ($scan['ok'] ?? false)) { return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()])); diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index 6a19789..57c9706 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -44,7 +44,11 @@ class Home extends BaseController } helper('admin'); - return view('bag/layout/workspace'); + // 계정별 저장된 탭 상태(자동 복원). 없으면 'null'. + $savedWorkspaceJson = (new \App\Controllers\Bag()) + ->loadWorkspaceTabsState((int) (session()->get('mb_idx') ?? 0)); + + return view('bag/layout/workspace', ['savedWorkspaceJson' => $savedWorkspaceJson]); } /** diff --git a/app/Views/bag/create_bag_order.php b/app/Views/bag/create_bag_order.php index 56ba81b..394dd86 100644 --- a/app/Views/bag/create_bag_order.php +++ b/app/Views/bag/create_bag_order.php @@ -788,6 +788,16 @@ } }); + // 박스수량 입력 중 Enter → 다음 봉투의 박스수량 입력칸으로 이동(#39) + selectedBody.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || !event.target.classList.contains('item-qty-box')) return; + event.preventDefault(); + const inputs = Array.from(selectedBody.querySelectorAll('.item-qty-box')); + const next = inputs[inputs.indexOf(event.target) + 1]; + if (next) { next.focus(); next.select(); } + else { event.target.blur(); } + }); + referenceRows.forEach((row) => { row.addEventListener('click', (event) => { const button = event.target.closest('.js-toggle-bag'); diff --git a/app/Views/bag/layout/workspace.php b/app/Views/bag/layout/workspace.php index 375f33c..468cd0b 100644 --- a/app/Views/bag/layout/workspace.php +++ b/app/Views/bag/layout/workspace.php @@ -191,18 +191,33 @@ if ($effectiveLgIdx) { var STORE_KEY = 'jrj_ws_tabs'; var WS_OWNER = 'get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별 + var WS_SAVE_URL = ''; + var WS_CSRF_NAME = ''; + var wsCsrfHash = ''; + var wsSaveTimer = null; + function wsStateObj() { + return { + owner: WS_OWNER, + tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }), + layout: layout, + focused: focused, + vRatio: vRatio, + hRatio: hRatio, + slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; }) + }; + } function persist() { - try { - sessionStorage.setItem(STORE_KEY, JSON.stringify({ - owner: WS_OWNER, - tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }), - layout: layout, - focused: focused, - vRatio: vRatio, - hRatio: hRatio, - slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; }) - })); - } catch (e) {} + var obj = wsStateObj(); + var json = JSON.stringify(obj); + try { sessionStorage.setItem(STORE_KEY, json); } catch (e) {} + // 계정(DB)에 저장 → 다음 로그인/다른 기기에서도 자동 복원 (연속 변경은 디바운스) + clearTimeout(wsSaveTimer); + wsSaveTimer = setTimeout(function () { + var b = new URLSearchParams(); + b.set(WS_CSRF_NAME, wsCsrfHash); b.set('state', json); + fetch(WS_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() }) + .then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) wsCsrfHash = d.csrf; }).catch(function () {}); + }, 500); } // 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성 @@ -560,11 +575,19 @@ if ($effectiveLgIdx) { }); }); - // 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할 + // 첫 화면: ① 계정(DB) 저장 자동복원 → ② 세션 저장 → ③ 없으면 대시보드 1분할 + var SERVER_STATE = ; (function restore() { var saved = null; - try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {} - if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; } + // ① 계정 DB에 저장된 상태 우선(다음 로그인·다른 기기에서도 복원) + if (SERVER_STATE && SERVER_STATE.tabs && SERVER_STATE.tabs.length) { + saved = SERVER_STATE; + } + // ② 없으면 이 브라우저 세션 저장 사용 + if (!saved) { + try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {} + if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; } + } if (saved && saved.tabs && saved.tabs.length) { saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); }); layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single'; diff --git a/app/Views/bag/order_phone.php b/app/Views/bag/order_phone.php index f9f9342..123c18d 100644 --- a/app/Views/bag/order_phone.php +++ b/app/Views/bag/order_phone.php @@ -187,7 +187,7 @@ unset($g); 금액 수량 금액 - 판매수량 + 판매수량(팩) 금액 B P @@ -212,7 +212,7 @@ unset($g); - + 0 0 0 @@ -330,12 +330,18 @@ unset($g); } function calcRow(row) { - const qtyInput = row.querySelector('.item-qty-input'); - const qty = parseInt(qtyInput.value || '0', 10) || 0; + const qtyInput = row.querySelector('.item-pack-input'); + const hiddenQty = row.querySelector('.item-qty-hidden'); + const packQty = parseInt(qtyInput.value || '0', 10) || 0; // 입력값 = 팩 수 const unitPrice = parseInt(row.dataset.unitPrice || '0', 10) || 0; const boxSheets = parseInt(row.dataset.boxSheets || '0', 10) || 0; const packSheets = parseInt(row.dataset.packSheets || '0', 10) || 0; + // 팩당 낱장 수(팩 매수 미설정 봉투는 1:1로 처리) + const mult = packSheets > 0 ? packSheets : 1; + const qty = packQty * mult; // 실제 낱장 수 → 서버로 전송 + if (hiddenQty) hiddenQty.value = String(qty); + let box = 0; let pack = 0; let sheet = qty; @@ -359,14 +365,14 @@ unset($g); row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : ''; row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : ''; - return { qty, amount, box, pack, sheet }; + return { packQty, amount, box, pack, sheet }; } function recalcAllRows() { let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0; document.querySelectorAll('.order-row').forEach((row) => { const r = calcRow(row); - sumQty += r.qty; + sumQty += r.packQty; sumAmount += r.amount; sumBox += r.box; sumPack += r.pack; diff --git a/app/Views/bag/order_phone_manage.php b/app/Views/bag/order_phone_manage.php index e57685c..29eda93 100644 --- a/app/Views/bag/order_phone_manage.php +++ b/app/Views/bag/order_phone_manage.php @@ -69,12 +69,14 @@
상태 -
+
입금상태 -
접수 품목 내역
+ @@ -238,6 +240,8 @@ 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('', window.location.href).pathname; const receiptBase = ''; btnReceipt?.addEventListener('click', () => { const id = inputSoIdx.value; @@ -252,6 +256,43 @@ 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) : ''); @@ -263,6 +304,12 @@ 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) { @@ -346,6 +393,7 @@ btnSave.disabled = isCancelled; btnCancel.disabled = isCancelled; if (btnReceipt) btnReceipt.disabled = false; + if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); } renderPacking(order); // 포장 명세 탭 동기화 diff --git a/writable/database/menu_rename_phone_to_order.sql b/writable/database/menu_rename_phone_to_order.sql new file mode 100644 index 0000000..ca48bc5 --- /dev/null +++ b/writable/database/menu_rename_phone_to_order.sql @@ -0,0 +1,5 @@ +-- 사용자 의견 #35: 메뉴 '전화 접수 관리' → '주문접수관리' 개명 +-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_rename_phone_to_order.sql +-- 멱등(이미 바뀌었으면 0건). + +UPDATE `menu` SET `mm_name` = '주문접수관리' WHERE `mm_name` = '전화 접수 관리'; diff --git a/writable/database/menu_site_seed_from_csv.sql b/writable/database/menu_site_seed_from_csv.sql index 200ed4e..a71931b 100644 --- a/writable/database/menu_site_seed_from_csv.sql +++ b/writable/database/menu_site_seed_from_csv.sql @@ -177,7 +177,7 @@ INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_ SELECT @mt_site, 1, t.mm_name, CASE t.mm_name WHEN '전화 접수' THEN 'bag/shop-orders' - WHEN '전화 접수 관리' THEN 'bag/shop-orders' + WHEN '주문접수관리' THEN 'bag/shop-orders' WHEN '지정 판매소 판매' THEN 'bag/sale/create' WHEN '지정 판매소 반품' THEN 'bag/bag-sales' WHEN '지정 판매소 판매 취소' THEN 'bag/bag-sales' @@ -187,7 +187,7 @@ SELECT @mt_site, 1, t.mm_name, @parent_sales, 1, t.mm_num, 0, '', 'Y' FROM ( SELECT 0 AS mm_num, '전화 접수' AS mm_name UNION ALL - SELECT 1, '전화 접수 관리' UNION ALL + SELECT 1, '주문접수관리' UNION ALL SELECT 2, '지정 판매소 판매' UNION ALL SELECT 3, '지정 판매소 반품' UNION ALL SELECT 4, '지정 판매소 판매 취소' UNION ALL