From 7b5951c2270ffc6bb57b82f63a44ecdf3958eb0c Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 14 May 2026 14:58:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(alerts+transfers):=20=EC=9C=A0=ED=86=B5?= =?UTF-8?q?=EA=B8=B0=ED=95=9C=20=EC=9E=84=EB=B0=95=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?+=20=EC=B0=BD=EA=B3=A0=EC=9D=B4=EB=8F=99=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (창고이동) 등록 — 통계 메뉴 산하 --- src/app/(main)/m/admin/expiry-alerts/page.tsx | 138 ++++++++++++++++ src/app/(main)/m/admin/inbounds/page.tsx | 2 + src/app/(main)/m/admin/transfers/page.tsx | 154 ++++++++++++++++++ src/app/api/m/admin/expiry-alerts/route.ts | 50 ++++++ src/app/api/m/admin/transfers/route.ts | 58 +++++++ src/app/api/m/inbounds/save/route.ts | 12 +- 6 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 src/app/(main)/m/admin/expiry-alerts/page.tsx create mode 100644 src/app/(main)/m/admin/transfers/page.tsx create mode 100644 src/app/api/m/admin/expiry-alerts/route.ts create mode 100644 src/app/api/m/admin/transfers/route.ts diff --git a/src/app/(main)/m/admin/expiry-alerts/page.tsx b/src/app/(main)/m/admin/expiry-alerts/page.tsx new file mode 100644 index 0000000..0cea0b9 --- /dev/null +++ b/src/app/(main)/m/admin/expiry-alerts/page.tsx @@ -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([]); + 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 만료 {Math.abs(daysLeft)}일 경과; + if (daysLeft === 0) return 오늘 만료; + if (daysLeft <= 7) return D-{daysLeft}; + return D-{daysLeft}; + }; + + return ( +
+
+
+

+ + 유통기한 임박 알림 +

+

+ 입고 시 입력한 소비기한 기준. 만료 / D-7 이내 / D-30 이내 분류. 관리자 그룹 전용. +

+
+
+ + +
+
+ + {/* 알림 카드 */} +
+
+
+ 이미 만료 +
+
{counts.expired}건
+
+
+
+ 7일 이내 임박 +
+
{counts.urgent}건
+
+
+
+ 30일 이내 주의 +
+
{counts.soon}건
+
+
+ + {/* 리스트 */} +
+ + + + + + + + + + + + + + + {rows.length === 0 ? ( + + ) : rows.map((r) => ( + + + + + + + + + + + ))} + +
입고번호소비기한남은일창고공급업체입고완료자메모입고금액
+ {loading ? "조회 중..." : "임박한 유통기한이 없습니다."} +
{r.INBOUND_NO} +
입고 {r.INBOUND_DATE}
{r.EXPIRY_DATE}{badge(Number(r.DAYS_LEFT))}{r.WH_NAME ?? "-"}{r.VENDOR_NAME ?? "-"}{r.COMPLETED_BY ?? "-"} + {r.MEMO ?? ""} + ₩{fmt(Number(r.TOTAL_AMOUNT))}
+
+
+ ); +} diff --git a/src/app/(main)/m/admin/inbounds/page.tsx b/src/app/(main)/m/admin/inbounds/page.tsx index 1c05985..d0aded2 100644 --- a/src/app/(main)/m/admin/inbounds/page.tsx +++ b/src/app/(main)/m/admin/inbounds/page.tsx @@ -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(); diff --git a/src/app/(main)/m/admin/transfers/page.tsx b/src/app/(main)/m/admin/transfers/page.tsx new file mode 100644 index 0000000..9594f95 --- /dev/null +++ b/src/app/(main)/m/admin/transfers/page.tsx @@ -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([]); + 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 ( +
+
+
+

+ + 창고 이동 통계 +

+

+ 창고 간 이동 이력 (TRANSFER). 수량 × 단가(cost_price) = 이동 금액. 이동자/날짜/시간 포함. +

+
+
+ setDateFrom(e.target.value)} + className="h-9 px-3 rounded border border-slate-300 text-sm" /> + ~ + setDateTo(e.target.value)} + className="h-9 px-3 rounded border border-slate-300 text-sm" /> + + +
+
+ + {/* 합계 카드 */} +
+
+
이동 건수
+
{fmt(rows.length)}건
+
+
+
총 이동 수량
+
{fmt(totals.qty)}
+
+
+
총 이동 금액 (단가 기준)
+
₩{fmt(totals.amount)}
+
+
+ + {/* 리스트 */} +
+ + + + + + + + + + + + + + + {rows.length === 0 ? ( + + ) : rows.map((r) => ( + + + + + + + + + + + ))} + +
이동일시품목수량단가금액출발 → 도착이동자메모
+ {loading ? "조회 중..." : "이동 내역이 없습니다."} +
{r.MOVED_AT} +
{r.ITEM_NAME}
+
{r.ITEM_CODE}
+
{fmt(Number(r.QTY))} {r.UNIT}{fmt(Number(r.COST_PRICE))}₩{fmt(Number(r.AMOUNT))} + {r.FROM_WH} + + {r.TO_WH} + +
{r.REG_USER_NAME ?? "-"}
+
{r.REGID}
+
+ {r.MEMO ?? ""} +
+
+
+ ); +} diff --git a/src/app/api/m/admin/expiry-alerts/route.ts b/src/app/api/m/admin/expiry-alerts/route.ts new file mode 100644 index 0000000..874e8c7 --- /dev/null +++ b/src/app/api/m/admin/expiry-alerts/route.ts @@ -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), + }); +} diff --git a/src/app/api/m/admin/transfers/route.ts b/src/app/api/m/admin/transfers/route.ts new file mode 100644 index 0000000..428bcf4 --- /dev/null +++ b/src/app/api/m/admin/transfers/route.ts @@ -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, + }); +} diff --git a/src/app/api/m/inbounds/save/route.ts b/src/app/api/m/inbounds/save/route.ts index 5006864..57b4a76 100644 --- a/src/app/api/m/inbounds/save/route.ts +++ b/src/app/api/m/inbounds/save/route.ts @@ -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 있을 때) — 발주수량 - 기존 누적 입고 ≥ 이번 입고