feat(alerts+transfers): 유통기한 임박 알림 + 창고이동 통계 메뉴
Deploy momo-erp / deploy (push) Successful in 2m8s

A. 유통기한 임박 알림 (/m/admin/expiry-alerts)
   - momo_inbounds.expiry_date/completed_by 컬럼 운영 DB 추가
   - inbounds/save API: 입고 시 expiryDate/completedBy 함께 저장
   - 페이지: 만료/7일이내/30일이내 분류 카드 + 행별 D-N 뱃지
   - 빠른 필터 (7/14/30/60/90일)

B. 창고 이동 통계 (/m/admin/transfers)
   - stock_moves WHERE ref_type='TRANSFER' AND move_type='OUT' 기준
   - 출발창고 / 도착창고 / 품목 / 수량 / 단가(cost_price) / 금액
   - 이동자(regid + user_name), 이동일시, 메모
   - 합계 카드 + 엑셀 다운로드

운영 DB:
- ALTER TABLE momo_inbounds ADD COLUMN expiry_date, completed_by
- 인덱스 idx_momo_inbounds_expiry
- menu_info: 9000511 (유통기한), 9000512 (창고이동) 등록 — 통계 메뉴 산하
This commit is contained in:
chpark
2026-05-14 14:58:58 +09:00
parent 9fd1160b38
commit 7b5951c227
6 changed files with 409 additions and 5 deletions
@@ -0,0 +1,138 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AlertTriangle, AlertCircle, Clock, RefreshCcw } from "lucide-react";
interface Row {
INBOUND_OBJID: string; INBOUND_NO: string;
INBOUND_DATE: string; EXPIRY_DATE: string; DAYS_LEFT: number;
WH_NAME: string | null; VENDOR_NAME: string | null;
COMPLETED_BY: string | null; MEMO: string | null; TOTAL_AMOUNT: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
export default function ExpiryAlertsPage() {
const [days, setDays] = useState(30);
const [rows, setRows] = useState<Row[]>([]);
const [counts, setCounts] = useState({ expired: 0, urgent: 0, soon: 0 });
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/expiry-alerts", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days }),
});
const j = await res.json();
setRows(j.RESULTLIST ?? []);
setCounts({ expired: j.EXPIRED_CNT ?? 0, urgent: j.URGENT_CNT ?? 0, soon: j.SOON_CNT ?? 0 });
} finally { setLoading(false); }
}, [days]);
useEffect(() => { load(); }, [load]);
const rowStyle = (daysLeft: number) => {
if (daysLeft < 0) return "bg-rose-50 border-l-4 border-l-rose-500";
if (daysLeft <= 7) return "bg-amber-50 border-l-4 border-l-amber-500";
return "bg-white";
};
const badge = (daysLeft: number) => {
if (daysLeft < 0) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold"> {Math.abs(daysLeft)} </span>;
if (daysLeft === 0) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold"> </span>;
if (daysLeft <= 7) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">D-{daysLeft}</span>;
return <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">D-{daysLeft}</span>;
};
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">
<AlertTriangle size={20} className="text-amber-600" />
</h1>
<p className="text-xs text-slate-500 mt-0.5">
. / D-7 / D-30 . .
</p>
</div>
<div className="flex items-center gap-2">
<select value={days} onChange={(e) => setDays(Number(e.target.value))}
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
<option value={7}>7 </option>
<option value={14}>14 </option>
<option value={30}>30 </option>
<option value={60}>60 </option>
<option value={90}>90 </option>
</select>
<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>
</div>
</div>
{/* 알림 카드 */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-rose-50 border border-rose-200 rounded-xl p-4">
<div className="text-[11px] text-rose-700 font-semibold mb-1 flex items-center gap-1">
<AlertCircle size={14} />
</div>
<div className="text-2xl font-bold text-rose-700 tabular-nums">{counts.expired}</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="text-[11px] text-amber-700 font-semibold mb-1 flex items-center gap-1">
<AlertTriangle size={14} /> 7
</div>
<div className="text-2xl font-bold text-amber-700 tabular-nums">{counts.urgent}</div>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-600 font-semibold mb-1 flex items-center gap-1">
<Clock size={14} /> 30
</div>
<div className="text-2xl font-bold text-slate-700 tabular-nums">{counts.soon}</div>
</div>
</div>
{/* 리스트 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-center px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={8} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "임박한 유통기한이 없습니다."}
</td></tr>
) : rows.map((r) => (
<tr key={r.INBOUND_OBJID} className={`border-t border-slate-100 ${rowStyle(Number(r.DAYS_LEFT))}`}>
<td className="px-3 py-2 font-semibold">{r.INBOUND_NO}
<div className="text-[10px] text-slate-400"> {r.INBOUND_DATE}</div></td>
<td className="px-3 py-2 font-mono">{r.EXPIRY_DATE}</td>
<td className="px-3 py-2 text-center">{badge(Number(r.DAYS_LEFT))}</td>
<td className="px-3 py-2">{r.WH_NAME ?? "-"}</td>
<td className="px-3 py-2">{r.VENDOR_NAME ?? "-"}</td>
<td className="px-3 py-2 text-xs">{r.COMPLETED_BY ?? "-"}</td>
<td className="px-3 py-2 text-[10px] text-slate-500 whitespace-pre-line max-w-[300px] truncate" title={r.MEMO ?? ""}>
{r.MEMO ?? ""}
</td>
<td className="px-3 py-2 text-right font-semibold">{fmt(Number(r.TOTAL_AMOUNT))}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+2
View File
@@ -188,6 +188,8 @@ export default function InboundsPage() {
whObjid,
lines: whLines,
memo: checklistMemo,
expiryDate: checklist.expiryDate || undefined,
completedBy: checklist.completedBy || undefined,
}),
});
const j = await res.json();
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ArrowRightLeft, RefreshCcw, Download } from "lucide-react";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Row {
OBJID: string; MOVED_AT: string;
ITEM_CODE: string; ITEM_NAME: string; UNIT: string;
COST_PRICE: number; QTY: number; AMOUNT: number;
FROM_WH: string; FROM_CODE: string;
TO_WH: string; TO_CODE: string;
MEMO: string | null; REGID: string; REG_USER_NAME: string | null;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const todayStr = () => new Date().toISOString().slice(0, 10);
const monthAgo = () => { const d = new Date(); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); };
export default function TransfersPage() {
const [dateFrom, setDateFrom] = useState(monthAgo());
const [dateTo, setDateTo] = useState(todayStr());
const [rows, setRows] = useState<Row[]>([]);
const [totals, setTotals] = useState({ qty: 0, amount: 0 });
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/transfers", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom, dateTo }),
});
const j = await res.json();
setRows(j.RESULTLIST ?? []);
setTotals({ qty: Number(j.TOTAL_QTY ?? 0), amount: Number(j.TOTAL_AMOUNT ?? 0) });
} finally { setLoading(false); }
}, [dateFrom, dateTo]);
useEffect(() => { load(); }, [load]);
const onExport = () => {
if (rows.length === 0) return;
downloadXlsx(`창고이동_${dateFrom}_${dateTo}`, rows, [
{ header: "이동일시", key: "MOVED_AT" },
{ header: "품목코드", key: "ITEM_CODE" },
{ header: "품목명", key: "ITEM_NAME" },
{ header: "단위", key: "UNIT" },
{ header: "수량", key: (r) => Number(r.QTY) },
{ header: "단가", key: (r) => Number(r.COST_PRICE) },
{ header: "금액", key: (r) => Number(r.AMOUNT) },
{ header: "출발창고", key: (r) => `${r.FROM_WH ?? ""} (${r.FROM_CODE ?? ""})` },
{ header: "도착창고", key: (r) => `${r.TO_WH ?? ""} (${r.TO_CODE ?? ""})` },
{ header: "메모", key: (r) => r.MEMO ?? "" },
{ header: "이동자ID", key: "REGID" },
{ header: "이동자", key: (r) => r.REG_USER_NAME ?? "" },
]);
};
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">
<ArrowRightLeft size={20} className="text-sky-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5">
(TRANSFER). × (cost_price) = . // .
</p>
</div>
<div className="flex items-center gap-2">
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
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) => setDateTo(e.target.value)}
className="h-9 px-3 rounded border border-slate-300 text-sm" />
<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={rows.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-50">
<Download size={14} />
</button>
</div>
</div>
{/* 합계 카드 */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold tabular-nums">{fmt(rows.length)}</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<div className="text-xl font-bold tabular-nums">{fmt(totals.qty)}</div>
</div>
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4">
<div className="text-[11px] text-sky-700 mb-1 font-semibold"> ( )</div>
<div className="text-xl font-bold text-sky-700 tabular-nums">{fmt(totals.amount)}</div>
</div>
</div>
{/* 리스트 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-left px-3 py-2"> </th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
</tr>
</thead>
<tbody className="tabular-nums">
{rows.length === 0 ? (
<tr><td colSpan={8} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "이동 내역이 없습니다."}
</td></tr>
) : rows.map((r) => (
<tr key={r.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 text-xs font-mono">{r.MOVED_AT}</td>
<td className="px-3 py-2">
<div className="font-semibold">{r.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400">{r.ITEM_CODE}</div>
</td>
<td className="px-3 py-2 text-right font-semibold">{fmt(Number(r.QTY))} {r.UNIT}</td>
<td className="px-3 py-2 text-right text-slate-500">{fmt(Number(r.COST_PRICE))}</td>
<td className="px-3 py-2 text-right font-bold text-sky-700">{fmt(Number(r.AMOUNT))}</td>
<td className="px-3 py-2 text-xs">
<span className="text-slate-700">{r.FROM_WH}</span>
<span className="text-slate-300 mx-1"></span>
<span className="text-emerald-700 font-semibold">{r.TO_WH}</span>
</td>
<td className="px-3 py-2 text-xs">
<div className="font-semibold">{r.REG_USER_NAME ?? "-"}</div>
<div className="text-[10px] text-slate-400 font-mono">{r.REGID}</div>
</td>
<td className="px-3 py-2 text-[10px] text-slate-500 max-w-[200px] truncate" title={r.MEMO ?? ""}>
{r.MEMO ?? ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,50 @@
// 유통기한 임박 알림 — momo_inbounds.expiry_date 기준 N일 이내
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 days = Number(body.days) || 30; // 기본 30일 이내
// 입고건 중 expiry_date 가 있고 today + days 이내인 행. 이미 지난 것(과기) 도 포함.
const rows = await queryRows(
`SELECT
I.objid AS "INBOUND_OBJID",
I.inbound_no AS "INBOUND_NO",
TO_CHAR(I.inbound_date, 'YYYY-MM-DD') AS "INBOUND_DATE",
TO_CHAR(I.expiry_date, 'YYYY-MM-DD') AS "EXPIRY_DATE",
(I.expiry_date - CURRENT_DATE) AS "DAYS_LEFT",
W.wh_name AS "WH_NAME",
V.supply_name AS "VENDOR_NAME",
I.completed_by AS "COMPLETED_BY",
I.memo AS "MEMO",
I.total_amount AS "TOTAL_AMOUNT"
FROM momo_inbounds I
LEFT JOIN momo_warehouses W ON W.objid = I.wh_objid
LEFT JOIN supply_mng V ON V.objid::text = I.vendor_objid
WHERE I.expiry_date IS NOT NULL
AND I.expiry_date <= CURRENT_DATE + ($1 || ' days')::interval
AND COALESCE(I.is_del,'N') != 'Y'
ORDER BY I.expiry_date ASC`,
[days]
);
// 분류: 이미 만료 / 7일 이내 임박 / 30일 이내 주의
const now = new Date();
const expired = rows.filter((r) => Number(r.DAYS_LEFT) < 0);
const urgent = rows.filter((r) => Number(r.DAYS_LEFT) >= 0 && Number(r.DAYS_LEFT) <= 7);
const soon = rows.filter((r) => Number(r.DAYS_LEFT) > 7);
return NextResponse.json({
RESULTLIST: rows,
TOTAL_CNT: rows.length,
EXPIRED_CNT: expired.length,
URGENT_CNT: urgent.length,
SOON_CNT: soon.length,
NOW: now.toISOString().slice(0, 10),
});
}
+58
View File
@@ -0,0 +1,58 @@
// 창고 이동 통계 — momo_stock_moves 의 ref_type='TRANSFER' OUT 행 기준 + cost_price 로 금액 산정
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 | undefined;
const dateTo = body.dateTo as string | undefined;
const conditions: string[] = ["SM.ref_type = 'TRANSFER'", "SM.move_type = 'OUT'"];
const params: unknown[] = [];
let i = 1;
if (dateFrom) { conditions.push(`SM.regdate >= $${i++}::date`); params.push(dateFrom); }
if (dateTo) { conditions.push(`SM.regdate < ($${i++}::date + 1)`); params.push(dateTo); }
const rows = await queryRows(
`SELECT
SM.objid AS "OBJID",
TO_CHAR(SM.regdate, 'YYYY-MM-DD HH24:MI') AS "MOVED_AT",
I.item_code AS "ITEM_CODE",
I.item_name AS "ITEM_NAME",
I.unit AS "UNIT",
I.cost_price AS "COST_PRICE",
ABS(SM.qty) AS "QTY",
ABS(SM.qty) * COALESCE(I.cost_price, 0) AS "AMOUNT",
SW.wh_name AS "FROM_WH",
SW.wh_code AS "FROM_CODE",
TW.wh_name AS "TO_WH",
TW.wh_code AS "TO_CODE",
SM.memo AS "MEMO",
SM.regid AS "REGID",
U.user_name AS "REG_USER_NAME"
FROM momo_stock_moves SM
LEFT JOIN momo_items I ON I.objid = SM.item_objid
LEFT JOIN momo_warehouses SW ON SW.objid = SM.wh_objid
LEFT JOIN momo_warehouses TW ON TW.objid::text = SM.ref_objid
LEFT JOIN user_info U ON U.user_id = SM.regid
WHERE ${conditions.join(" AND ")}
ORDER BY SM.regdate DESC
LIMIT 500`,
params
);
// 합계
const totalQty = rows.reduce((a, r) => a + Number(r.QTY), 0);
const totalAmt = rows.reduce((a, r) => a + Number(r.AMOUNT), 0);
return NextResponse.json({
RESULTLIST: rows,
TOTAL_CNT: rows.length,
TOTAL_QTY: totalQty,
TOTAL_AMOUNT: totalAmt,
});
}
+7 -5
View File
@@ -17,9 +17,9 @@ export async function POST(req: NextRequest) {
if (g instanceof NextResponse) return g;
const adminId = g.user.objid || g.user.userId;
const { procObjid, vendorObjid, whObjid, inboundDate, lines, memo } = await req.json() as {
const { procObjid, vendorObjid, whObjid, inboundDate, lines, memo, expiryDate, completedBy } = await req.json() as {
procObjid?: string; vendorObjid?: string; whObjid: string; inboundDate?: string;
lines: Line[]; memo?: string;
lines: Line[]; memo?: string; expiryDate?: string; completedBy?: string;
};
if (!whObjid || !Array.isArray(lines) || lines.length === 0) {
return NextResponse.json({ success: false, message: "창고와 입고 라인이 필요합니다." }, { status: 400 });
@@ -34,9 +34,11 @@ export async function POST(req: NextRequest) {
try {
await client.query("BEGIN");
await client.query(
`INSERT INTO momo_inbounds (objid, inbound_no, proc_objid, vendor_objid, wh_objid, inbound_date, status, total_amount, memo, regdate, regid)
VALUES ($1,$2,$3,$4,$5,COALESCE($6::date, CURRENT_DATE),'COMPLETED',$7,$8,NOW(),$9)`,
[inboundObjid, inboundNo, procObjid ?? null, vendorObjid ?? null, whObjid, inboundDate ?? null, total, memo ?? null, adminId]
`INSERT INTO momo_inbounds (objid, inbound_no, proc_objid, vendor_objid, wh_objid, inbound_date, status, total_amount, memo, expiry_date, completed_by, regdate, regid)
VALUES ($1,$2,$3,$4,$5,COALESCE($6::date, CURRENT_DATE),'COMPLETED',$7,$8,$10::date,$11,NOW(),$9)`,
[inboundObjid, inboundNo, procObjid ?? null, vendorObjid ?? null, whObjid, inboundDate ?? null, total, memo ?? null, adminId,
expiryDate && expiryDate.trim() !== "" ? expiryDate : null,
completedBy && completedBy.trim() !== "" ? completedBy : null]
);
// 입고 한도 사전 검증 (procObjid 있을 때) — 발주수량 - 기존 누적 입고 ≥ 이번 입고