매트릭스 뷰 — 행: 품목, 열: 창고 7개, 셀: 발주수량/여유분 2행.
- 발주수량 = 현재 보유 재고 (momo_stocks.qty)
- 여유분 = 현재고 - 기간 내 발주(REQUESTED/APPROVED/INVOICED/PAID)
의 출고 예정 수량 (거래처 default 창고 기준)
- 기간 필터: dateFrom/dateTo (기본 이번 주 월 ~ 오늘) + '금주' 버튼
- 엑셀 다운로드 지원
- 여유분 음수면 rose 강조 (재고 부족 경고)
운영 DB menu_info 9000420 등록 (parent 9000400 출고/정산).
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCcw, Warehouse, Download } from "lucide-react";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
|
||||
interface ItemRow {
|
||||
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
STOCK: Record<string, number>; // wh_code → 현재고
|
||||
AVAILABLE: Record<string, number>; // wh_code → 여유분(현재고 - 진행중)
|
||||
TOTAL_STOCK: number;
|
||||
TOTAL_PENDING: number;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
// 이번 주 월요일 → 오늘
|
||||
function weekRange() {
|
||||
const today = new Date();
|
||||
const day = today.getDay(); // 0=일
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() + mondayOffset);
|
||||
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return [iso(monday), iso(today)];
|
||||
}
|
||||
|
||||
export default function WhStockStatusPage() {
|
||||
const [[dateFrom, dateTo], setRange] = useState(weekRange());
|
||||
const [warehouses, setWarehouses] = useState<Wh[]>([]);
|
||||
const [items, setItems] = useState<ItemRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/wh-stock-status", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom, dateTo }),
|
||||
});
|
||||
const j = await res.json();
|
||||
setWarehouses(j.WAREHOUSES ?? []);
|
||||
setItems(j.ITEMS ?? []);
|
||||
} finally { setLoading(false); }
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onExport = () => {
|
||||
if (items.length === 0) return;
|
||||
const cols = [
|
||||
{ header: "품목", key: "ITEM_NAME" },
|
||||
{ header: "분류", key: "KIND" },
|
||||
...warehouses.map((w) => ({ header: w.WH_NAME, key: w.WH_CODE })),
|
||||
];
|
||||
type Row = Record<string, string | number>;
|
||||
const data: Row[] = [];
|
||||
for (const it of items) {
|
||||
const stockRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "발주수량(현재고)" };
|
||||
const availRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "여유분" };
|
||||
for (const w of warehouses) {
|
||||
stockRow[w.WH_CODE] = Number(it.STOCK[w.WH_CODE] ?? 0);
|
||||
availRow[w.WH_CODE] = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
|
||||
}
|
||||
data.push(stockRow, availRow);
|
||||
}
|
||||
downloadXlsx(`창고별재고_${dateFrom}_${dateTo}`, data, cols);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Warehouse size={20} className="text-emerald-700" />
|
||||
창고별 재고 현황
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
기간 내 발주(출고요청/완료/계산서/입금) 수량과 현재고를 창고별로 나란히. <b>발주수량</b> = 현재 보유 재고, <b>여유분</b> = 현재고 − 기간 내 발주된 출고 예정 수량 (해당 창고 기준).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" value={dateFrom} onChange={(e) => setRange([e.target.value, dateTo])}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm" />
|
||||
<span className="text-slate-400">~</span>
|
||||
<input type="date" value={dateTo} onChange={(e) => setRange([dateFrom, e.target.value])}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm" />
|
||||
<button onClick={() => setRange(weekRange())}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold">금주</button>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
<button onClick={onExport} disabled={items.length === 0}
|
||||
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-40">
|
||||
<Download size={14} /> 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<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-[160px]">품목</th>
|
||||
<th className="text-center px-3 py-2 border-b border-slate-200 min-w-[100px]">분류</th>
|
||||
{warehouses.map((w) => (
|
||||
<th key={w.WH_CODE} className="text-right px-3 py-2 border-b border-slate-200 min-w-[88px]">
|
||||
{w.WH_NAME}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={2 + warehouses.length} className="text-center py-12 text-slate-400">
|
||||
{loading ? "조회 중..." : "데이터가 없습니다."}
|
||||
</td></tr>
|
||||
) : items.flatMap((it) => [
|
||||
<tr key={`${it.ITEM_OBJID}-stock`} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 align-top font-semibold" rowSpan={2}>
|
||||
{it.ITEM_NAME}
|
||||
<div className="text-[10px] text-slate-400 font-mono">{it.ITEM_CODE}</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-50/70">발주수량</td>
|
||||
{warehouses.map((w) => {
|
||||
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
|
||||
return (
|
||||
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-800"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
<tr key={`${it.ITEM_OBJID}-avail`} className="border-b border-slate-100">
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold">여유분</td>
|
||||
{warehouses.map((w) => {
|
||||
const v = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
|
||||
const negative = v < 0;
|
||||
return (
|
||||
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
])}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 창고별 재고 현황 — 7개 창고 × 활성 품목 매트릭스
|
||||
// 발주수량 = 보유 재고 (momo_stocks.qty)
|
||||
// 여유분 = 보유 재고 - 입금 전 진행 중 발주의 출고예정 수량 (해당 창고 기준)
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const dateFrom = (body.dateFrom as string) || ""; // YYYY-MM-DD
|
||||
const dateTo = (body.dateTo as string) || "";
|
||||
|
||||
// 1) 창고 7개 (wh_code 순)
|
||||
const warehouses = await queryRows<{ OBJID: string; WH_CODE: string; WH_NAME: string }>(
|
||||
`SELECT objid AS "OBJID", wh_code AS "WH_CODE", wh_name AS "WH_NAME"
|
||||
FROM momo_warehouses
|
||||
WHERE COALESCE(is_del,'N') != 'Y'
|
||||
ORDER BY wh_code`
|
||||
);
|
||||
|
||||
// 2) 품목 + 창고별 stock + 기간 내 발주(예정/완료) 수량
|
||||
// 발주수량 = 기간 내 status IN (REQUESTED/APPROVED/INVOICED/PAID) 의 ITEM 수량 합
|
||||
// 여유분 = 현재고 - 발주수량 (음수면 부족)
|
||||
const dateConds: string[] = ["COALESCE(O.is_del,'N') != 'Y'", "COALESCE(OI.kind, 'ITEM') = 'ITEM'",
|
||||
"O.status IN ('REQUESTED','APPROVED','INVOICED','PAID')"];
|
||||
const params: unknown[] = [];
|
||||
if (dateFrom) { params.push(dateFrom); dateConds.push(`O.order_date >= $${params.length}::date`); }
|
||||
if (dateTo) { params.push(dateTo); dateConds.push(`O.order_date <= $${params.length}::date`); }
|
||||
|
||||
const rows = await queryRows<{
|
||||
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
WH_OBJID: string; WH_CODE: string;
|
||||
STOCK_QTY: string; PENDING_QTY: string;
|
||||
}>(
|
||||
`WITH stock AS (
|
||||
SELECT item_objid, wh_objid, qty
|
||||
FROM momo_stocks
|
||||
),
|
||||
pending AS (
|
||||
SELECT OI.item_objid,
|
||||
COALESCE(U.default_wh_objid::text, (SELECT objid FROM momo_warehouses WHERE wh_code='WH001')) AS wh_objid,
|
||||
SUM(OI.qty) AS qty
|
||||
FROM momo_order_items OI
|
||||
JOIN momo_orders O ON O.objid = OI.order_objid
|
||||
LEFT JOIN user_info U ON U.user_id = O.customer_objid
|
||||
WHERE ${dateConds.join(" AND ")}
|
||||
GROUP BY OI.item_objid, COALESCE(U.default_wh_objid::text, (SELECT objid FROM momo_warehouses WHERE wh_code='WH001'))
|
||||
)
|
||||
SELECT
|
||||
I.objid AS "ITEM_OBJID",
|
||||
I.item_code AS "ITEM_CODE",
|
||||
I.item_name AS "ITEM_NAME",
|
||||
W.objid AS "WH_OBJID",
|
||||
W.wh_code AS "WH_CODE",
|
||||
COALESCE(s.qty, 0) AS "STOCK_QTY",
|
||||
COALESCE(p.qty, 0) AS "PENDING_QTY"
|
||||
FROM momo_items I
|
||||
CROSS JOIN momo_warehouses W
|
||||
LEFT JOIN stock s ON s.item_objid = I.objid AND s.wh_objid = W.objid
|
||||
LEFT JOIN pending p ON p.item_objid = I.objid AND p.wh_objid = W.objid
|
||||
WHERE COALESCE(I.is_del,'N') != 'Y'
|
||||
AND COALESCE(W.is_del,'N') != 'Y'
|
||||
AND COALESCE(I.is_hidden,'N') != 'Y'
|
||||
ORDER BY I.item_name, W.wh_code`,
|
||||
params
|
||||
);
|
||||
|
||||
// 품목별 매트릭스로 정리 — 클라가 쓰기 쉽게
|
||||
type Matrix = {
|
||||
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
STOCK: Record<string, number>;
|
||||
AVAILABLE: Record<string, number>;
|
||||
TOTAL_STOCK: number;
|
||||
TOTAL_PENDING: number;
|
||||
};
|
||||
const byItem = new Map<string, Matrix>();
|
||||
for (const r of rows) {
|
||||
const stockQty = Number(r.STOCK_QTY);
|
||||
const pendingQty = Number(r.PENDING_QTY);
|
||||
const avail = stockQty - pendingQty;
|
||||
if (!byItem.has(r.ITEM_OBJID)) {
|
||||
byItem.set(r.ITEM_OBJID, {
|
||||
ITEM_OBJID: r.ITEM_OBJID, ITEM_CODE: r.ITEM_CODE, ITEM_NAME: r.ITEM_NAME,
|
||||
STOCK: {}, AVAILABLE: {}, TOTAL_STOCK: 0, TOTAL_PENDING: 0,
|
||||
});
|
||||
}
|
||||
const m = byItem.get(r.ITEM_OBJID)!;
|
||||
m.STOCK[r.WH_CODE] = stockQty;
|
||||
m.AVAILABLE[r.WH_CODE] = avail;
|
||||
m.TOTAL_STOCK += stockQty;
|
||||
m.TOTAL_PENDING += pendingQty;
|
||||
}
|
||||
|
||||
// 재고가 한 창고라도 있거나 진행중 발주가 있는 품목만 노출
|
||||
const items = Array.from(byItem.values()).filter(
|
||||
(m) => m.TOTAL_STOCK > 0 || m.TOTAL_PENDING > 0
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
WAREHOUSES: warehouses,
|
||||
ITEMS: items,
|
||||
TOTAL_CNT: items.length,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user