feat(menu+filter+history): 사이드바 메뉴 노출 + 자동조회 + 재고이력 한글/이동 상대창고 표시
Deploy momo-erp / deploy (push) Successful in 2m53s

1) 사이드바 메뉴 누락 fix (마이그레이션 026):
   - FITO menu_info 테이블에 9000304 '매입 입금관리', 9000305 '재고이력' INSERT
   - 기존 입고/재고 seq 재정렬 (11→12, 12→13)
   - momo_menus 만으로는 사이드바에 안 나옴 — menu_info 가 사이드바의 진짜 소스

2) 재고이력 표시 개선:
   - inventory/history API: REF_TYPE_LABEL (한글) + COUNTER_WH_NAME (이동 시 상대 창고) 추가
   - inventory/transfer 라우트: stock_moves 의 ref_objid 에 상대 창고 objid 박음
   - StockHistoryModal + history page: "INBOUND" → "입고", TRANSFER 시 "→ XX창고/← XX창고" 표시

3) 자동조회 (조회 버튼 없이 즉시):
   - m/orders (내발주이력): 날짜 from~to + 상태 input 추가 + state dep useEffect
   - m/orders/new (출고요청): "재고있는 품목만 / 전체 품목" 필터 추가 + 250ms 디바운스 자동 fetch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-13 14:22:28 +09:00
parent bf2339c242
commit 7d18285ac6
7 changed files with 92 additions and 21 deletions
@@ -0,0 +1,19 @@
-- FITO menu_info 에 매입 입금관리 + 재고이력 메뉴 추가 (사이드바에 노출되도록)
-- 부모: 9000300 매입/입고
-- 기존 seq 재정렬
UPDATE menu_info SET seq = '12' WHERE objid = '9000302'; -- 입고 처리: 11→12
UPDATE menu_info SET seq = '13' WHERE objid = '9000303'; -- 재고 관리: 12→13
-- 매입 입금관리 (seq 11) + 재고이력 (seq 14) — idempotent
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate)
VALUES
('9000304', '1', '9000300', '매입 입금관리', 'Proc Payment', '11', '/m/admin/proc-payments', 'active', 'PMS', NOW()),
('9000305', '1', '9000300', '재고이력', 'Stock History', '14', '/m/admin/inventory/history', 'active', 'PMS', NOW())
ON CONFLICT (objid) DO UPDATE SET
menu_name_kor = EXCLUDED.menu_name_kor,
menu_name_eng = EXCLUDED.menu_name_eng,
seq = EXCLUDED.seq,
menu_url = EXCLUDED.menu_url,
status = 'active',
system_name = EXCLUDED.system_name;
@@ -13,7 +13,9 @@ interface Move {
MOVE_TYPE_NAME: string;
QTY: number;
REF_TYPE: string;
REF_TYPE_LABEL?: string;
REF_OBJID: string;
COUNTER_WH_NAME?: string | null;
MEMO: string;
REGID: string;
REGDATE: string;
@@ -186,7 +188,14 @@ export default function InventoryHistoryPage() {
{m.MOVE_TYPE === "OUT" ? "-" : "+"}{fmt(m.QTY)}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-500">{m.REF_TYPE || "-"}</td>
<td className="px-3 py-2 text-xs text-slate-700">
{m.REF_TYPE_LABEL || m.REF_TYPE || "-"}
{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (
<span className="ml-1 text-slate-500">
{m.MOVE_TYPE === "OUT" ? `${m.COUNTER_WH_NAME}` : `${m.COUNTER_WH_NAME}`}
</span>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-500 max-w-[200px] truncate">{m.MEMO || "-"}</td>
<td className="px-3 py-2 text-xs text-slate-500">{m.REGID || "-"}</td>
<td className="px-3 py-2 text-center text-xs text-slate-500">{m.REGDATE}</td>
@@ -215,7 +224,7 @@ export default function InventoryHistoryPage() {
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${MOVE_TYPE_BADGE[m.MOVE_TYPE] || "bg-slate-100 text-slate-600"}`}>
{m.MOVE_TYPE_NAME}
</span>
{m.REF_TYPE && <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 font-mono">{m.REF_TYPE}</span>}
{m.REF_TYPE && <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">{m.REF_TYPE_LABEL || m.REF_TYPE}{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (m.MOVE_TYPE === "OUT" ? `${m.COUNTER_WH_NAME}` : `${m.COUNTER_WH_NAME}`)}</span>}
</div>
<div className="font-bold text-sm text-slate-900 truncate">{m.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400 font-mono">{m.ITEM_CODE}</div>
+10 -1
View File
@@ -348,7 +348,9 @@ interface MoveRow {
MOVE_TYPE_NAME?: string;
QTY: number;
REF_TYPE: string;
REF_TYPE_LABEL?: string;
REF_OBJID: string;
COUNTER_WH_NAME?: string | null;
MEMO: string | null;
REGID: string | null;
REGDATE: string;
@@ -424,7 +426,14 @@ function StockHistoryModal({
<td className={`px-2 py-2 text-right tabular-nums font-bold ${Number(m.QTY) < 0 ? "text-rose-600" : "text-emerald-700"}`}>
{Number(m.QTY) > 0 ? "+" : ""}{fmt(m.QTY)}
</td>
<td className="px-2 py-2 text-[10px] text-slate-500">{m.REF_TYPE || "-"}</td>
<td className="px-2 py-2 text-[11px] text-slate-700">
{m.REF_TYPE_LABEL || m.REF_TYPE || "-"}
{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (
<span className="ml-1 text-slate-500">
{m.MOVE_TYPE === "OUT" ? `${m.COUNTER_WH_NAME}` : `${m.COUNTER_WH_NAME}`}
</span>
)}
</td>
<td className="px-2 py-2 text-[11px] truncate max-w-[150px]">{m.MEMO || "-"}</td>
<td className="px-2 py-2 text-[11px] text-slate-500">{m.REGID || "-"}</td>
</tr>
+16 -6
View File
@@ -34,6 +34,7 @@ export default function ItemsBrowse() {
const [items, setItems] = useState<Item[]>([]);
const [keyword, setKeyword] = useState("");
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
const [stockFilter, setStockFilter] = useState<"AVAILABLE" | "ALL">("AVAILABLE");
const [loading, setLoading] = useState(false);
const [cart, setCart] = useState<CartLine[]>([]);
const [extras, setExtras] = useState<ExtraLine[]>([]);
@@ -56,14 +57,22 @@ export default function ItemsBrowse() {
const res = await fetch("/api/m/items/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword, isTaxFree: taxFilter || undefined }),
body: JSON.stringify({
keyword,
isTaxFree: taxFilter || undefined,
onlyAvailable: stockFilter === "AVAILABLE",
}),
});
const j = await res.json();
setItems(j.RESULTLIST ?? []);
setLoading(false);
}, [keyword, taxFilter]);
}, [keyword, taxFilter, stockFilter]);
useEffect(() => { fetchItems(); }, []); // eslint-disable-line
// 검색조건 변경 시 즉시 자동 조회 (디바운스 250ms)
useEffect(() => {
const t = setTimeout(() => { fetchItems(); }, 250);
return () => clearTimeout(t);
}, [fetchItems]);
// 카트에 택배전용 품목이 있는지
const cartNeedsDelivery = useMemo(
@@ -442,9 +451,10 @@ export default function ItemsBrowse() {
<option value="Y"></option>
<option value="N"></option>
</select>
<button onClick={fetchItems} className="h-10 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
</button>
<select value={stockFilter} onChange={(e) => setStockFilter(e.target.value as "AVAILABLE" | "ALL")} className="h-10 px-2 sm:px-3 rounded-lg border border-slate-200 text-sm">
<option value="AVAILABLE"> </option>
<option value="ALL"> </option>
</select>
{/* 보기 모드 토글 */}
<div className="flex items-center bg-slate-100 rounded-lg p-0.5 ml-auto">
<button
+14 -5
View File
@@ -56,8 +56,8 @@ export default function MyOrdersPage() {
};
})();
const [status, setStatus] = useState(initial.status);
const [dateFrom] = useState(initial.dateFrom);
const [dateTo] = useState(initial.dateTo);
const [dateFrom, setDateFrom] = useState(initial.dateFrom);
const [dateTo, setDateTo] = useState(initial.dateTo);
const [detail, setDetail] = useState<{ order: Order & { CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string; EMAIL?: string; MEMO?: string }; items: DetailLine[]; supplier: Supplier } | null>(null);
const load = async () => {
@@ -73,7 +73,8 @@ export default function MyOrdersPage() {
setOrders(j.RESULTLIST ?? []);
};
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// 검색조건 변경 시 즉시 자동 조회
useEffect(() => { load(); }, [status, dateFrom, dateTo]); // eslint-disable-line react-hooks/exhaustive-deps
const openDetail = async (o: Order) => {
const res = await fetch("/api/m/orders/detail", {
@@ -136,12 +137,20 @@ export default function MyOrdersPage() {
</div>
</div>
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 flex-wrap items-center">
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<span className="text-slate-400">~</span>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
<option value=""> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></button>
{(dateFrom || dateTo || status) && (
<button onClick={() => { setDateFrom(""); setDateTo(""); setStatus(""); }}
className="h-10 px-3 rounded-lg border border-slate-200 text-slate-600 text-xs"></button>
)}
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
+16 -2
View File
@@ -65,13 +65,27 @@ export async function POST(req: NextRequest) {
END AS "MOVE_TYPE_NAME",
SM.qty AS "QTY",
SM.ref_type AS "REF_TYPE",
CASE SM.ref_type
WHEN 'INBOUND' THEN '입고'
WHEN 'PROCUREMENT' THEN '매입발주'
WHEN 'ORDER' THEN '출고'
WHEN 'TRANSFER' THEN '이동'
WHEN 'ADJUST' THEN '재고조정'
ELSE COALESCE(SM.ref_type, '-')
END AS "REF_TYPE_LABEL",
SM.ref_objid AS "REF_OBJID",
-- TRANSFER 의 경우 ref_objid 는 상대 창고 — 그 창고명 join
CASE WHEN SM.ref_type = 'TRANSFER'
THEN CW.wh_name
ELSE NULL
END AS "COUNTER_WH_NAME",
SM.memo AS "MEMO",
SM.regid AS "REGID",
TO_CHAR(SM.regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE"
FROM momo_stock_moves SM
LEFT JOIN momo_warehouses W ON SM.wh_objid = W.objid
LEFT JOIN momo_items I ON SM.item_objid = I.objid
LEFT JOIN momo_warehouses W ON SM.wh_objid = W.objid
LEFT JOIN momo_warehouses CW ON SM.ref_objid = CW.objid::text AND SM.ref_type = 'TRANSFER'
LEFT JOIN momo_items I ON SM.item_objid = I.objid
WHERE ${conditions.join(" AND ")}
ORDER BY SM.regdate DESC
LIMIT 500`,
+6 -5
View File
@@ -51,12 +51,13 @@ export async function POST(req: NextRequest) {
[createObjectId(), toWhObjid, itemObjid, qty]
);
// 이동 로그 — 출발(OUT) + 도착(IN)
// 이동 로그 — 출발(OUT)에 ref_objid=도착창고, 도착(IN)에 ref_objid=출발창고
// (history 조회 시 ref_objid 로 상대 창고 join 해서 "→/←" 표기)
await client.query(
`INSERT INTO momo_stock_moves (objid, wh_objid, item_objid, move_type, qty, ref_type, memo, regdate, regid)
VALUES ($1, $2, $3, 'OUT', $4, 'TRANSFER', $5, NOW(), $6),
($7, $8, $3, 'IN', $4, 'TRANSFER', $5, NOW(), $6)`,
[createObjectId(), fromWhObjid, itemObjid, qty, memo ?? null, userId, createObjectId(), toWhObjid]
`INSERT INTO momo_stock_moves (objid, wh_objid, item_objid, move_type, qty, ref_type, ref_objid, memo, regdate, regid)
VALUES ($1, $2, $3, 'OUT', $4, 'TRANSFER', $9, $5, NOW(), $6),
($7, $8, $3, 'IN', $4, 'TRANSFER', $2, $5, NOW(), $6)`,
[createObjectId(), fromWhObjid, itemObjid, qty, memo ?? null, userId, createObjectId(), toWhObjid, toWhObjid]
);
await client.query("COMMIT");