feat: Add hardcoded inbound-outbound management page for multiple companies
- Implemented a new hardcoded page for managing inbound and outbound logistics, based on the inventory_history table. - The page includes features for grouping, searching, and exporting data to Excel, enhancing user experience in managing logistics operations. - Integrated dynamic search filters and improved data loading mechanisms to ensure efficient retrieval and display of logistics data. These changes aim to provide a comprehensive interface for monitoring and managing inbound and outbound logistics across COMPANY_10, COMPANY_16, COMPANY_29, COMPANY_30, COMPANY_7, COMPANY_8, and COMPANY_9.
This commit is contained in:
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입출고관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
||||||
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "inventory_history";
|
||||||
|
|
||||||
|
const fmtNum = (v: any) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? "0" : n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (v: any) => {
|
||||||
|
if (!v) return "-";
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InboundOutboundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [groupBy, setGroupBy] = useState("none");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목명/단위 캐시
|
||||||
|
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ════════ 데이터 로드 ════════
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||||
|
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||||
|
|
||||||
|
for (const f of searchFilters) {
|
||||||
|
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
|
||||||
|
page: 1, size: 1000,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setData(rows);
|
||||||
|
|
||||||
|
// 품목 정보 조회
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||||
|
if (itemCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: itemCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||||
|
}
|
||||||
|
setItemMap(map);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 정보 조회
|
||||||
|
try {
|
||||||
|
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
|
||||||
|
page: 1, size: 100, autoFilter: true,
|
||||||
|
});
|
||||||
|
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||||
|
const whMap: Record<string, string> = {};
|
||||||
|
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
|
||||||
|
setWarehouseMap(whMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// 사용자 정보 조회 (writer → user_name 변환)
|
||||||
|
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
|
||||||
|
if (writerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
const uMap: Record<string, string> = {};
|
||||||
|
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
|
||||||
|
setUserMap(uMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("입출고 내역 조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, typeFilter, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ════════ 그룹핑 ════════
|
||||||
|
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (groupBy === "none") return data;
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
for (const row of data) {
|
||||||
|
let key: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||||
|
case "remark": key = row.remark || "미지정"; break;
|
||||||
|
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: key = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const result: any[] = [];
|
||||||
|
groups.forEach((items, gk) => {
|
||||||
|
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
|
||||||
|
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
|
||||||
|
result.push(...items);
|
||||||
|
});
|
||||||
|
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||||
|
return result;
|
||||||
|
}, [data, groupBy, itemMap, warehouseMap]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 체크박스 ════════
|
||||||
|
|
||||||
|
const allIds = data.map((r) => r.id);
|
||||||
|
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
|
||||||
|
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 엑셀 ════════
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const rows = data.map((r, i) => ({
|
||||||
|
No: i + 1,
|
||||||
|
입출고구분: r.transaction_type || "",
|
||||||
|
카테고리: r.remark || "",
|
||||||
|
처리일자: fmtDate(r.transaction_date),
|
||||||
|
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||||
|
위치: r.location_code || "",
|
||||||
|
품목코드: r.item_code || "",
|
||||||
|
품목명: itemMap[r.item_code]?.item_name || "",
|
||||||
|
수량: Number(r.quantity) || 0,
|
||||||
|
단위: itemMap[r.item_code]?.unit || "",
|
||||||
|
로트번호: r.lot_number || "",
|
||||||
|
참조번호: r.reference_number || "",
|
||||||
|
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
|
||||||
|
}));
|
||||||
|
const _n = new Date();
|
||||||
|
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 렌더 ════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="입출고구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="입고">입고</SelectItem>
|
||||||
|
<SelectItem value="출고">출고</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="카테고리" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={HISTORY_TABLE}
|
||||||
|
filterId="c16-inbound-outbound"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">입출고 내역</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="그룹없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">그룹없음</SelectItem>
|
||||||
|
<SelectItem value="transaction_type">입출고구분별</SelectItem>
|
||||||
|
<SelectItem value="remark">카테고리별</SelectItem>
|
||||||
|
<SelectItem value="warehouse_code">창고별</SelectItem>
|
||||||
|
<SelectItem value="item_code">품목코드별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 opacity-30" />
|
||||||
|
<span className="text-sm">조회된 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center text-[11px]">입출고구분</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center text-[11px]">카테고리</TableHead>
|
||||||
|
<TableHead className="w-[95px] text-center text-[11px]">처리일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">창고</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||||
|
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">참조번호</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedData.map((row, idx) => {
|
||||||
|
if (row._group) {
|
||||||
|
const expanded = expandedGroups.has(row._key);
|
||||||
|
return (
|
||||||
|
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||||
|
<TableCell colSpan={8} className="py-2 px-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{row._key}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||||
|
{fmtNum(row._totalQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={4} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 그룹 접힘 체크
|
||||||
|
if (groupBy !== "none") {
|
||||||
|
let gk: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: gk = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!expandedGroups.has(gk)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIn = row.transaction_type === "입고";
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
const checked = checkedIds.has(row.id);
|
||||||
|
const info = itemMap[row.item_code];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
|
||||||
|
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
|
||||||
|
)}>
|
||||||
|
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
|
||||||
|
{row.transaction_type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||||
|
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입출고관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
||||||
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "inventory_history";
|
||||||
|
|
||||||
|
const fmtNum = (v: any) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? "0" : n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (v: any) => {
|
||||||
|
if (!v) return "-";
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InboundOutboundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [groupBy, setGroupBy] = useState("none");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목명/단위 캐시
|
||||||
|
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ════════ 데이터 로드 ════════
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||||
|
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||||
|
|
||||||
|
for (const f of searchFilters) {
|
||||||
|
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
|
||||||
|
page: 1, size: 1000,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setData(rows);
|
||||||
|
|
||||||
|
// 품목 정보 조회
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||||
|
if (itemCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: itemCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||||
|
}
|
||||||
|
setItemMap(map);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 정보 조회
|
||||||
|
try {
|
||||||
|
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
|
||||||
|
page: 1, size: 100, autoFilter: true,
|
||||||
|
});
|
||||||
|
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||||
|
const whMap: Record<string, string> = {};
|
||||||
|
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
|
||||||
|
setWarehouseMap(whMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// 사용자 정보 조회 (writer → user_name 변환)
|
||||||
|
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
|
||||||
|
if (writerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
const uMap: Record<string, string> = {};
|
||||||
|
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
|
||||||
|
setUserMap(uMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("입출고 내역 조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, typeFilter, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ════════ 그룹핑 ════════
|
||||||
|
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (groupBy === "none") return data;
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
for (const row of data) {
|
||||||
|
let key: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||||
|
case "remark": key = row.remark || "미지정"; break;
|
||||||
|
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: key = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const result: any[] = [];
|
||||||
|
groups.forEach((items, gk) => {
|
||||||
|
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
|
||||||
|
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
|
||||||
|
result.push(...items);
|
||||||
|
});
|
||||||
|
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||||
|
return result;
|
||||||
|
}, [data, groupBy, itemMap, warehouseMap]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 체크박스 ════════
|
||||||
|
|
||||||
|
const allIds = data.map((r) => r.id);
|
||||||
|
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
|
||||||
|
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 엑셀 ════════
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const rows = data.map((r, i) => ({
|
||||||
|
No: i + 1,
|
||||||
|
입출고구분: r.transaction_type || "",
|
||||||
|
카테고리: r.remark || "",
|
||||||
|
처리일자: fmtDate(r.transaction_date),
|
||||||
|
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||||
|
위치: r.location_code || "",
|
||||||
|
품목코드: r.item_code || "",
|
||||||
|
품목명: itemMap[r.item_code]?.item_name || "",
|
||||||
|
수량: Number(r.quantity) || 0,
|
||||||
|
단위: itemMap[r.item_code]?.unit || "",
|
||||||
|
로트번호: r.lot_number || "",
|
||||||
|
참조번호: r.reference_number || "",
|
||||||
|
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
|
||||||
|
}));
|
||||||
|
const _n = new Date();
|
||||||
|
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 렌더 ════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="입출고구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="입고">입고</SelectItem>
|
||||||
|
<SelectItem value="출고">출고</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="카테고리" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={HISTORY_TABLE}
|
||||||
|
filterId="c16-inbound-outbound"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">입출고 내역</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="그룹없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">그룹없음</SelectItem>
|
||||||
|
<SelectItem value="transaction_type">입출고구분별</SelectItem>
|
||||||
|
<SelectItem value="remark">카테고리별</SelectItem>
|
||||||
|
<SelectItem value="warehouse_code">창고별</SelectItem>
|
||||||
|
<SelectItem value="item_code">품목코드별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 opacity-30" />
|
||||||
|
<span className="text-sm">조회된 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center text-[11px]">입출고구분</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center text-[11px]">카테고리</TableHead>
|
||||||
|
<TableHead className="w-[95px] text-center text-[11px]">처리일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">창고</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||||
|
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">참조번호</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedData.map((row, idx) => {
|
||||||
|
if (row._group) {
|
||||||
|
const expanded = expandedGroups.has(row._key);
|
||||||
|
return (
|
||||||
|
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||||
|
<TableCell colSpan={8} className="py-2 px-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{row._key}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||||
|
{fmtNum(row._totalQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={4} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 그룹 접힘 체크
|
||||||
|
if (groupBy !== "none") {
|
||||||
|
let gk: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: gk = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!expandedGroups.has(gk)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIn = row.transaction_type === "입고";
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
const checked = checkedIds.has(row.id);
|
||||||
|
const info = itemMap[row.item_code];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
|
||||||
|
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
|
||||||
|
)}>
|
||||||
|
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
|
||||||
|
{row.transaction_type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||||
|
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입출고관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
||||||
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "inventory_history";
|
||||||
|
|
||||||
|
const fmtNum = (v: any) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? "0" : n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (v: any) => {
|
||||||
|
if (!v) return "-";
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InboundOutboundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [groupBy, setGroupBy] = useState("none");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목명/단위 캐시
|
||||||
|
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ════════ 데이터 로드 ════════
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||||
|
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||||
|
|
||||||
|
for (const f of searchFilters) {
|
||||||
|
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
|
||||||
|
page: 1, size: 1000,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setData(rows);
|
||||||
|
|
||||||
|
// 품목 정보 조회
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||||
|
if (itemCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: itemCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||||
|
}
|
||||||
|
setItemMap(map);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 정보 조회
|
||||||
|
try {
|
||||||
|
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
|
||||||
|
page: 1, size: 100, autoFilter: true,
|
||||||
|
});
|
||||||
|
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||||
|
const whMap: Record<string, string> = {};
|
||||||
|
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
|
||||||
|
setWarehouseMap(whMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// 사용자 정보 조회 (writer → user_name 변환)
|
||||||
|
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
|
||||||
|
if (writerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
const uMap: Record<string, string> = {};
|
||||||
|
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
|
||||||
|
setUserMap(uMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("입출고 내역 조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, typeFilter, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ════════ 그룹핑 ════════
|
||||||
|
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (groupBy === "none") return data;
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
for (const row of data) {
|
||||||
|
let key: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||||
|
case "remark": key = row.remark || "미지정"; break;
|
||||||
|
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: key = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const result: any[] = [];
|
||||||
|
groups.forEach((items, gk) => {
|
||||||
|
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
|
||||||
|
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
|
||||||
|
result.push(...items);
|
||||||
|
});
|
||||||
|
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||||
|
return result;
|
||||||
|
}, [data, groupBy, itemMap, warehouseMap]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 체크박스 ════════
|
||||||
|
|
||||||
|
const allIds = data.map((r) => r.id);
|
||||||
|
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
|
||||||
|
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 엑셀 ════════
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const rows = data.map((r, i) => ({
|
||||||
|
No: i + 1,
|
||||||
|
입출고구분: r.transaction_type || "",
|
||||||
|
카테고리: r.remark || "",
|
||||||
|
처리일자: fmtDate(r.transaction_date),
|
||||||
|
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||||
|
위치: r.location_code || "",
|
||||||
|
품목코드: r.item_code || "",
|
||||||
|
품목명: itemMap[r.item_code]?.item_name || "",
|
||||||
|
수량: Number(r.quantity) || 0,
|
||||||
|
단위: itemMap[r.item_code]?.unit || "",
|
||||||
|
로트번호: r.lot_number || "",
|
||||||
|
참조번호: r.reference_number || "",
|
||||||
|
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
|
||||||
|
}));
|
||||||
|
const _n = new Date();
|
||||||
|
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 렌더 ════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="입출고구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="입고">입고</SelectItem>
|
||||||
|
<SelectItem value="출고">출고</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="카테고리" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={HISTORY_TABLE}
|
||||||
|
filterId="c16-inbound-outbound"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">입출고 내역</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="그룹없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">그룹없음</SelectItem>
|
||||||
|
<SelectItem value="transaction_type">입출고구분별</SelectItem>
|
||||||
|
<SelectItem value="remark">카테고리별</SelectItem>
|
||||||
|
<SelectItem value="warehouse_code">창고별</SelectItem>
|
||||||
|
<SelectItem value="item_code">품목코드별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 opacity-30" />
|
||||||
|
<span className="text-sm">조회된 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center text-[11px]">입출고구분</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center text-[11px]">카테고리</TableHead>
|
||||||
|
<TableHead className="w-[95px] text-center text-[11px]">처리일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">창고</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||||
|
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">참조번호</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedData.map((row, idx) => {
|
||||||
|
if (row._group) {
|
||||||
|
const expanded = expandedGroups.has(row._key);
|
||||||
|
return (
|
||||||
|
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||||
|
<TableCell colSpan={8} className="py-2 px-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{row._key}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||||
|
{fmtNum(row._totalQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={4} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 그룹 접힘 체크
|
||||||
|
if (groupBy !== "none") {
|
||||||
|
let gk: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: gk = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!expandedGroups.has(gk)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIn = row.transaction_type === "입고";
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
const checked = checkedIds.has(row.id);
|
||||||
|
const info = itemMap[row.item_code];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
|
||||||
|
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
|
||||||
|
)}>
|
||||||
|
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
|
||||||
|
{row.transaction_type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||||
|
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입출고관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
||||||
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "inventory_history";
|
||||||
|
|
||||||
|
const fmtNum = (v: any) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? "0" : n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (v: any) => {
|
||||||
|
if (!v) return "-";
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InboundOutboundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [groupBy, setGroupBy] = useState("none");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목명/단위 캐시
|
||||||
|
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ════════ 데이터 로드 ════════
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||||
|
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||||
|
|
||||||
|
for (const f of searchFilters) {
|
||||||
|
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
|
||||||
|
page: 1, size: 1000,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setData(rows);
|
||||||
|
|
||||||
|
// 품목 정보 조회
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||||
|
if (itemCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: itemCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||||
|
}
|
||||||
|
setItemMap(map);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 정보 조회
|
||||||
|
try {
|
||||||
|
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
|
||||||
|
page: 1, size: 100, autoFilter: true,
|
||||||
|
});
|
||||||
|
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||||
|
const whMap: Record<string, string> = {};
|
||||||
|
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
|
||||||
|
setWarehouseMap(whMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// 사용자 정보 조회 (writer → user_name 변환)
|
||||||
|
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
|
||||||
|
if (writerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
const uMap: Record<string, string> = {};
|
||||||
|
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
|
||||||
|
setUserMap(uMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("입출고 내역 조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, typeFilter, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ════════ 그룹핑 ════════
|
||||||
|
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (groupBy === "none") return data;
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
for (const row of data) {
|
||||||
|
let key: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||||
|
case "remark": key = row.remark || "미지정"; break;
|
||||||
|
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: key = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const result: any[] = [];
|
||||||
|
groups.forEach((items, gk) => {
|
||||||
|
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
|
||||||
|
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
|
||||||
|
result.push(...items);
|
||||||
|
});
|
||||||
|
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||||
|
return result;
|
||||||
|
}, [data, groupBy, itemMap, warehouseMap]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 체크박스 ════════
|
||||||
|
|
||||||
|
const allIds = data.map((r) => r.id);
|
||||||
|
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
|
||||||
|
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 엑셀 ════════
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const rows = data.map((r, i) => ({
|
||||||
|
No: i + 1,
|
||||||
|
입출고구분: r.transaction_type || "",
|
||||||
|
카테고리: r.remark || "",
|
||||||
|
처리일자: fmtDate(r.transaction_date),
|
||||||
|
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||||
|
위치: r.location_code || "",
|
||||||
|
품목코드: r.item_code || "",
|
||||||
|
품목명: itemMap[r.item_code]?.item_name || "",
|
||||||
|
수량: Number(r.quantity) || 0,
|
||||||
|
단위: itemMap[r.item_code]?.unit || "",
|
||||||
|
로트번호: r.lot_number || "",
|
||||||
|
참조번호: r.reference_number || "",
|
||||||
|
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
|
||||||
|
}));
|
||||||
|
const _n = new Date();
|
||||||
|
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 렌더 ════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="입출고구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="입고">입고</SelectItem>
|
||||||
|
<SelectItem value="출고">출고</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="카테고리" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={HISTORY_TABLE}
|
||||||
|
filterId="c16-inbound-outbound"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">입출고 내역</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="그룹없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">그룹없음</SelectItem>
|
||||||
|
<SelectItem value="transaction_type">입출고구분별</SelectItem>
|
||||||
|
<SelectItem value="remark">카테고리별</SelectItem>
|
||||||
|
<SelectItem value="warehouse_code">창고별</SelectItem>
|
||||||
|
<SelectItem value="item_code">품목코드별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 opacity-30" />
|
||||||
|
<span className="text-sm">조회된 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center text-[11px]">입출고구분</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center text-[11px]">카테고리</TableHead>
|
||||||
|
<TableHead className="w-[95px] text-center text-[11px]">처리일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">창고</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||||
|
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">참조번호</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedData.map((row, idx) => {
|
||||||
|
if (row._group) {
|
||||||
|
const expanded = expandedGroups.has(row._key);
|
||||||
|
return (
|
||||||
|
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||||
|
<TableCell colSpan={8} className="py-2 px-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{row._key}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||||
|
{fmtNum(row._totalQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={4} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 그룹 접힘 체크
|
||||||
|
if (groupBy !== "none") {
|
||||||
|
let gk: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: gk = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!expandedGroups.has(gk)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIn = row.transaction_type === "입고";
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
const checked = checkedIds.has(row.id);
|
||||||
|
const info = itemMap[row.item_code];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
|
||||||
|
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
|
||||||
|
)}>
|
||||||
|
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
|
||||||
|
{row.transaction_type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||||
|
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입출고관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
||||||
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "inventory_history";
|
||||||
|
|
||||||
|
const fmtNum = (v: any) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? "0" : n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (v: any) => {
|
||||||
|
if (!v) return "-";
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InboundOutboundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [groupBy, setGroupBy] = useState("none");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목명/단위 캐시
|
||||||
|
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ════════ 데이터 로드 ════════
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||||
|
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||||
|
|
||||||
|
for (const f of searchFilters) {
|
||||||
|
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
|
||||||
|
page: 1, size: 1000,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setData(rows);
|
||||||
|
|
||||||
|
// 품목 정보 조회
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||||
|
if (itemCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: itemCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||||
|
}
|
||||||
|
setItemMap(map);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 정보 조회
|
||||||
|
try {
|
||||||
|
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
|
||||||
|
page: 1, size: 100, autoFilter: true,
|
||||||
|
});
|
||||||
|
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||||
|
const whMap: Record<string, string> = {};
|
||||||
|
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
|
||||||
|
setWarehouseMap(whMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// 사용자 정보 조회 (writer → user_name 변환)
|
||||||
|
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
|
||||||
|
if (writerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
const uMap: Record<string, string> = {};
|
||||||
|
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
|
||||||
|
setUserMap(uMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("입출고 내역 조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, typeFilter, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ════════ 그룹핑 ════════
|
||||||
|
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (groupBy === "none") return data;
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
for (const row of data) {
|
||||||
|
let key: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||||
|
case "remark": key = row.remark || "미지정"; break;
|
||||||
|
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: key = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const result: any[] = [];
|
||||||
|
groups.forEach((items, gk) => {
|
||||||
|
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
|
||||||
|
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
|
||||||
|
result.push(...items);
|
||||||
|
});
|
||||||
|
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||||
|
return result;
|
||||||
|
}, [data, groupBy, itemMap, warehouseMap]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 체크박스 ════════
|
||||||
|
|
||||||
|
const allIds = data.map((r) => r.id);
|
||||||
|
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
|
||||||
|
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 엑셀 ════════
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const rows = data.map((r, i) => ({
|
||||||
|
No: i + 1,
|
||||||
|
입출고구분: r.transaction_type || "",
|
||||||
|
카테고리: r.remark || "",
|
||||||
|
처리일자: fmtDate(r.transaction_date),
|
||||||
|
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||||
|
위치: r.location_code || "",
|
||||||
|
품목코드: r.item_code || "",
|
||||||
|
품목명: itemMap[r.item_code]?.item_name || "",
|
||||||
|
수량: Number(r.quantity) || 0,
|
||||||
|
단위: itemMap[r.item_code]?.unit || "",
|
||||||
|
로트번호: r.lot_number || "",
|
||||||
|
참조번호: r.reference_number || "",
|
||||||
|
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
|
||||||
|
}));
|
||||||
|
const _n = new Date();
|
||||||
|
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 렌더 ════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="입출고구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="입고">입고</SelectItem>
|
||||||
|
<SelectItem value="출고">출고</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="카테고리" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={HISTORY_TABLE}
|
||||||
|
filterId="c16-inbound-outbound"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">입출고 내역</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="그룹없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">그룹없음</SelectItem>
|
||||||
|
<SelectItem value="transaction_type">입출고구분별</SelectItem>
|
||||||
|
<SelectItem value="remark">카테고리별</SelectItem>
|
||||||
|
<SelectItem value="warehouse_code">창고별</SelectItem>
|
||||||
|
<SelectItem value="item_code">품목코드별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 opacity-30" />
|
||||||
|
<span className="text-sm">조회된 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center text-[11px]">입출고구분</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center text-[11px]">카테고리</TableHead>
|
||||||
|
<TableHead className="w-[95px] text-center text-[11px]">처리일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">창고</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||||
|
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">참조번호</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedData.map((row, idx) => {
|
||||||
|
if (row._group) {
|
||||||
|
const expanded = expandedGroups.has(row._key);
|
||||||
|
return (
|
||||||
|
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||||
|
<TableCell colSpan={8} className="py-2 px-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{row._key}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||||
|
{fmtNum(row._totalQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={4} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 그룹 접힘 체크
|
||||||
|
if (groupBy !== "none") {
|
||||||
|
let gk: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: gk = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!expandedGroups.has(gk)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIn = row.transaction_type === "입고";
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
const checked = checkedIds.has(row.id);
|
||||||
|
const info = itemMap[row.item_code];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
|
||||||
|
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
|
||||||
|
)}>
|
||||||
|
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
|
||||||
|
{row.transaction_type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||||
|
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입출고관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
||||||
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "inventory_history";
|
||||||
|
|
||||||
|
const fmtNum = (v: any) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? "0" : n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (v: any) => {
|
||||||
|
if (!v) return "-";
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InboundOutboundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [groupBy, setGroupBy] = useState("none");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목명/단위 캐시
|
||||||
|
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ════════ 데이터 로드 ════════
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||||
|
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||||
|
|
||||||
|
for (const f of searchFilters) {
|
||||||
|
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
|
||||||
|
page: 1, size: 1000,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setData(rows);
|
||||||
|
|
||||||
|
// 품목 정보 조회
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||||
|
if (itemCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: itemCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||||
|
}
|
||||||
|
setItemMap(map);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 정보 조회
|
||||||
|
try {
|
||||||
|
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
|
||||||
|
page: 1, size: 100, autoFilter: true,
|
||||||
|
});
|
||||||
|
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||||
|
const whMap: Record<string, string> = {};
|
||||||
|
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
|
||||||
|
setWarehouseMap(whMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// 사용자 정보 조회 (writer → user_name 변환)
|
||||||
|
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
|
||||||
|
if (writerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
const uMap: Record<string, string> = {};
|
||||||
|
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
|
||||||
|
setUserMap(uMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("입출고 내역 조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, typeFilter, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ════════ 그룹핑 ════════
|
||||||
|
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (groupBy === "none") return data;
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
for (const row of data) {
|
||||||
|
let key: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||||
|
case "remark": key = row.remark || "미지정"; break;
|
||||||
|
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: key = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const result: any[] = [];
|
||||||
|
groups.forEach((items, gk) => {
|
||||||
|
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
|
||||||
|
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
|
||||||
|
result.push(...items);
|
||||||
|
});
|
||||||
|
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||||
|
return result;
|
||||||
|
}, [data, groupBy, itemMap, warehouseMap]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 체크박스 ════════
|
||||||
|
|
||||||
|
const allIds = data.map((r) => r.id);
|
||||||
|
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
|
||||||
|
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 엑셀 ════════
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const rows = data.map((r, i) => ({
|
||||||
|
No: i + 1,
|
||||||
|
입출고구분: r.transaction_type || "",
|
||||||
|
카테고리: r.remark || "",
|
||||||
|
처리일자: fmtDate(r.transaction_date),
|
||||||
|
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||||
|
위치: r.location_code || "",
|
||||||
|
품목코드: r.item_code || "",
|
||||||
|
품목명: itemMap[r.item_code]?.item_name || "",
|
||||||
|
수량: Number(r.quantity) || 0,
|
||||||
|
단위: itemMap[r.item_code]?.unit || "",
|
||||||
|
로트번호: r.lot_number || "",
|
||||||
|
참조번호: r.reference_number || "",
|
||||||
|
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
|
||||||
|
}));
|
||||||
|
const _n = new Date();
|
||||||
|
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 렌더 ════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="입출고구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="입고">입고</SelectItem>
|
||||||
|
<SelectItem value="출고">출고</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="카테고리" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={HISTORY_TABLE}
|
||||||
|
filterId="c16-inbound-outbound"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">입출고 내역</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="그룹없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">그룹없음</SelectItem>
|
||||||
|
<SelectItem value="transaction_type">입출고구분별</SelectItem>
|
||||||
|
<SelectItem value="remark">카테고리별</SelectItem>
|
||||||
|
<SelectItem value="warehouse_code">창고별</SelectItem>
|
||||||
|
<SelectItem value="item_code">품목코드별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 opacity-30" />
|
||||||
|
<span className="text-sm">조회된 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center text-[11px]">입출고구분</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center text-[11px]">카테고리</TableHead>
|
||||||
|
<TableHead className="w-[95px] text-center text-[11px]">처리일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">창고</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||||
|
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">참조번호</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedData.map((row, idx) => {
|
||||||
|
if (row._group) {
|
||||||
|
const expanded = expandedGroups.has(row._key);
|
||||||
|
return (
|
||||||
|
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||||
|
<TableCell colSpan={8} className="py-2 px-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{row._key}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||||
|
{fmtNum(row._totalQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={4} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 그룹 접힘 체크
|
||||||
|
if (groupBy !== "none") {
|
||||||
|
let gk: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: gk = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!expandedGroups.has(gk)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIn = row.transaction_type === "입고";
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
const checked = checkedIds.has(row.id);
|
||||||
|
const info = itemMap[row.item_code];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
|
||||||
|
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
|
||||||
|
)}>
|
||||||
|
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
|
||||||
|
{row.transaction_type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||||
|
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입출고관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
||||||
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
||||||
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "inventory_history";
|
||||||
|
|
||||||
|
const fmtNum = (v: any) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return isNaN(n) ? "0" : n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (v: any) => {
|
||||||
|
if (!v) return "-";
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InboundOutboundPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [groupBy, setGroupBy] = useState("none");
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목명/단위 캐시
|
||||||
|
const [itemMap, setItemMap] = useState<Record<string, { item_name: string; unit: string }>>({});
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ════════ 데이터 로드 ════════
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||||
|
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||||
|
|
||||||
|
for (const f of searchFilters) {
|
||||||
|
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${HISTORY_TABLE}/data`, {
|
||||||
|
page: 1, size: 1000,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setData(rows);
|
||||||
|
|
||||||
|
// 품목 정보 조회
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||||
|
if (itemCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: itemCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||||
|
}
|
||||||
|
setItemMap(map);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 정보 조회
|
||||||
|
try {
|
||||||
|
const whRes = await apiClient.post(`/table-management/tables/warehouse_info/data`, {
|
||||||
|
page: 1, size: 100, autoFilter: true,
|
||||||
|
});
|
||||||
|
const whs = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||||
|
const whMap: Record<string, string> = {};
|
||||||
|
for (const w of whs) whMap[w.warehouse_code] = w.warehouse_name || w.warehouse_code;
|
||||||
|
setWarehouseMap(whMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
// 사용자 정보 조회 (writer → user_name 변환)
|
||||||
|
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
|
||||||
|
if (writerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
const uMap: Record<string, string> = {};
|
||||||
|
for (const u of users) uMap[u.user_id] = u.user_name || u.user_id;
|
||||||
|
setUserMap(uMap);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("입출고 내역 조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, typeFilter, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ════════ 그룹핑 ════════
|
||||||
|
|
||||||
|
const groupedData = useMemo(() => {
|
||||||
|
if (groupBy === "none") return data;
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
for (const row of data) {
|
||||||
|
let key: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||||
|
case "remark": key = row.remark || "미지정"; break;
|
||||||
|
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: key = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
const result: any[] = [];
|
||||||
|
groups.forEach((items, gk) => {
|
||||||
|
const totalQty = items.reduce((s, r) => s + (Number(r.quantity) || 0), 0);
|
||||||
|
result.push({ _group: true, _key: gk, _count: items.length, _totalQty: totalQty });
|
||||||
|
result.push(...items);
|
||||||
|
});
|
||||||
|
if (expandedGroups.size === 0) setExpandedGroups(new Set(Array.from(groups.keys())));
|
||||||
|
return result;
|
||||||
|
}, [data, groupBy, itemMap, warehouseMap]);
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 체크박스 ════════
|
||||||
|
|
||||||
|
const allIds = data.map((r) => r.id);
|
||||||
|
const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.has(id));
|
||||||
|
const toggleAll = (checked: boolean) => setCheckedIds(checked ? new Set(allIds) : new Set());
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 엑셀 ════════
|
||||||
|
|
||||||
|
const handleExcel = async () => {
|
||||||
|
if (data.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const rows = data.map((r, i) => ({
|
||||||
|
No: i + 1,
|
||||||
|
입출고구분: r.transaction_type || "",
|
||||||
|
카테고리: r.remark || "",
|
||||||
|
처리일자: fmtDate(r.transaction_date),
|
||||||
|
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||||
|
위치: r.location_code || "",
|
||||||
|
품목코드: r.item_code || "",
|
||||||
|
품목명: itemMap[r.item_code]?.item_name || "",
|
||||||
|
수량: Number(r.quantity) || 0,
|
||||||
|
단위: itemMap[r.item_code]?.unit || "",
|
||||||
|
로트번호: r.lot_number || "",
|
||||||
|
참조번호: r.reference_number || "",
|
||||||
|
담당자: r.manager_name || userMap[r.writer] || r.writer || "",
|
||||||
|
}));
|
||||||
|
const _n = new Date();
|
||||||
|
await exportToExcel(rows, `입출고관리_${_n.getFullYear()}${String(_n.getMonth() + 1).padStart(2, "0")}${String(_n.getDate()).padStart(2, "0")}.xlsx`, "입출고내역");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════ 렌더 ════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="입출고구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="입고">입고</SelectItem>
|
||||||
|
<SelectItem value="출고">출고</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="카테고리" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{categoryOptions.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={HISTORY_TABLE}
|
||||||
|
filterId="c16-inbound-outbound"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">입출고 내역</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||||
|
<SelectValue placeholder="그룹없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">그룹없음</SelectItem>
|
||||||
|
<SelectItem value="transaction_type">입출고구분별</SelectItem>
|
||||||
|
<SelectItem value="remark">카테고리별</SelectItem>
|
||||||
|
<SelectItem value="warehouse_code">창고별</SelectItem>
|
||||||
|
<SelectItem value="item_code">품목코드별</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcel} disabled={data.length === 0}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<Inbox className="w-10 h-10 opacity-30" />
|
||||||
|
<span className="text-sm">조회된 데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => toggleAll(!!c)} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center text-[11px]">입출고구분</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center text-[11px]">카테고리</TableHead>
|
||||||
|
<TableHead className="w-[95px] text-center text-[11px]">처리일자</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">창고</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">위치</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">품목코드</TableHead>
|
||||||
|
<TableHead className="w-[160px] text-[11px]">품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right text-[11px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[50px] text-center text-[11px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-[11px]">로트번호</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">참조번호</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center text-[11px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedData.map((row, idx) => {
|
||||||
|
if (row._group) {
|
||||||
|
const expanded = expandedGroups.has(row._key);
|
||||||
|
return (
|
||||||
|
<TableRow key={`g-${row._key}`} className="bg-muted/60 cursor-pointer hover:bg-muted" onClick={() => toggleGroup(row._key)}>
|
||||||
|
<TableCell colSpan={8} className="py-2 px-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{row._key}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{row._count}건</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-bold text-primary text-[13px]">
|
||||||
|
{fmtNum(row._totalQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={4} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 그룹 접힘 체크
|
||||||
|
if (groupBy !== "none") {
|
||||||
|
let gk: string;
|
||||||
|
switch (groupBy) {
|
||||||
|
case "warehouse_code": gk = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||||
|
case "item_code": gk = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||||
|
default: gk = row[groupBy] || "미지정";
|
||||||
|
}
|
||||||
|
if (!expandedGroups.has(gk)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIn = row.transaction_type === "입고";
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
const checked = checkedIds.has(row.id);
|
||||||
|
const info = itemMap[row.item_code];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id || idx} className={cn("hover:bg-accent/50", checked && "bg-primary/5")}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={() => toggleOne(row.id)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full",
|
||||||
|
isIn ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
|
||||||
|
)}>
|
||||||
|
{isIn ? <ArrowDownToLine className="w-3 h-3" /> : <ArrowUpFromLine className="w-3 h-3" />}
|
||||||
|
{row.transaction_type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] font-mono">{row.item_code || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{info?.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className={cn("text-right font-mono font-semibold text-[13px]", isIn ? "text-emerald-600" : "text-amber-600")}>
|
||||||
|
{isIn ? "+" : ""}{fmtNum(qty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{info?.unit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px]">{row.lot_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted-foreground">{row.reference_number || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center text-[12px]">{row.manager_name || userMap[row.writer] || row.writer || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -113,6 +113,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_7/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_7/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_7/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_7/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_7/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_7/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_7/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_7/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_7/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_7/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_7/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_7/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_7/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -158,6 +159,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_7/mold/info": dynamic(() => import("@/app/(main)/COMPANY_7/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_7/mold/info": dynamic(() => import("@/app/(main)/COMPANY_7/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_16/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -200,6 +202,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_8/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_8/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_8/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_8/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_8/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_8/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_8/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_8/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_8/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_8/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_8/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_8/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_8/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_8/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_8/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -242,6 +245,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_10/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_10/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_10/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_10/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_10/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_10/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_10/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_10/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_10/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_10/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_10/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_10/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_10/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_10/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_10/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -284,6 +288,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_29/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_29/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_29/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_29/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_29/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_29/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_29/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_29/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_29/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_29/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -326,6 +331,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_9/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_9/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_9/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_9/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_9/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_9/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_9/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_9/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_9/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_9/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_9/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_9/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_9/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_9/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_9/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -369,6 +375,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_30/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_30/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_30/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_30/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_30/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_30/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_30/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_30/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_30/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_30/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_30/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_30/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_30/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_30/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_30/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
@@ -486,6 +493,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
|||||||
"/COMPANY_7/equipment/plc-settings": () => import("@/app/(main)/COMPANY_7/equipment/plc-settings/page"),
|
"/COMPANY_7/equipment/plc-settings": () => import("@/app/(main)/COMPANY_7/equipment/plc-settings/page"),
|
||||||
"/COMPANY_7/logistics/material-status": () => import("@/app/(main)/COMPANY_7/logistics/material-status/page"),
|
"/COMPANY_7/logistics/material-status": () => import("@/app/(main)/COMPANY_7/logistics/material-status/page"),
|
||||||
"/COMPANY_7/logistics/outbound": () => import("@/app/(main)/COMPANY_7/logistics/outbound/page"),
|
"/COMPANY_7/logistics/outbound": () => import("@/app/(main)/COMPANY_7/logistics/outbound/page"),
|
||||||
|
"/COMPANY_7/logistics/inbound-outbound": () => import("@/app/(main)/COMPANY_7/logistics/inbound-outbound/page"),
|
||||||
"/COMPANY_7/logistics/receiving": () => import("@/app/(main)/COMPANY_7/logistics/receiving/page"),
|
"/COMPANY_7/logistics/receiving": () => import("@/app/(main)/COMPANY_7/logistics/receiving/page"),
|
||||||
"/COMPANY_7/logistics/packaging": () => import("@/app/(main)/COMPANY_7/logistics/packaging/page"),
|
"/COMPANY_7/logistics/packaging": () => import("@/app/(main)/COMPANY_7/logistics/packaging/page"),
|
||||||
"/COMPANY_7/logistics/info": () => import("@/app/(main)/COMPANY_7/logistics/info/page"),
|
"/COMPANY_7/logistics/info": () => import("@/app/(main)/COMPANY_7/logistics/info/page"),
|
||||||
@@ -514,6 +522,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
|||||||
"/COMPANY_10/equipment/info": () => import("@/app/(main)/COMPANY_10/equipment/info/page"),
|
"/COMPANY_10/equipment/info": () => import("@/app/(main)/COMPANY_10/equipment/info/page"),
|
||||||
"/COMPANY_10/logistics/material-status": () => import("@/app/(main)/COMPANY_10/logistics/material-status/page"),
|
"/COMPANY_10/logistics/material-status": () => import("@/app/(main)/COMPANY_10/logistics/material-status/page"),
|
||||||
"/COMPANY_10/logistics/outbound": () => import("@/app/(main)/COMPANY_10/logistics/outbound/page"),
|
"/COMPANY_10/logistics/outbound": () => import("@/app/(main)/COMPANY_10/logistics/outbound/page"),
|
||||||
|
"/COMPANY_10/logistics/inbound-outbound": () => import("@/app/(main)/COMPANY_10/logistics/inbound-outbound/page"),
|
||||||
"/COMPANY_10/logistics/receiving": () => import("@/app/(main)/COMPANY_10/logistics/receiving/page"),
|
"/COMPANY_10/logistics/receiving": () => import("@/app/(main)/COMPANY_10/logistics/receiving/page"),
|
||||||
"/COMPANY_10/logistics/packaging": () => import("@/app/(main)/COMPANY_10/logistics/packaging/page"),
|
"/COMPANY_10/logistics/packaging": () => import("@/app/(main)/COMPANY_10/logistics/packaging/page"),
|
||||||
"/COMPANY_10/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_10/outsourcing/subcontractor/page"),
|
"/COMPANY_10/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_10/outsourcing/subcontractor/page"),
|
||||||
@@ -547,6 +556,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
|||||||
"/COMPANY_9/monitoring/quality": () => import("@/app/(main)/COMPANY_9/monitoring/quality/page"),
|
"/COMPANY_9/monitoring/quality": () => import("@/app/(main)/COMPANY_9/monitoring/quality/page"),
|
||||||
"/COMPANY_9/logistics/material-status": () => import("@/app/(main)/COMPANY_9/logistics/material-status/page"),
|
"/COMPANY_9/logistics/material-status": () => import("@/app/(main)/COMPANY_9/logistics/material-status/page"),
|
||||||
"/COMPANY_9/logistics/outbound": () => import("@/app/(main)/COMPANY_9/logistics/outbound/page"),
|
"/COMPANY_9/logistics/outbound": () => import("@/app/(main)/COMPANY_9/logistics/outbound/page"),
|
||||||
|
"/COMPANY_9/logistics/inbound-outbound": () => import("@/app/(main)/COMPANY_9/logistics/inbound-outbound/page"),
|
||||||
"/COMPANY_9/logistics/receiving": () => import("@/app/(main)/COMPANY_9/logistics/receiving/page"),
|
"/COMPANY_9/logistics/receiving": () => import("@/app/(main)/COMPANY_9/logistics/receiving/page"),
|
||||||
"/COMPANY_9/logistics/packaging": () => import("@/app/(main)/COMPANY_9/logistics/packaging/page"),
|
"/COMPANY_9/logistics/packaging": () => import("@/app/(main)/COMPANY_9/logistics/packaging/page"),
|
||||||
"/COMPANY_9/logistics/info": () => import("@/app/(main)/COMPANY_9/logistics/info/page"),
|
"/COMPANY_9/logistics/info": () => import("@/app/(main)/COMPANY_9/logistics/info/page"),
|
||||||
@@ -589,6 +599,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
|||||||
"/COMPANY_30/monitoring/quality": () => import("@/app/(main)/COMPANY_30/monitoring/quality/page"),
|
"/COMPANY_30/monitoring/quality": () => import("@/app/(main)/COMPANY_30/monitoring/quality/page"),
|
||||||
"/COMPANY_30/logistics/material-status": () => import("@/app/(main)/COMPANY_30/logistics/material-status/page"),
|
"/COMPANY_30/logistics/material-status": () => import("@/app/(main)/COMPANY_30/logistics/material-status/page"),
|
||||||
"/COMPANY_30/logistics/outbound": () => import("@/app/(main)/COMPANY_30/logistics/outbound/page"),
|
"/COMPANY_30/logistics/outbound": () => import("@/app/(main)/COMPANY_30/logistics/outbound/page"),
|
||||||
|
"/COMPANY_30/logistics/inbound-outbound": () => import("@/app/(main)/COMPANY_30/logistics/inbound-outbound/page"),
|
||||||
"/COMPANY_30/logistics/receiving": () => import("@/app/(main)/COMPANY_30/logistics/receiving/page"),
|
"/COMPANY_30/logistics/receiving": () => import("@/app/(main)/COMPANY_30/logistics/receiving/page"),
|
||||||
"/COMPANY_30/logistics/packaging": () => import("@/app/(main)/COMPANY_30/logistics/packaging/page"),
|
"/COMPANY_30/logistics/packaging": () => import("@/app/(main)/COMPANY_30/logistics/packaging/page"),
|
||||||
"/COMPANY_30/logistics/info": () => import("@/app/(main)/COMPANY_30/logistics/info/page"),
|
"/COMPANY_30/logistics/info": () => import("@/app/(main)/COMPANY_30/logistics/info/page"),
|
||||||
@@ -623,6 +634,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
|||||||
"/COMPANY_29/equipment/info": () => import("@/app/(main)/COMPANY_29/equipment/info/page"),
|
"/COMPANY_29/equipment/info": () => import("@/app/(main)/COMPANY_29/equipment/info/page"),
|
||||||
"/COMPANY_29/logistics/material-status": () => import("@/app/(main)/COMPANY_29/logistics/material-status/page"),
|
"/COMPANY_29/logistics/material-status": () => import("@/app/(main)/COMPANY_29/logistics/material-status/page"),
|
||||||
"/COMPANY_29/logistics/outbound": () => import("@/app/(main)/COMPANY_29/logistics/outbound/page"),
|
"/COMPANY_29/logistics/outbound": () => import("@/app/(main)/COMPANY_29/logistics/outbound/page"),
|
||||||
|
"/COMPANY_29/logistics/inbound-outbound": () => import("@/app/(main)/COMPANY_29/logistics/inbound-outbound/page"),
|
||||||
"/COMPANY_29/logistics/receiving": () => import("@/app/(main)/COMPANY_29/logistics/receiving/page"),
|
"/COMPANY_29/logistics/receiving": () => import("@/app/(main)/COMPANY_29/logistics/receiving/page"),
|
||||||
"/COMPANY_29/logistics/packaging": () => import("@/app/(main)/COMPANY_29/logistics/packaging/page"),
|
"/COMPANY_29/logistics/packaging": () => import("@/app/(main)/COMPANY_29/logistics/packaging/page"),
|
||||||
"/COMPANY_29/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"),
|
"/COMPANY_29/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"),
|
||||||
|
|||||||
Reference in New Issue
Block a user