Files
pipeline/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx
T
kjs 31bdbe1331 feat: Enhance inventory and outbound pages with category mapping and user information
- Implemented user mapping to display user names instead of IDs in the inventory and receiving pages.
- Added category mapping for materials and units in the outbound page, improving data representation.
- Updated API calls to fetch user and category data, ensuring accurate and user-friendly displays.
- These enhancements aim to improve the overall user experience by providing clearer information and better data management across multiple company implementations.
2026-04-12 19:34:45 +09:00

757 lines
28 KiB
TypeScript

"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<any[]>([]);
const [stockLoading, setStockLoading] = useState(false);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 우측: 이동 이력
const [historyItems, setHistoryItems] = useState<any[]>([]);
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<string, { code: string; label: string }[]>
>({});
// 사용자 맵 (writer → 이름)
const [userMap, setUserMap] = useState<Record<string, string>>({});
// 카테고리 + 사용자 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
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");
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
} catch { /* skip */ }
setCategoryOptions(optMap);
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users").then((res) => {
const users = res.data?.data || res.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = 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_code: whMap.get(r.warehouse_code) || r.warehouse_code || "",
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) => (
<span className="font-mono">
<span className={cn(row._isLow && "text-destructive font-bold")}>
{Number(row.current_qty || 0).toLocaleString()}
</span>
{row._isLow && (
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
)}
</span>
),
};
}
if (col.key === "safety_qty") {
return {
...base,
align: "right" as const,
formatNumber: true,
};
}
if (col.key === "status") {
return {
...base,
render: (val: any) => (
<Badge variant={getStatusVariant(val)} className="text-[10px]">
{val}
</Badge>
),
};
}
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 (
<div className="flex flex-col h-full gap-3 p-3">
{/* 검색 바 */}
<DynamicSearchFilter
tableName={STOCK_TABLE}
filterId="c16-inventory"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={stockItems.length}
extraActions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={handleExcelExport}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
}
/>
{/* 마스터-디테일 패널 */}
<ResizablePanelGroup
direction="horizontal"
className="flex-1 rounded-lg border bg-card"
>
{/* 좌측: 재고 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<span className="text-[13px] font-bold"> </span>
<Badge variant="default" className="rounded-full text-[11px]">
{stockItems.length}
</Badge>
</div>
</div>
<EDataTable
columns={stockColumns}
data={ts.groupData(stockItems)}
rowKey={(row) => row.id}
loading={stockLoading}
emptyMessage="등록된 재고가 없어요"
selectedId={selectedStockId}
onSelect={(id) => setSelectedStockId(id)}
showRowNumber
showPagination={false}
draggableColumns={false}
columnOrderKey="c16-inventory"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 이력 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
{!selectedStock ? (
<div className="flex flex-col items-center justify-center flex-1 m-5 border-2 border-dashed rounded-lg border-border">
<Package className="h-12 w-12 text-muted-foreground/40 mb-4" />
<p className="text-sm font-semibold text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
</p>
</div>
) : (
<>
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
<span className="text-[13px] font-bold">
{selectedStock.item_name || selectedStock.item_code}
</span>
<Badge
variant="outline"
className="rounded-full text-[11px] font-mono"
>
{selectedStock.item_code}
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">:</span>
<span
className={cn(
"font-bold font-mono",
selectedStock._isLow
? "text-destructive"
: "text-foreground"
)}
>
{Number(selectedStock.current_qty || 0).toLocaleString()}
</span>
{selectedStock._isLow && (
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => {
setAdjustForm({
adjust_type: "증가",
adjust_qty: "",
reason: "",
});
setAdjustModalOpen(true);
}}
>
<ClipboardEdit className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 재고 요약 카드 */}
<div className="grid grid-cols-4 gap-2 px-4 py-3 border-b">
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold font-mono">
{Number(selectedStock.current_qty || 0).toLocaleString()}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold font-mono">
{Number(selectedStock.safety_qty || 0).toLocaleString()}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-sm font-bold truncate max-w-full">
{selectedStock.warehouse_name || "-"}
</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
<span className="text-[10px] text-muted-foreground"></span>
<Badge
variant={getStatusVariant(selectedStock.status)}
className="text-[10px] mt-0.5"
>
{selectedStock.status || "-"}
</Badge>
</div>
</div>
{/* 이력 서브헤더 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">
</span>
<Badge variant="secondary" className="rounded-full text-[10px]">
{historyItems.length}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs"
onClick={fetchHistory}
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
{/* 이력 테이블 */}
<div className="flex-1 overflow-auto">
{historyLoading ? (
<div className="flex items-center justify-center h-20">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : historyItems.length === 0 ? (
<div className="flex items-center justify-center h-20 text-xs text-muted-foreground">
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historyItems.map((h, idx) => (
<TableRow key={h.id || idx} className="text-xs">
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-mono">
{h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""}
</TableCell>
<TableCell>
<Badge
variant={getHistoryTypeVariant(h.transaction_type || h.history_type)}
className="text-[10px]"
>
{h.transaction_type || h.history_type}
</Badge>
</TableCell>
<TableCell
className={cn(
"text-right font-mono",
Number(h.quantity ?? h.change_qty) > 0
? "text-primary"
: "text-destructive"
)}
>
{Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""}
{Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono">
{Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()}
</TableCell>
<TableCell className="font-mono truncate max-w-[120px]">
{h.reference_number || h.reference_no || ""}
</TableCell>
<TableCell className="truncate max-w-[150px]">
{h.remark || h.reason || ""}
</TableCell>
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 재고 조정 Dialog */}
<Dialog open={adjustModalOpen} onOpenChange={setAdjustModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{selectedStock
? `${selectedStock.item_name || selectedStock.item_code} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
: ""}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Select
value={adjustForm.adjust_type}
onValueChange={(v) =>
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="조정 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="증가"> ( )</SelectItem>
<SelectItem value="감소"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
</Label>
<Input
type="number"
min={1}
placeholder="수량을 입력해주세요"
value={adjustForm.adjust_qty}
onChange={(e) =>
setAdjustForm((prev) => ({
...prev,
adjust_qty: e.target.value,
}))
}
/>
{adjustForm.adjust_qty && selectedStock && (
<p className="text-xs text-muted-foreground">
:{" "}
<span className="font-mono font-bold">
{(
Number(selectedStock.current_qty || 0) +
(adjustForm.adjust_type === "증가" ? 1 : -1) *
Number(adjustForm.adjust_qty || 0)
).toLocaleString()}
</span>
</p>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-[11px] font-semibold text-muted-foreground">
*
</Label>
<Textarea
placeholder="조정 사유를 입력해주세요"
rows={3}
value={adjustForm.reason}
onChange={(e) =>
setAdjustForm((prev) => ({
...prev,
reason: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAdjustModalOpen(false)}
>
</Button>
<Button onClick={handleAdjustSave} disabled={adjustSaving}>
{adjustSaving && (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}