"use client"; /** * 재고현황 — 하드코딩 페이지 (Type B 마스터-디테일) * * 좌측: 재고 목록 (inventory_stock, item_info JOIN) * 우측: 선택 품목의 재고 이동 이력 (inventory_history) * * ★ 재고 직접 등록/삭제 불가 — 입출고를 통해서만 변동, 조정만 가능 */ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; import { Package, Loader2, Download, ClipboardEdit, History, AlertTriangle, RefreshCw, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { toast } from "sonner"; import { exportToExcel } from "@/lib/utils/excelExport"; const STOCK_TABLE = "inventory_stock"; const STOCK_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, { key: "warehouse_code", label: "창고" }, { key: "location_code", label: "위치" }, { key: "current_qty", label: "현재수량", align: "right" as const }, { key: "safety_qty", label: "안전재고", align: "right" as const }, { key: "unit", label: "단위" }, { key: "status", label: "상태" }, ]; const HISTORY_TABLE = "inventory_history"; const getStatusVariant = ( status: string ): "default" | "secondary" | "outline" | "destructive" => { switch (status) { case "정상": return "default"; case "부족": return "destructive"; case "과잉": return "secondary"; default: return "outline"; } }; const getHistoryTypeVariant = ( type: string ): "default" | "secondary" | "outline" | "destructive" => { switch (type) { case "입고": return "default"; case "출고": return "secondary"; case "조정": return "outline"; case "입고취소": case "이동": return "destructive"; default: return "outline"; } }; export default function InventoryStatusPage() { const { user } = useAuth(); const ts = useTableSettings("c16-inventory", STOCK_TABLE, STOCK_COLUMNS); // 좌측: 재고 목록 const [stockItems, setStockItems] = useState([]); const [stockLoading, setStockLoading] = useState(false); const [selectedStockId, setSelectedStockId] = useState(null); // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); // 우측: 이동 이력 const [historyItems, setHistoryItems] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); // 조정 모달 const [adjustModalOpen, setAdjustModalOpen] = useState(false); const [adjustForm, setAdjustForm] = useState<{ adjust_type: string; adjust_qty: string; reason: string; }>({ adjust_type: "증가", adjust_qty: "", reason: "" }); const [adjustSaving, setAdjustSaving] = useState(false); // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState< Record >({}); // 사용자 맵 (writer → 이름) const [userMap, setUserMap] = useState>({}); // 카테고리 + 사용자 로드 useEffect(() => { const load = async () => { const optMap: Record = {}; const flatten = (vals: any[]): { code: string; label: string }[] => { const result: { code: string; label: string }[] = []; for (const v of vals) { result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label }); if (v.children?.length) result.push(...flatten(v.children)); } return result; }; // inventory_stock 카테고리 for (const col of ["status"]) { try { const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } // item_info 단위 카테고리 try { const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_8"); if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); }; load(); // 사용자 목록 로드 apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => { const users = res.data?.data || res.data || []; const map: Record = {}; for (const u of users) { const id = u.userId || u.user_id || u.id; const name = u.user_name || u.name || id; if (id) map[id] = name; } setUserMap(map); }).catch(() => {}); }, []); // 재고 목록 조회 const fetchStock = useCallback(async () => { setStockLoading(true); try { const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); const [stockRes, itemRes, whRes] = await Promise.all([ apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, sort: { columnName: "item_code", order: "asc" }, }), apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }), apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }), ]); const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || []; const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || []; const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.unit || "" }])); const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code])); const resolve = (col: string, code: string) => { if (!code) return ""; return categoryOptions[col]?.find((o) => o.code === code)?.label || code; }; const data = raw.map((r: any) => { const itemInfo = itemMap.get(r.item_code) as any; const rawUnit = itemInfo?.unit || r.unit || ""; return { ...r, item_name: itemInfo?.name || "", unit: resolve("item_unit", rawUnit) || rawUnit, warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "", status: resolve("status", r.status), _isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty), }; }); setStockItems(data); } catch { toast.error("재고 목록을 불러오지 못했어요"); } finally { setStockLoading(false); } }, [categoryOptions, searchFilters]); useEffect(() => { fetchStock(); }, [fetchStock]); // 선택된 재고 const selectedStock = stockItems.find((s) => s.id === selectedStockId); // 이력 조회 const fetchHistory = useCallback(async () => { if (!selectedStock?.item_code) { setHistoryItems([]); return; } setHistoryLoading(true); try { const historyFilters: any[] = [ { columnName: "item_code", operator: "equals", value: selectedStock.item_code, }, ]; if (selectedStock.warehouse_code) { historyFilters.push({ columnName: "warehouse_code", operator: "equals", value: selectedStock.warehouse_code, }); } const res = await apiClient.post( `/table-management/tables/${HISTORY_TABLE}/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: historyFilters }, autoFilter: true, sort: { columnName: "transaction_date", order: "desc" }, } ); const raw = res.data?.data?.data || res.data?.data?.rows || []; setHistoryItems(raw); } catch { toast.error("재고 이력을 불러오지 못했어요"); } finally { setHistoryLoading(false); } }, [selectedStock?.item_code, selectedStock?.warehouse_code]); useEffect(() => { fetchHistory(); }, [fetchHistory]); // 재고 조정 저장 const handleAdjustSave = async () => { if (!selectedStock) return; const qty = Number(adjustForm.adjust_qty); if (!qty || qty <= 0) { toast.error("조정 수량을 입력해주세요"); return; } if (!adjustForm.reason.trim()) { toast.error("조정 사유를 입력해주세요"); return; } setAdjustSaving(true); try { const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty; const afterQty = Number(selectedStock.current_qty || 0) + changeQty; await apiClient.post( `/table-management/tables/${HISTORY_TABLE}/add`, { id: crypto.randomUUID(), item_code: selectedStock.item_code, warehouse_code: selectedStock.warehouse_code || "", location_code: selectedStock.location_code || "", transaction_type: "조정", transaction_date: new Date().toISOString(), quantity: String(changeQty), balance_qty: String(afterQty), remark: adjustForm.reason.trim(), } ); await apiClient.put( `/table-management/tables/${STOCK_TABLE}/edit`, { originalData: { id: selectedStock.id }, updatedData: { current_qty: afterQty }, } ); toast.success("재고가 조정되었어요"); setAdjustModalOpen(false); setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" }); fetchStock(); } catch { toast.error("재고 조정에 실패했어요"); } finally { setAdjustSaving(false); } }; // EDataTable 컬럼 정의 const stockColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => { const base: EDataTableColumn = { key: col.key, label: col.label, align: col.align }; if (col.key === "current_qty") { return { ...base, align: "right" as const, render: (val: any, row: any) => ( {Number(row.current_qty || 0).toLocaleString()} {row._isLow && ( )} ), }; } if (col.key === "warehouse_code") { return { ...base, render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "", }; } if (col.key === "safety_qty") { return { ...base, align: "right" as const, formatNumber: true, }; } if (col.key === "status") { return { ...base, render: (val: any) => ( {val} ), }; } return base; }); // 엑셀 내보내기 const handleExcelExport = () => { if (stockItems.length === 0) { toast.error("내보낼 데이터가 없어요"); return; } exportToExcel( stockItems.map((r) => ({ 품목코드: r.item_code, 품명: r.item_name, 창고: r.warehouse_name || r.warehouse_code, 위치: r.location_code, 현재수량: r.current_qty, 안전재고: r.safety_qty, 단위: r.unit, 상태: r.status, })), "재고현황" ); }; return (
{/* 검색 바 */}
} /> {/* 마스터-디테일 패널 */} {/* 좌측: 재고 목록 */}
재고 목록 {stockItems.length}건
row.id} loading={stockLoading} emptyMessage="등록된 재고가 없어요" selectedId={selectedStockId} onSelect={(id) => setSelectedStockId(id)} showRowNumber showPagination={false} draggableColumns={false} columnOrderKey="c16-inventory" />
{/* 우측: 상세 이력 */}
{!selectedStock ? (

품목을 선택해주세요

좌측에서 품목을 선택하면 재고 이력이 표시돼요

) : ( <> {/* 패널 헤더 */}
{selectedStock.item_name || selectedStock.item_code} {selectedStock.item_code}
현재: {Number(selectedStock.current_qty || 0).toLocaleString()} {selectedStock._isLow && ( )}
{/* 재고 요약 카드 */}
현재수량 {Number(selectedStock.current_qty || 0).toLocaleString()}
안전재고 {Number(selectedStock.safety_qty || 0).toLocaleString()}
창고 {selectedStock.warehouse_name || "-"}
상태 {selectedStock.status || "-"}
{/* 이력 서브헤더 */}
재고 이동 이력 {historyItems.length}건
{/* 이력 테이블 */}
{historyLoading ? (
) : historyItems.length === 0 ? (
재고 이동 이력이 없어요
) : ( # 일자 유형 변동수량 이후수량 참조번호 사유 처리자 {historyItems.map((h, idx) => ( {idx + 1} {h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""} {h.transaction_type || h.history_type} 0 ? "text-primary" : "text-destructive" )} > {Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""} {Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()} {Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()} {h.reference_number || h.reference_no || ""} {h.remark || h.reason || ""} {userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""} ))}
)}
)}
{/* 테이블 설정 모달 */} {/* 재고 조정 Dialog */} 재고 조정 {selectedStock ? `${selectedStock.item_name || selectedStock.item_code} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}` : ""}
setAdjustForm((prev) => ({ ...prev, adjust_qty: e.target.value, })) } /> {adjustForm.adjust_qty && selectedStock && (

조정 후 수량:{" "} {( Number(selectedStock.current_qty || 0) + (adjustForm.adjust_type === "증가" ? 1 : -1) * Number(adjustForm.adjust_qty || 0) ).toLocaleString()}

)}