fix(inventory): 재고관리 매트릭스 — 품목 가로(기본) / 창고 가로 토글
Deploy momo-erp / deploy (push) Successful in 2m9s

This commit is contained in:
chpark
2026-05-15 01:16:56 +09:00
parent 71cf966781
commit a120803799
+127 -40
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Plus, Search, Trash2, History, ArrowRightLeft, Package } from "lucide-react";
import { Plus, Search, Trash2, History, ArrowRightLeft, Package, LayoutGrid, Columns3 } from "lucide-react";
import { useRouter } from "next/navigation";
import Swal from "sweetalert2";
@@ -35,6 +35,29 @@ export default function InventoryPage() {
const [trItem, setTrItem] = useState("");
const [trQty, setTrQty] = useState(1);
// 매트릭스 보기 모드 — 기본 '품목 가로' (헤더=품목, 행=창고). 토글로 '창고 가로'.
const [viewMode, setViewMode] = useState<"by-item" | "by-wh">("by-item");
// list 평면 → 매트릭스 pivot
const matrix = useMemo(() => {
const itemSet = new Map<string, { OBJID: string; CODE: string; NAME: string; UNIT: string; IS_TAX_FREE: string }>();
const whSet = new Map<string, { OBJID: string; CODE: string; NAME: string }>();
const cell: Record<string, Record<string, { qty: number; updateDate: string }>> = {};
// cell[itemObjid][whObjid] = { qty, updateDate }
for (const s of list) {
if (!itemSet.has(s.ITEM_OBJID)) itemSet.set(s.ITEM_OBJID, {
OBJID: s.ITEM_OBJID, CODE: s.ITEM_CODE, NAME: s.ITEM_NAME, UNIT: s.UNIT, IS_TAX_FREE: s.IS_TAX_FREE,
});
if (!whSet.has(s.WH_OBJID)) whSet.set(s.WH_OBJID, { OBJID: s.WH_OBJID, CODE: s.WH_CODE, NAME: s.WH_NAME });
if (!cell[s.ITEM_OBJID]) cell[s.ITEM_OBJID] = {};
cell[s.ITEM_OBJID][s.WH_OBJID] = { qty: Number(s.QTY), updateDate: s.UPDATE_DATE };
}
// 창고 7개 — list 에 없는 창고도 헤더에 포함시키려면 whs 사용
const allWhs = [...whs].sort((a, b) => a.WH_CODE.localeCompare(b.WH_CODE));
const itemList = Array.from(itemSet.values()).sort((a, b) => a.NAME.localeCompare(b.NAME));
return { items: itemList, warehouses: allWhs, cell };
}, [list, whs]);
const load = async () => {
const res = await fetch("/api/m/inventory/list", {
method: "POST", headers: { "Content-Type": "application/json" },
@@ -158,46 +181,110 @@ export default function InventoryPage() {
</div>
</div>
{/* 데스크톱 테이블 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-left px-4 py-3"> </th>
<th className="text-center px-4 py-3 w-[100px]"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400"> . .</td></tr>
) : list.map((s) => (
<tr key={s.OBJID} className="border-t border-slate-100">
<td className="px-4 py-3">{s.WH_NAME}</td>
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
<td className="px-4 py-3 text-center">
{s.IS_TAX_FREE === "Y"
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold"></span>}
</td>
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
<td className="px-4 py-3 text-center">
<button onClick={() => setHistoryOpen({ itemObjid: s.ITEM_OBJID, whObjid: s.WH_OBJID, itemName: s.ITEM_NAME, whName: s.WH_NAME })}
className="inline-flex items-center gap-1 h-7 px-2 rounded bg-slate-100 hover:bg-slate-200 text-slate-700 text-[11px] font-bold">
<History size={12} />
</button>
</td>
{/* 데스크톱 — 매트릭스 토글 (품목 가로 ↔ 창고 가로) */}
<div className="hidden sm:block">
<div className="flex items-center justify-end mb-2">
<div className="inline-flex rounded border border-slate-300 overflow-hidden">
<button onClick={() => setViewMode("by-item")}
className={`h-8 px-3 text-xs font-semibold inline-flex items-center gap-1 ${viewMode === "by-item" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
<LayoutGrid size={13} />
</button>
<button onClick={() => setViewMode("by-wh")}
className={`h-8 px-3 text-xs font-semibold inline-flex items-center gap-1 border-l border-slate-300 ${viewMode === "by-wh" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
<Columns3 size={13} />
</button>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
{matrix.items.length === 0 ? (
<div className="text-center py-12 text-slate-400"> . .</div>
) : viewMode === "by-item" ? (
/* 품목 가로 (기본): 헤더=품목, 행=창고 */
<table className="text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 sticky left-0 bg-slate-50 z-10 min-w-[140px]"></th>
{matrix.items.map((it) => (
<th key={it.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px] whitespace-nowrap">
<div>{it.NAME}</div>
<div className="text-[10px] text-slate-400 font-mono font-normal">{it.CODE}</div>
</th>
))}
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="tabular-nums">
{matrix.warehouses.map((w) => (
<tr key={w.OBJID} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 font-semibold sticky left-0 bg-white">
{w.WH_NAME}
<div className="text-[10px] text-slate-400 font-mono">{w.WH_CODE}</div>
</td>
{matrix.items.map((it) => {
const c = matrix.cell[it.OBJID]?.[w.OBJID];
const qty = c ? c.qty : 0;
return (
<td key={it.OBJID} className={`px-3 py-2 text-right ${qty === 0 ? "text-slate-300" : "text-slate-800 font-semibold"}`}>
{qty === 0 ? "-" : (
<button
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
className="hover:underline hover:text-emerald-700"
title="재고 이력"
>
{fmt(qty)} {it.UNIT}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
) : (
/* 창고 가로: 헤더=창고, 행=품목 */
<table className="w-full text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 min-w-[200px]"></th>
{matrix.warehouses.map((w) => (
<th key={w.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px]">
{w.WH_NAME}
</th>
))}
</tr>
</thead>
<tbody className="tabular-nums">
{matrix.items.map((it) => (
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2">
<span className="font-semibold">{it.NAME}</span>
<span className="ml-2 text-[10px] text-slate-400 font-mono">{it.CODE}</span>
{it.IS_TAX_FREE === "Y"
? <span className="ml-2 px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold"></span>
: <span className="ml-2 px-1 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold"></span>}
</td>
{matrix.warehouses.map((w) => {
const c = matrix.cell[it.OBJID]?.[w.OBJID];
const qty = c ? c.qty : 0;
return (
<td key={w.OBJID} className={`px-3 py-2 text-right ${qty === 0 ? "text-slate-300" : "text-slate-800 font-semibold"}`}>
{qty === 0 ? "-" : (
<button
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
className="hover:underline hover:text-emerald-700"
title="재고 이력"
>
{fmt(qty)} {it.UNIT}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>