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>
This commit is contained in:
taekyoungc
2026-07-23 08:04:45 +09:00
parent dc5c38d242
commit 215d87f991
9 changed files with 221 additions and 23 deletions

View File

@@ -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');

View File

@@ -191,18 +191,33 @@ if ($effectiveLgIdx) {
var STORE_KEY = 'jrj_ws_tabs';
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
var WS_SAVE_URL = '<?= site_url('bag/pref/workspace-tabs') ?>';
var WS_CSRF_NAME = '<?= csrf_token() ?>';
var wsCsrfHash = '<?= csrf_hash() ?>';
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 = <?= $savedWorkspaceJson ?? 'null' ?>;
(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';

View File

@@ -187,7 +187,7 @@ unset($g);
<th class="text-right">금액</th>
<th class="text-right">수량</th>
<th class="text-right">금액</th>
<th class="text-right">판매수량</th>
<th class="text-right">판매수량(팩)</th>
<th class="text-right">금액</th>
<th class="text-center">B</th>
<th class="text-center">P</th>
@@ -212,7 +212,7 @@ unset($g);
<td class="text-right px-2"><?= number_format($r['box']) ?></td>
<td class="text-right px-2"><?= number_format($boxPrice) ?></td>
<td class="text-right px-2"><?= number_format($r['price']) ?></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input item-pack-input" type="number" min="0" value="0"/><input type="hidden" name="item_qty[]" class="item-qty-hidden" value="0"/></td>
<td class="text-right px-2 item-amount-cell">0</td>
<td class="text-center item-box-cell">0</td>
<td class="text-center item-pack-cell">0</td>
@@ -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;

View File

@@ -69,12 +69,14 @@
<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>
@@ -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('<?= 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;
@@ -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); // 포장 명세 탭 동기화