Files
pipeline/frontend/app/(main)/COMPANY_8/logistics/inbound-outbound/page.tsx
T
kjs 9200c58d2e 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.
2026-04-09 14:21:26 +09:00

380 lines
18 KiB
TypeScript

"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>
);
}