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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user