feat: Enhance process and work standard management functionalities

- Updated the processInfoController to allow for flexible use_yn filtering, supporting both "Y" and "N" values along with their corresponding "USE_Y" and "USE_N" mappings.
- Modified the processWorkStandardController to include selected_bom_items in work item details, enabling better management of BOM data.
- Improved the productionPlanService to handle order summaries more effectively, incorporating legacy and detail data through a unified query structure.
- Enhanced the receiving and outbound pages with new filtering and grouping functionalities, improving user experience and data handling.

These changes aim to streamline process management and improve overall functionality across various modules.
This commit is contained in:
kjs
2026-04-06 15:50:33 +09:00
parent b974139abe
commit cf9f53e4c5
10 changed files with 1293 additions and 177 deletions
@@ -37,17 +37,22 @@ import {
Save,
ChevronRight,
ChevronLeft,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Inbox,
Settings2,
Filter,
Check,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
// API: /outbound/*
import {
getOutboundList,
@@ -110,6 +115,118 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (출고번호 뒤)
const MASTER_BODY_LAYOUT = [
{ key: "outbound_type", label: "출고유형", colSpan: 1 },
{ key: "outbound_date", label: "출고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "customer_name", label: "거래처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "outbound_status", label: "출고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_type", label: "출처" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "specification", label: "규격" },
{ key: "outbound_qty", label: "출고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["outbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 출고번호(1) + 마스터필드(7) = 10
const TOTAL_COLS = 10;
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<string>;
onToggle: (colKey: string, value: string) => void;
onClear: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const hasFilter = filterValues.size > 0;
const filteredValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {colLabel}</span>
{hasFilter && (
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredValues.slice(0, 100).map((val) => {
const isSelected = filterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggle(colKey, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// 선택된 소스 아이템 (등록 모달에서 사용)
interface SelectedSourceItem {
key: string;
@@ -139,6 +256,12 @@ export default function OutboundPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블 state
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 등록 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalOutboundType, setModalOutboundType] = useState("판매출고");
@@ -210,14 +333,146 @@ export default function OutboundPage() {
})();
}, []);
// 체크박스
const allChecked = data.length > 0 && checkedIds.length === data.length;
const toggleCheckAll = () => {
setCheckedIds(allChecked ? [] : data.map((d) => d.id));
// --- 마스터-디테일 그룹핑, 필터, 정렬 ---
// outbound_number 기준 그룹핑 + 필터 + 정렬
const filteredGroups = useMemo(() => {
// 1차: outbound_number 기준 그룹핑
const allGroups: Record<string, { master: OutboundItem; details: OutboundItem[] }> = {};
for (const row of data) {
const key = row.outbound_number || "_no_number";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
const raw = (group.master as any)?.[colKey] ?? "";
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([outNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_type") cellVal = SOURCE_TYPE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [outNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
const av = (a.master as any)?.[key] ?? "";
const bv = (b.master as any)?.[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
let av: any = (a as any)[key] ?? "";
let bv: any = (b as any)[key] ?? "";
if (key === "source_type") { av = SOURCE_TYPE_LABEL[av] || av; bv = SOURCE_TYPE_LABEL[bv] || bv; }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, OutboundItem>();
data.forEach((row) => {
if (row.outbound_number && !seenMasters.has(row.outbound_number)) {
seenMasters.set(row.outbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "outbound_number", label: "출고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
const val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_type") val = SOURCE_TYPE_LABEL[val] || val;
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const toggleCheck = (id: string) => {
setCheckedIds((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
const handleSort = (key: string) => {
setSortState((prev) =>
prev?.key === key
? prev.direction === "asc" ? { key, direction: "desc" } : null
: { key, direction: "asc" }
);
};
@@ -316,7 +571,7 @@ export default function OutboundPage() {
unit_price: Number(g.unit_price) || 0,
total_amount: Number(g.total_amount) || 0,
source_type: g.source_type || "",
source_id: g.source_id || "",
source_id: (g as any).source_id || "",
}))
);
setSourceKeyword("");
@@ -644,40 +899,294 @@ export default function OutboundPage() {
</div>
</div>
<EDataTable
columns={[
{ key: "outbound_number", label: "출고번호", width: "w-[130px]" },
{ key: "outbound_type", label: "출고유형", width: "w-[90px]", render: (v) => (
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(v))}>{v || "-"}</Badge>
)},
{ key: "outbound_date", label: "출고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" },
{ key: "reference_number", label: "참조번호", width: "w-[120px]" },
{ key: "source_type", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TYPE_LABEL[v] || v : "-" },
{ key: "customer_name", label: "거래처", width: "w-[120px]" },
{ key: "item_code", label: "품목코드", width: "w-[100px]" },
{ key: "item_name", label: "품목명", minWidth: "min-w-[150px]" },
{ key: "specification", label: "규격", width: "w-[80px]" },
{ key: "outbound_qty", label: "출고수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true },
{ key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" },
{ key: "outbound_status", label: "출고상태", width: "w-[90px]", align: "center", render: (v) => (
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(v))}>{v || "-"}</Badge>
)},
{ key: "memo", label: "비고", width: "w-[100px]" },
] as EDataTableColumn<OutboundItem>[]}
data={ts.groupData(data)}
rowKey={(row) => row.id}
loading={loading}
emptyMessage="등록된 출고 내역이 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row)}
showPagination
draggableColumns
columnOrderKey="c16-outbound"
/>
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1200px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "100px" }} /><col style={{ width: "120px" }} /><col style={{ width: "120px" }} /><col style={{ width: "100px" }} /><col style={{ width: "90px" }} /><col style={{ width: "120px" }} /></colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 출고번호 */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("outbound_number")}>
<span className="truncate"></span>
{sortState?.key === "outbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["outbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="outbound_number" colLabel="출고번호"
uniqueValues={masterUniqueValues["outbound_number"] || []}
filterValues={headerFilters["outbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 */}
{MASTER_BODY_LAYOUT.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<PackageOpen className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([outboundNo, group]) => {
const isExpanded = expandedOrders.has(outboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
return (
<React.Fragment key={outboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(outboundNo)) {
setClosingOrders((prev) => new Set(prev).add(outboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(outboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(outboundNo));
}
}}
onDoubleClick={() => openEditModal(group.details[0])}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 출고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{outboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 출고유형 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
{master.outbound_type || "-"}
</Badge>
</TableCell>
{/* 출고일 */}
<TableCell className="whitespace-nowrap text-[13px]">
{master.outbound_date ? new Date(master.outbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 거래처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.customer_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 출고상태 */}
<TableCell>
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(master.outbound_status))}>
{master.outbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground">
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
</TableCell>
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(outboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
const isRight = ["outbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_type") v = SOURCE_TYPE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(outboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_type ? (SOURCE_TYPE_LABEL[row.source_type] || row.source_type) : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_code || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.specification || ""}</TableCell>
{/* 출고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.outbound_qty ? Number(row.outbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
</TableRow>
);
})}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
{/* 출고 등록 모달 */}
@@ -43,17 +43,23 @@ import {
X,
Save,
ChevronRight,
ChevronDown,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
Settings2,
Filter,
Check,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
// EDataTable 제거 — 마스터-디테일 그룹 테이블로 교체
// API: /receiving/*
import {
getReceivingList,
@@ -89,13 +95,137 @@ const GRID_COLUMNS = [
{ key: "remark", label: "비고" },
];
// 마스터 헤더 레이아웃 (입고번호 뒤, 디테일 7컬럼 위에 colSpan으로 맵핑)
const MASTER_BODY_LAYOUT = [
{ key: "inbound_type", label: "입고유형", colSpan: 1 },
{ key: "inbound_date", label: "입고일", colSpan: 1 },
{ key: "reference_number", label: "참조번호", colSpan: 1 },
{ key: "supplier_name", label: "공급처", colSpan: 1 },
{ key: "warehouse_name", label: "창고", colSpan: 1 },
{ key: "inbound_status", label: "입고상태", colSpan: 1 },
{ key: "memo", label: "비고", colSpan: 1 },
];
// 디테일 헤더 컬럼
const DETAIL_HEADER_COLS = [
{ key: "source_table", label: "출처" },
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "spec", label: "규격" },
{ key: "inbound_qty", label: "입고수량" },
{ key: "unit_price", label: "단가" },
{ key: "total_amount", label: "금액" },
];
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 입고번호(1) + 디테일(7) = 10
const TOTAL_COLS = 10;
// 마스터 필드 키 목록 (필터 분류용)
const MASTER_KEYS = new Set(["inbound_number", ...MASTER_BODY_LAYOUT.map((c) => c.key)]);
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<string>;
onToggle: (colKey: string, value: string) => void;
onClear: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const hasFilter = filterValues.size > 0;
const filteredValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {colLabel}</span>
{hasFilter && (
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredValues.slice(0, 100).map((val) => {
const isSelected = filterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggle(colKey, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// 입고유형 옵션
const INBOUND_TYPES = [
{ value: "구매입고", label: "구매입고" },
{ value: "외주입고", label: "외주입고" },
{ value: "사급자재입고", label: "사급자재입고" },
{ value: "반품입고", label: "반품입고" },
{ value: "기타입고", label: "기타입고" },
];
// 입고유형 카테고리 코드→라벨 매핑
const INBOUND_TYPE_CODE_MAP: Record<string, string> = {
CAT_MLYTB8ON_A3AU: "구매입고",
CAT_MLYTBMH6_9AB7: "외주입고",
CAT_MLYTBSLW_5N81: "사급자재입고",
CAT_MLYTBGEV_N23U: "반품입고",
CAT_MLYTBYLU_0Z5T: "기타입고",
};
const resolveInboundType = (v: string) => INBOUND_TYPE_CODE_MAP[v] || v || "-";
const INBOUND_STATUS_OPTIONS = [
{ value: "대기", label: "대기" },
{ value: "입고완료", label: "입고완료" },
@@ -156,6 +286,12 @@ export default function ReceivingPage() {
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 마스터-디테일 그룹 테이블
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 등록 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalInboundType, setModalInboundType] = useState("구매입고");
@@ -237,14 +373,149 @@ export default function ReceivingPage() {
})();
}, []);
// 체크박스
const allChecked = data.length > 0 && checkedIds.length === data.length;
const toggleCheckAll = () => {
setCheckedIds(allChecked ? [] : data.map((d) => d.id));
// 필터 + 정렬 적용된 데이터 -> 그룹핑
const filteredGroups = useMemo(() => {
// 1차: inbound_number 기준 그룹핑
const allGroups: Record<string, { master: InboundItem; details: InboundItem[] }> = {};
for (const row of data) {
const key = row.inbound_number || "_no_inbound";
if (!allGroups[key]) {
allGroups[key] = { master: row, details: [] };
}
allGroups[key].details.push(row);
}
// 마스터 필터 / 디테일 필터 분리
const masterFilters: Record<string, Set<string>> = {};
const detailFilters: Record<string, Set<string>> = {};
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values;
else detailFilters[colKey] = values;
}
// 2차: 마스터 필터 적용 (그룹 단위)
let entries = Object.entries(allGroups);
if (Object.keys(masterFilters).length > 0) {
entries = entries.filter(([, group]) =>
Object.entries(masterFilters).every(([colKey, values]) => {
let raw = (group.master as any)?.[colKey] ?? "";
// 입고유형은 코드→라벨 변환된 값으로 비교
if (colKey === "inbound_type") raw = resolveInboundType(String(raw));
return values.has(String(raw));
})
);
}
// 3차: 디테일 필터 적용 (행 단위)
if (Object.keys(detailFilters).length > 0) {
entries = entries
.map(([inboundNo, group]) => {
const filtered = group.details.filter((row) =>
Object.entries(detailFilters).every(([colKey, values]) => {
let cellVal = (row as any)[colKey] != null ? String((row as any)[colKey]) : "";
if (colKey === "source_table") cellVal = SOURCE_TABLE_LABEL[cellVal] || cellVal;
return values.has(cellVal);
})
);
return [inboundNo, { ...group, details: filtered }] as [string, typeof group];
})
.filter(([, group]) => group.details.length > 0);
}
// 4차: 정렬
if (sortState) {
const { key, direction } = sortState;
if (MASTER_KEYS.has(key)) {
entries.sort(([, a], [, b]) => {
let av: any = (a.master as any)?.[key] ?? "";
let bv: any = (b.master as any)?.[key] ?? "";
if (key === "inbound_type") { av = resolveInboundType(String(av)); bv = resolveInboundType(String(bv)); }
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
} else {
entries.forEach(([, group]) => {
group.details.sort((a, b) => {
const av = (a as any)[key] ?? "";
const bv = (b as any)[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
});
}
}
return Object.fromEntries(entries);
}, [data, headerFilters, sortState]);
// 마스터 컬럼별 고유값 (마스터 헤더 필터용)
const masterUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const seenMasters = new Map<string, InboundItem>();
data.forEach((row) => {
if (row.inbound_number && !seenMasters.has(row.inbound_number)) {
seenMasters.set(row.inbound_number, row);
}
});
const masters = Array.from(seenMasters.values());
for (const col of [{ key: "inbound_number", label: "입고번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label }))]) {
const values = new Set<string>();
masters.forEach((m) => {
let val = (m as any)?.[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "inbound_type") val = resolveInboundType(String(val));
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of DETAIL_HEADER_COLS) {
const values = new Set<string>();
data.forEach((row) => {
let val = (row as any)[col.key];
if (val !== null && val !== undefined && val !== "") {
if (col.key === "source_table") val = SOURCE_TABLE_LABEL[val] || val;
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const toggleCheck = (id: string) => {
setCheckedIds((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
const handleSort = (key: string) => {
setSortState((prev) =>
prev?.key === key
? prev.direction === "asc" ? { key, direction: "desc" } : null
: { key, direction: "asc" }
);
};
@@ -561,13 +832,13 @@ export default function ReceivingPage() {
}
/>
{/* 입고 목록 테이블 */}
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
{/* 입고 목록 테이블 (마스터-디테일 그룹) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-bold"> </h3>
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{data.length}
{Object.keys(filteredGroups).length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -575,39 +846,292 @@ export default function ReceivingPage() {
</Button>
</div>
<EDataTable
columns={[
{ key: "inbound_number", label: "입고번호", width: "w-[130px]" },
{ key: "inbound_type", label: "입고유형", width: "w-[90px]", render: (v) => (
<Badge variant={getTypeVariant(v)} className="text-[11px]">{v || "-"}</Badge>
)},
{ key: "inbound_date", label: "입고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" },
{ key: "reference_number", label: "참조번호", width: "w-[120px]" },
{ key: "source_table", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TABLE_LABEL[v] || v : "-" },
{ key: "supplier_name", label: "공급처", width: "w-[120px]" },
{ key: "item_number", label: "품목코드", width: "w-[100px]" },
{ key: "item_name", label: "품목명", minWidth: "min-w-[150px]" },
{ key: "spec", label: "규격", width: "w-[80px]" },
{ key: "inbound_qty", label: "입고수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true },
{ key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" },
{ key: "inbound_status", label: "입고상태", width: "w-[90px]", align: "center", render: (v) => (
<Badge variant={getStatusVariant(v)} className="text-[11px]">{v || "-"}</Badge>
)},
{ key: "memo", label: "비고", width: "w-[100px]" },
] as EDataTableColumn<InboundItem>[]}
data={ts.groupData(data)}
rowKey={(row) => row.id}
loading={loading}
emptyMessage="등록된 입고 내역이 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
showPagination
draggableColumns
columnOrderKey="c16-receiving"
/>
<div className="h-[calc(100%-44px)] overflow-auto">
<Table style={{ minWidth: "1100px" }}>
<colgroup><col style={{ width: "40px" }} /><col style={{ width: "36px" }} /><col style={{ width: "140px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /><col style={{ width: "160px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "90px" }} /><col style={{ width: "110px" }} /></colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = Object.values(filteredGroups).flatMap((g) => g.details.map((d) => d.id));
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
{/* 입고번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("inbound_number")}>
<span className="truncate"></span>
{sortState?.key === "inbound_number" && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues["inbound_number"] || []).length > 0 && (
<HeaderFilterPopover
colKey="inbound_number" colLabel="입고번호"
uniqueValues={masterUniqueValues["inbound_number"] || []}
filterValues={headerFilters["inbound_number"] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
{MASTER_BODY_LAYOUT.map((col) => (
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<div className="inline-flex items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(masterUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={masterUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(filteredGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Inbox className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
) : (
Object.entries(filteredGroups).map(([inboundNo, group]) => {
const isExpanded = expandedOrders.has(inboundNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
return (
<React.Fragment key={inboundNo}>
{/* 마스터 행 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
allDetailChecked && "border-l-primary bg-primary/5"
)}
onClick={() => {
if (expandedOrders.has(inboundNo)) {
setClosingOrders((prev) => new Set(prev).add(inboundNo));
setTimeout(() => {
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
setClosingOrders((prev) => { const next = new Set(prev); next.delete(inboundNo); return next; });
}, 200);
} else {
setExpandedOrders((prev) => new Set(prev).add(inboundNo));
}
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
{isExpanded
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
{/* 입고번호 */}
<TableCell className="font-mono whitespace-nowrap">
{inboundNo}
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
</TableCell>
{/* 입고유형 */}
<TableCell className="text-[13px]">
<Badge variant={getTypeVariant(resolveInboundType(master.inbound_type))} className="text-[11px]">
{resolveInboundType(master.inbound_type)}
</Badge>
</TableCell>
{/* 입고일 */}
<TableCell className="text-[13px] whitespace-nowrap">
{master.inbound_date ? new Date(master.inbound_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
{/* 참조번호 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.reference_number || ""}</span>
</TableCell>
{/* 공급처 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.supplier_name || ""}</span>
</TableCell>
{/* 창고 */}
<TableCell className="text-[13px] truncate max-w-0">
<span className="block truncate">{master.warehouse_name || master.warehouse_code || ""}</span>
</TableCell>
{/* 입고상태 */}
<TableCell className="text-[13px]">
<Badge variant={getStatusVariant(master.inbound_status)} className="text-[11px]">
{master.inbound_status || "-"}
</Badge>
</TableCell>
{/* 비고 */}
<TableCell className="text-muted-foreground text-[13px]">
<span className="block truncate max-w-[110px]">{master.memo || ""}</span>
</TableCell>
</TableRow>
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
{isExpanded && (
<TableRow
className={cn(
"border-l-[3px] border-l-primary/30 bg-muted/60",
closingOrders.has(inboundNo) ? "tree-detail-row-closing" : "tree-detail-row",
)}
>
<TableCell />
<TableCell />
<TableCell />
{DETAIL_HEADER_COLS.map((col) => {
const isRight = ["inbound_qty", "unit_price", "total_amount"].includes(col.key);
const isSorted = sortState?.key === col.key;
const uniqueVals = Array.from(new Set(
group.details.map((d) => {
let v = (d as any)[col.key];
if (col.key === "source_table") v = SOURCE_TABLE_LABEL[v] || v;
return v;
}).filter((v: any) => v != null && v !== "").map(String)
)).sort();
const filterVals = headerFilters[col.key] || new Set<string>();
return (
<TableCell
key={col.key}
className={cn(
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
isRight && "text-right",
)}
>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div
className="flex items-center gap-1 cursor-pointer min-w-0"
onClick={() => handleSort(col.key)}
>
<span className="truncate">{col.label}</span>
{isSorted && (
sortState!.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{uniqueVals.length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={uniqueVals} filterValues={filterVals}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableCell>
);
})}
</TableRow>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(inboundNo);
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={`${row.id}-${detailIdx}`}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<TableCell />
{/* 출처 */}
<TableCell className="text-[13px]">{row.source_table ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table : "-"}</TableCell>
{/* 품목코드 */}
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
{/* 품목명 */}
<TableCell className="text-[13px] max-w-[160px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
{/* 규격 */}
<TableCell className="text-[13px] text-muted-foreground">{row.spec || ""}</TableCell>
{/* 입고수량 */}
<TableCell className="text-right font-mono text-[13px]">{row.inbound_qty ? Number(row.inbound_qty).toLocaleString() : ""}</TableCell>
{/* 단가 */}
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
{/* 금액 */}
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.total_amount ? Number(row.total_amount).toLocaleString() : ""}</TableCell>
</TableRow>
);
})}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
{/* 입고 등록 모달 */}
@@ -1033,11 +1557,11 @@ function SourcePurchaseOrderTable({
</TableRow>
</TableHeader>
<TableBody>
{data.map((po) => {
{data.map((po, idx) => {
const isSelected = selectedKeys.includes(`po-${po.id}`);
return (
<TableRow
key={po.id}
key={`${po.source_table || 'po'}-${po.id}-${idx}`}
className={cn(
"cursor-pointer text-xs transition-colors",
isSelected && "bg-primary/5"
@@ -61,6 +61,7 @@ const GRID_COLUMNS = [
{ key: "inventory_unit", label: "재고단위" },
{ key: "user_type01", label: "대분류" },
{ key: "user_type02", label: "중분류" },
{ key: "lead_time", label: "생산 리드타임(일)", align: "right" as const },
];
const FORM_FIELDS = [
@@ -81,6 +82,7 @@ const FORM_FIELDS = [
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
{ key: "meno", label: "메모", type: "textarea" },
];
@@ -390,11 +390,17 @@ export default function BomManagementPage() {
details = details.map((d: any) => {
const item = itemMap.get(d.child_item_id) as any;
const divisionRaw = item?.division || "";
// 카테고리 코드 → 라벨 변환 (쉼표 구분 다중값 지원)
const divisionLabel = divisionRaw.split(",").map((code: string) => {
const c = code.trim();
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
}).filter((v: string) => v && v !== "s").join(", ");
return {
...d,
item_number: item?.item_number || "",
item_name: item?.item_name || "",
item_type: item?.division || "",
item_type: divisionLabel,
unit: d.unit || item?.unit || "",
};
});
@@ -409,16 +415,16 @@ export default function BomManagementPage() {
} finally {
setDetailLoading(false);
}
}, []);
}, [categoryOptions]);
// 버전 목록 로드
const fetchVersions = useCallback(async (bomId: string) => {
setVersionLoading(true);
try {
const res = await apiClient.get(`/bom/${bomId}/versions`);
const data = res.data?.data || res.data;
setVersions(data?.versions || []);
setCurrentVersionId(data?.currentVersionId || null);
const resData = res.data;
setVersions(resData?.data || []);
setCurrentVersionId(resData?.currentVersionId || null);
} catch (err: any) {
toast.error("버전 목록 조회에 실패했어요");
} finally {
@@ -636,6 +642,8 @@ export default function BomManagementPage() {
fetchBomList();
if (bomId) {
setSelectedBomId(bomId);
fetchBomDetail(bomId);
fetchVersions(bomId);
}
} catch (err: any) {
toast.error(err?.response?.data?.message || "BOM 저장에 실패했어요");
@@ -1087,72 +1095,44 @@ export default function BomManagementPage() {
</div>
{/* 트리뷰 탭 */}
<TabsContent value="tree" className="flex-1 overflow-auto p-2 m-0">
<TabsContent value="tree" className="flex-1 overflow-auto p-0 m-0">
{flatTree.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2 py-12">
<Inbox className="w-8 h-8 text-muted-foreground/40" />
<p className="text-xs">BOM </p>
</div>
) : (
<div className="space-y-0.5">
{flatTree.map((node) => {
const typeBadge = getItemTypeBadge(node.item_type);
const hasChildren = node.children.length > 0;
return (
<div
key={node.id}
className="flex items-center gap-1.5 py-1.5 px-2 rounded hover:bg-accent cursor-pointer transition-colors"
style={{ paddingLeft: `${node._level * 24 + 8}px` }}
>
{/* 토글 버튼 */}
{hasChildren ? (
<button
className="flex items-center justify-center w-[18px] h-[18px] rounded hover:bg-accent text-muted-foreground shrink-0"
onClick={() => toggleTreeNode(node.id)}
>
{node.expanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</button>
) : (
<span className="w-[18px] h-[18px] shrink-0" />
)}
{/* 타입 뱃지 */}
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", typeBadge.className)}>
{typeBadge.label}
</span>
{/* 품목코드 */}
<span className="font-mono text-[11px] text-muted-foreground whitespace-nowrap">
{node.item_number || "-"}
</span>
<span className="text-muted-foreground/40 text-[10px]">|</span>
{/* 품명 */}
<span className="text-xs text-foreground whitespace-nowrap">
{node.item_name || "-"}
</span>
{/* 수량 */}
<span className="ml-auto flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">
<strong className="font-semibold">{node.quantity || "1"}</strong>
{node.unit || ""}
</span>
{/* 비고 */}
{node.remark && (
<span className="text-[10px] text-muted-foreground/50 shrink-0">
{node.remark}
</span>
)}
</div>
);
})}
</div>
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[60px] text-center text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="w-[80px] text-center text-[11px] font-bold"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{flatTree.map((node) => {
const typeBadge = getItemTypeBadge(node.item_type);
return (
<TableRow key={node.id} className="hover:bg-accent/50">
<TableCell className="text-center text-[13px] font-mono">{node._level}</TableCell>
<TableCell className="text-[13px]">
<span className="font-mono">{node.item_number || "-"}</span>
</TableCell>
<TableCell className="text-[13px]">{node.item_name || "-"}</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className={cn("text-[10px]", typeBadge.className)}>{typeBadge.label}</Badge>
</TableCell>
<TableCell className="text-right text-[13px] font-mono font-semibold">
{node.quantity ? Number(node.quantity).toLocaleString() : "-"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</TabsContent>
@@ -1026,8 +1026,8 @@ export default function ProductionPlanManagementPage() {
<Checkbox checked={selectedItemGroups.size === orderItems.length && orderItems.length > 0} onCheckedChange={(c) => toggleAllItemGroups(!!c)} className="h-4 w-4" />
</TableHead>
<TableHead className="w-[40px]" />
<TableHead className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead style={{ width: "140px", minWidth: "140px" }} className="text-xs font-bold whitespace-nowrap text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{isColVisible("total_order_qty") && <TableHead style={ts.thStyle("total_order_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_ship_qty") && <TableHead style={ts.thStyle("total_ship_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{isColVisible("total_balance_qty") && <TableHead style={ts.thStyle("total_balance_qty")} className="text-xs font-bold whitespace-nowrap text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
@@ -1066,8 +1066,8 @@ export default function ProductionPlanManagementPage() {
<TableCell className="text-center" onClick={() => toggleItemExpand(item.item_code)}>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-200", expandedItems.has(item.item_code) && "rotate-90")} />
</TableCell>
<TableCell className="text-[13px] text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell className="text-[13px] text-primary" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_code}</TableCell>
<TableCell style={{ width: "140px", minWidth: "140px" }} className="text-[13px] text-primary truncate" onClick={() => toggleItemExpand(item.item_code)}>{item.item_name}</TableCell>
{isColVisible("total_order_qty") && <TableCell style={ts.thStyle("total_order_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_order_qty)}</TableCell>}
{isColVisible("total_ship_qty") && <TableCell style={ts.thStyle("total_ship_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_ship_qty)}</TableCell>}
{isColVisible("total_balance_qty") && <TableCell style={ts.thStyle("total_balance_qty")} className="text-[13px] text-right text-primary" onClick={() => toggleItemExpand(item.item_code)}>{formatNumber(item.total_balance_qty)}</TableCell>}