From d1683a3c6e3db40e40aa10c2c56bde78e2e111ff Mon Sep 17 00:00:00 2001 From: chpark Date: Fri, 8 May 2026 16:34:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(inventory+warehouse):=20=EC=9E=AC=EA=B3=A0?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20+=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20+=20=EC=B0=BD=EA=B3=A0=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [재고 관리] - 새 기능 '재고 이동' (A창고 → B창고): 출발창고 잠금 + 충분재고 검증 + 도착 창고 upsert + 이동 로그 2건(OUT/IN, ref_type=TRANSFER) 트랜잭션 처리 - /api/m/inventory/transfer 신규 - 모바일에서 테이블 가로 스크롤이 안 되던 문제 → sm:hidden 카드 + sm:block desktop 테이블로 분리. 페이지 자체 스크롤로 자연스러운 UX - 검색 영역 모바일 1열 / sm 3열 그리드 정리 - 재고 이동 모달은 출발창고 선택 시 그 창고에 재고 있는 품목만 셀렉트 - list API 응답에 WH_OBJID 추가 (이동 모달에서 출발창고 필터 용도) [창고 관리] - 창고 코드는 자동생성(WH001, WH002 ...) — 등록/수정 폼에서 readonly + 회색. save API: regist 시 nextWhCode() 로 MAX+1 패딩. update 시 wh_code 미변경 - 클라이언트가 whCode 보내도 무시되도록 서버에서 분기 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(main)/m/admin/inventory/page.tsx | 246 ++++++++++++++++----- src/app/(main)/m/admin/warehouses/page.tsx | 11 +- src/app/api/m/inventory/list/route.ts | 1 + src/app/api/m/inventory/transfer/route.ts | 72 ++++++ src/app/api/m/warehouses/save/route.ts | 28 ++- 5 files changed, 291 insertions(+), 67 deletions(-) create mode 100644 src/app/api/m/inventory/transfer/route.ts diff --git a/src/app/(main)/m/admin/inventory/page.tsx b/src/app/(main)/m/admin/inventory/page.tsx index b27d76a..1f6d1c9 100644 --- a/src/app/(main)/m/admin/inventory/page.tsx +++ b/src/app/(main)/m/admin/inventory/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; -import { Plus, Search, Trash2, History } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Plus, Search, Trash2, History, ArrowRightLeft, Package } from "lucide-react"; import { useRouter } from "next/navigation"; import Swal from "sweetalert2"; -interface Stock { OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string } +interface Stock { OBJID: string; WH_OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string } interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string } interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number } interface InboundLine { itemObjid: string; itemName: string; qty: number; costPrice?: number } @@ -20,6 +20,7 @@ export default function InventoryPage() { const [whFilter, setWhFilter] = useState(""); const [keyword, setKeyword] = useState(""); const [inboundOpen, setInboundOpen] = useState(false); + const [transferOpen, setTransferOpen] = useState(false); // 입고 폼 const [inboundWh, setInboundWh] = useState(""); @@ -27,6 +28,12 @@ export default function InventoryPage() { const [pickItem, setPickItem] = useState(""); const [pickQty, setPickQty] = useState(1); + // 이동 폼 + const [trFrom, setTrFrom] = useState(""); + const [trTo, setTrTo] = useState(""); + const [trItem, setTrItem] = useState(""); + const [trQty, setTrQty] = useState(1); + const load = async () => { const res = await fetch("/api/m/inventory/list", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -73,84 +80,164 @@ export default function InventoryPage() { } }; + // 출발 창고에 재고가 있는 품목만 추리기 (현재 화면 list 기준) + const trItemsInFrom = useMemo(() => { + if (!trFrom) return [] as Stock[]; + return list.filter((s) => s.WH_OBJID === trFrom && Number(s.QTY) > 0); + }, [list, trFrom]); + + const trCurrent = useMemo(() => { + if (!trFrom || !trItem) return 0; + return Number(list.find((s) => s.WH_OBJID === trFrom && s.ITEM_OBJID === trItem)?.QTY ?? 0); + }, [list, trFrom, trItem]); + + const submitTransfer = async () => { + if (!trFrom || !trTo || !trItem || trQty <= 0) { + Swal.fire({ icon: "warning", title: "출발창고/도착창고/품목/수량을 입력하세요." }); + return; + } + if (trFrom === trTo) { + Swal.fire({ icon: "warning", title: "출발창고와 도착창고가 같습니다." }); + return; + } + if (trQty > trCurrent) { + Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고 ${trCurrent}` }); + return; + } + const res = await fetch("/api/m/inventory/transfer", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fromWhObjid: trFrom, toWhObjid: trTo, itemObjid: trItem, qty: trQty }), + }); + const j = await res.json().catch(() => ({ success: false, message: "응답 파싱 실패" })); + if (j.success) { + Swal.fire({ icon: "success", title: "재고 이동 완료", text: j.message, timer: 1500, showConfirmButton: false }); + setTransferOpen(false); + setTrFrom(""); setTrTo(""); setTrItem(""); setTrQty(1); + load(); + } else { + Swal.fire({ icon: "error", title: "이동 실패", text: j.message }); + } + }; + return ( -
-
-

재고 관리

-
+
+
+

재고 관리

+
- +
-
- -
- - setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명/코드" className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" /> + {/* 검색 영역 — 모바일 1열 / sm 2열 / lg 옆으로 */} +
+
+ +
+ + setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명 / 코드" className="w-full h-9 pl-8 pr-3 rounded-lg border border-slate-200 text-sm" /> +
+
-
-
- - - - - - - - - - - - - {list.length === 0 ? ( - - ) : list.map((s) => ( - - - - - - - + {/* 데스크톱 테이블 */} +
+
+
창고품목코드품목명구분현재고최종 변경
재고 데이터가 없습니다. 매입 입고로 등록하세요.
{s.WH_NAME}{s.ITEM_CODE}{s.ITEM_NAME} - {s.IS_TAX_FREE === "Y" - ? 면세 - : 과세} - {fmt(s.QTY)} {s.UNIT}{s.UPDATE_DATE}
+ + + + + + + + - ))} - -
창고품목코드품목명구분현재고최종 변경
+ + + {list.length === 0 ? ( + 재고 데이터가 없습니다. 매입 입고로 등록하세요. + ) : list.map((s) => ( + + {s.WH_NAME} + {s.ITEM_CODE} + {s.ITEM_NAME} + + {s.IS_TAX_FREE === "Y" + ? 면세 + : 과세} + + {fmt(s.QTY)} {s.UNIT} + {s.UPDATE_DATE} + + ))} + + +
+
+ + {/* 모바일 카드 — 페이지 자체 스크롤 사용 (테이블 가로 스크롤 X) */} +
+ {list.length === 0 ? ( +
+ + 재고 데이터가 없습니다. +
+ ) : list.map((s) => ( +
+
+
+
{s.ITEM_CODE}
+
{s.ITEM_NAME}
+
{s.WH_NAME}
+
+
+
{fmt(s.QTY)}{s.UNIT}
+ {s.IS_TAX_FREE === "Y" + ? 면세 + : 과세} +
+
+
+ 최종 변경 · {s.UPDATE_DATE} +
+
+ ))}
{/* 입고 모달 */} {inboundOpen && ( -
setInboundOpen(false)}> -
e.stopPropagation()} className="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"> +
setInboundOpen(false)}> +
e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-2xl w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">

매입 입고 등록

- setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white"> {whs.map((w) => )} -
- setPickItem(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 bg-white min-w-0"> {items.map((i) => )} - setPickQty(Number(e.target.value))} className="w-24 h-10 px-3 rounded-lg border border-slate-200" /> + setPickQty(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200" />
@@ -158,7 +245,7 @@ export default function InventoryPage() {
품목을 추가하세요
) : ( - + @@ -183,6 +270,51 @@ export default function InventoryPage() { )} + + {/* 재고 이동 모달 — A창고 → B창고 */} + {transferOpen && ( +
setTransferOpen(false)}> +
e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-md w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto"> +

재고 이동

+

A창고에서 B창고로 수량을 옮깁니다.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + setTrQty(Number(e.target.value))} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-right tabular-nums" /> +
+
+
+ + +
+
+
+ )} ); } diff --git a/src/app/(main)/m/admin/warehouses/page.tsx b/src/app/(main)/m/admin/warehouses/page.tsx index 9a67b82..9482e1b 100644 --- a/src/app/(main)/m/admin/warehouses/page.tsx +++ b/src/app/(main)/m/admin/warehouses/page.tsx @@ -34,7 +34,7 @@ export default function WarehousesPage() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist", - whCode: editing.WH_CODE, whName: editing.WH_NAME, + whName: editing.WH_NAME, location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK", }), }); @@ -119,7 +119,14 @@ export default function WarehousesPage() { className="bg-white rounded-xl max-w-md w-full p-5 sm:p-6 max-h-[90vh] overflow-y-auto">

{editing.OBJID ? "창고 수정" : "창고 추가"}

- setEditing({ ...editing, WH_CODE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> +
+ + +
setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
품목수량