e693963c2a
- Implemented a `parseRemark` function to convert JSON remarks into human-readable text, improving clarity in the inbound-outbound page. - Updated the category filtering logic to utilize parsed remarks, enhancing data representation. - Added unit label mapping for better display of item units in the inbound-outbound page. - Enhanced the material status page with a status mapping feature, allowing for dynamic styling based on status labels. - These changes aim to improve user experience by providing clearer information and better data management across multiple company implementations.
425 lines
19 KiB
TypeScript
425 lines
19 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 입출고관리 — 하드코딩 페이지
|
|
*
|
|
* inventory_history 테이블 기반 입고+출고 통합 조회
|
|
* 그룹핑, 검색, 엑셀 다운로드 지원
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import {
|
|
Download, Loader2, Inbox, ChevronDown, ChevronRight,
|
|
ArrowDownToLine, ArrowUpFromLine, Package,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { toast } from "sonner";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
|
|
const HISTORY_TABLE = "inventory_history";
|
|
|
|
const fmtNum = (v: any) => {
|
|
const n = Number(v);
|
|
return isNaN(n) ? "0" : n.toLocaleString();
|
|
};
|
|
|
|
const fmtDate = (v: any) => {
|
|
if (!v) return "-";
|
|
const s = String(v);
|
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
|
};
|
|
|
|
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
|
const parseRemark = (remark: string | null | undefined): string => {
|
|
if (!remark) return "";
|
|
const trimmed = remark.trim();
|
|
if (!trimmed.startsWith("{")) return trimmed;
|
|
try {
|
|
const d = JSON.parse(trimmed);
|
|
switch (d.type) {
|
|
case "move":
|
|
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
|
case "adjust":
|
|
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
|
case "confirm":
|
|
return `재고확인 (${d.reason || "이상없음"})`;
|
|
case "process_inbound":
|
|
return "공정입고";
|
|
default:
|
|
return d.reason || d.memo || trimmed;
|
|
}
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
};
|
|
|
|
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 });
|
|
|
|
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 {
|
|
// 단위 카테고리 코드→라벨 매핑 로드
|
|
let unitLabelMap: Record<string, string> = {};
|
|
try {
|
|
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
|
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
|
const flatten = (vals: any[]) => {
|
|
for (const v of vals) {
|
|
unitLabelMap[v.valueCode] = v.valueLabel;
|
|
if (v.children?.length) flatten(v.children);
|
|
}
|
|
};
|
|
flatten(catRes.data.data);
|
|
}
|
|
} catch { /* skip */ }
|
|
|
|
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) {
|
|
const rawUnit = i.unit || "";
|
|
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
|
}
|
|
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]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
// ════════ 카테고리 목록 (remark에서 추출) ════════
|
|
|
|
const categoryOptions = useMemo(() => {
|
|
const set = new Set<string>();
|
|
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
|
return Array.from(set).sort();
|
|
}, [data]);
|
|
|
|
// ════════ 그룹핑 ════════
|
|
|
|
// 카테고리 필터 (클라이언트)
|
|
const filteredData = useMemo(() => {
|
|
if (categoryFilter === "all") return data;
|
|
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
|
}, [data, categoryFilter]);
|
|
|
|
const groupedData = useMemo(() => {
|
|
if (groupBy === "none") return filteredData;
|
|
const groups = new Map<string, any[]>();
|
|
for (const row of filteredData) {
|
|
let key: string;
|
|
switch (groupBy) {
|
|
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
|
case "remark": key = parseRemark(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 || "",
|
|
카테고리: parseRemark(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">{filteredData.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]">{parseRemark(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>
|
|
);
|
|
}
|