feat(menu+filter+history): 사이드바 메뉴 노출 + 자동조회 + 재고이력 한글/이동 상대창고 표시
Deploy momo-erp / deploy (push) Successful in 2m53s
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user