feat(orders): ITEM 수량/비고 인라인 즉시저장 + 왼쪽 리스트 실시간 갱신
Deploy momo-erp / deploy (push) Successful in 2m35s

1) ITEM 라인 수량을 QtyInput 인라인 인풋으로 (REQUESTED/APPROVED 상태).
   onBlur/Enter 시 /api/m/orders/items/update 호출 → 자동 저장.
2) 비고(REMARK) 는 이미 onBlur 자동 저장이었음. 출고요청/출고완료 모두
   editable 이라 동작.
3) ExtraRow(택배/용차) 의 V(저장) 버튼 제거. onBlur 시 자동 저장으로 변경
   — label/단가/수량 어느 인풋이든 포커스 떠나면 자동 commit.
4) 모든 라인 수정 작업 (saveRemark/saveItemQty/upsertExtra/deleteExtra)
   에서 onReload + onReloadList 동시 호출 → 왼쪽 발주 리스트의 합계도
   즉시 반영.

부수: 운영 DB 의 모든 user_info 비밀번호를 '1' 로 reset (사용자 요청).
This commit is contained in:
chpark
2026-05-14 01:01:06 +09:00
parent 053a21c30e
commit 34ee374796
+58 -23
View File
@@ -456,24 +456,35 @@ function StatementPreview({
});
};
// 비고 저장
// 비고 저장 — 명세표 + 왼쪽 리스트 모두 갱신
const saveRemark = async (lineObjid: string, remark: string) => {
const res = await fetch("/api/m/orders/items/remark", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lineObjid, remark }),
});
const j = await res.json();
if (j.success) onReload();
if (j.success) { onReload(); onReloadList(); }
else Swal.fire({ icon: "error", title: "비고 저장 실패", text: j.message });
};
// ITEM 라인 수량 즉시 저장 — items/update API
const saveItemQty = async (lineObjid: string, qty: number) => {
const res = await fetch("/api/m/orders/items/update", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid: order.OBJID, lines: [{ objid: lineObjid, qty }] }),
});
const j = await res.json();
if (j.success) { onReload(); onReloadList(); }
else Swal.fire({ icon: "error", title: "수량 저장 실패", text: j.message });
};
const upsertExtra = async (line: { objid?: string; kind: "DELIVERY" | "CHARTER"; label: string; unitPrice: number; qty: number }) => {
const res = await fetch("/api/m/orders/lines/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid: order.OBJID, lines: [line] }),
});
const j = await res.json();
if (j.success) onReload();
if (j.success) { onReload(); onReloadList(); }
else Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
};
const deleteExtra = async (objid: string) => {
@@ -484,7 +495,7 @@ function StatementPreview({
body: JSON.stringify({ orderObjid: order.OBJID, lines: [{ objid, kind: "CHARTER", unitPrice: 0, qty: 1, delete: true }] }),
});
const j = await res.json();
if (j.success) onReload();
if (j.success) { onReload(); onReloadList(); }
else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message });
};
const addNewExtra = (kind: "DELIVERY" | "CHARTER") => {
@@ -671,7 +682,11 @@ function StatementPreview({
<td className={`border border-slate-300 px-1.5 py-1 text-right js-no-export ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}>
{isExtra ? "-" : fmt(it.STOCK_QTY)}
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
<td className="border border-slate-300 px-1 py-0.5 text-right">
{editable
? <QtyInput initial={Number(it.QTY)} onSave={(q) => saveItemQty(it.OBJID, q)} />
: fmt(it.QTY)}
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.UNIT_PRICE)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.SUPPLY_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
@@ -752,11 +767,16 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark }
const total = Math.round(unitPrice * qty);
const supply = Math.round(total / 1.1);
const vat = total - supply;
const dirty = label !== (line.EXTRA_LABEL || line.ITEM_NAME)
|| unitPrice !== Number(line.UNIT_PRICE)
|| qty !== Number(line.QTY);
const isDelivery = line.KIND === "DELIVERY";
// onBlur 시 자동 저장 (값이 바뀐 경우만). V 버튼 제거.
const commit = () => {
const dirty = label !== (line.EXTRA_LABEL || line.ITEM_NAME)
|| unitPrice !== Number(line.UNIT_PRICE)
|| qty !== Number(line.QTY);
if (dirty && qty > 0 && unitPrice >= 0) onSave({ label, unitPrice, qty });
};
return (
<tr className={isDelivery ? "bg-orange-50" : "bg-sky-50"}>
<td className="border border-slate-300 px-1.5 py-1 text-center">{displaySeq}</td>
@@ -767,6 +787,8 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark }
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
className="h-6 px-1.5 border border-slate-200 rounded text-[11px] bg-white w-[calc(100%-50px)] inline"
/>
</td>
@@ -775,11 +797,15 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark }
<td className="border border-slate-300 px-1 py-1 text-right">
<input type="number" min={1} value={qty}
onChange={(e) => setQty(Number(e.target.value))}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
className="w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" />
</td>
<td className="border border-slate-300 px-1 py-1 text-right">
<input type="number" min={0} value={unitPrice}
onChange={(e) => setUnitPrice(Number(e.target.value))}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
className="w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" />
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums">{Number(supply).toLocaleString("ko-KR")}</td>
@@ -791,21 +817,9 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark }
: <span className="text-slate-600 text-[10px]">{line.REMARK || ""}</span>}
</td>
<td className="border border-slate-300 px-1 py-1 text-center">
<div className="inline-flex gap-0.5">
{dirty && (
<button
type="button"
onClick={() => onSave({ label, unitPrice, qty })}
className="text-emerald-700 hover:text-emerald-900"
title="저장"
>
<Check size={12} />
</button>
)}
<button type="button" onClick={onDelete} className="text-rose-500 hover:text-rose-700" title="삭제">
<X size={12} />
</button>
</div>
<button type="button" onClick={onDelete} className="text-rose-500 hover:text-rose-700" title="삭제">
<X size={12} />
</button>
</td>
</tr>
);
@@ -900,3 +914,24 @@ function RemarkInput({ initial, onSave }: { initial: string; onSave: (r: string)
</div>
);
}
// ITEM 라인 수량 인라인 인풋 — onBlur / Enter 시 자동 저장
function QtyInput({ initial, onSave }: { initial: number; onSave: (q: number) => void }) {
const [val, setVal] = useState(String(initial));
useEffect(() => { setVal(String(initial)); }, [initial]);
const commit = () => {
const n = Number(val);
if (!Number.isFinite(n) || n <= 0) { setVal(String(initial)); return; }
if (n === initial) return;
onSave(n);
};
return (
<input
type="number" min={1} value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
className="w-16 h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white"
/>
);
}