ERP-node/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx

1399 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
GripVertical,
Plus,
X,
Search,
ChevronRight,
ChevronDown,
Package,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
// ─── 타입 정의 ───
interface BomItemNode {
tempId: string;
id?: string;
parent_detail_id: string | null;
seq_no: number;
level: number;
children: BomItemNode[];
_isNew?: boolean;
_isDeleted?: boolean;
data: Record<string, any>;
}
interface BomColumnConfig {
key: string;
title: string;
width?: string;
visible?: boolean;
editable?: boolean;
isSourceDisplay?: boolean;
inputType?: string;
}
interface ItemInfo {
id: string;
item_number: string;
item_name: string;
type: string;
unit: string;
division: string;
}
interface BomItemEditorProps {
component?: any;
formData?: Record<string, any>;
companyCode?: string;
isDesignMode?: boolean;
selectedRowsData?: any[];
onChange?: (flatData: any[]) => void;
bomId?: string;
}
// 임시 ID 생성
let tempIdCounter = 0;
const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
// ─── 품목 검색 모달 ───
interface ItemSearchModalProps {
open: boolean;
onClose: () => void;
onSelect: (items: ItemInfo[]) => void;
companyCode?: string;
existingItemIds?: Set<string>;
}
function ItemSearchModal({
open,
onClose,
onSelect,
companyCode,
existingItemIds,
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const searchItems = useCallback(
async (query: string) => {
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("item_info", {
page: 1,
size: 50,
search: query
? { item_number: query, item_name: query }
: undefined,
enableEntityJoin: true,
companyCodeOverride: companyCode,
});
setItems((result.data || []) as ItemInfo[]);
} catch (error) {
console.error("[BomItemEditor] 품목 검색 실패:", error);
} finally {
setLoading(false);
}
},
[companyCode],
);
useEffect(() => {
if (open) {
setSearchText("");
setSelectedItems(new Set());
searchItems("");
}
}, [open, searchItems]);
const handleSearch = () => {
searchItems(searchText);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSearch();
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="품목코드 또는 품목명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
size="sm"
className="h-8 sm:h-10"
>
<Search className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm">
.
</span>
</div>
) : (
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
<th className="w-8 px-2 py-2 text-center">
<Checkbox
checked={selectedItems.size > 0 && selectedItems.size === items.length}
onCheckedChange={(checked) => {
if (checked) setSelectedItems(new Set(items.map((i) => i.id)));
else setSelectedItems(new Set());
}}
/>
</th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const alreadyAdded = existingItemIds?.has(item.id) || false;
return (
<tr
key={item.id}
onClick={() => {
if (alreadyAdded) return;
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
className={cn(
"border-t transition-colors",
alreadyAdded
? "cursor-not-allowed opacity-40"
: "cursor-pointer",
!alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "",
)}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.has(item.id)}
disabled={alreadyAdded}
onCheckedChange={(checked) => {
if (alreadyAdded) return;
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono">
{item.item_number}
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">()</span>}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{selectedItems.size > 0 && (
<DialogFooter className="gap-2 sm:gap-0">
<span className="text-muted-foreground text-xs sm:text-sm">
{selectedItems.size}
</span>
<Button
onClick={() => {
const selected = items.filter((i) => selectedItems.has(i.id));
onSelect(selected);
onClose();
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ───
interface TreeNodeRowProps {
node: BomItemNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
columns: BomColumnConfig[];
categoryOptionsMap: Record<string, { value: string; label: string }[]>;
mainTableName?: string;
onToggle: () => void;
onFieldChange: (tempId: string, field: string, value: string) => void;
onDelete: (tempId: string) => void;
onAddChild: (parentTempId: string) => void;
onDragStart: (e: React.DragEvent, tempId: string) => void;
onDragOver: (e: React.DragEvent, tempId: string) => void;
onDrop: (e: React.DragEvent, tempId: string) => void;
isDragOver?: boolean;
}
function TreeNodeRow({
node,
depth,
expanded,
hasChildren,
columns,
categoryOptionsMap,
mainTableName,
onToggle,
onFieldChange,
onDelete,
onAddChild,
onDragStart,
onDragOver,
onDrop,
isDragOver,
}: TreeNodeRowProps) {
const indentPx = depth * 32;
const visibleColumns = columns.filter((c) => c.visible !== false);
const renderCell = (col: BomColumnConfig) => {
const value = node.data[col.key] ?? "";
// 소스 표시 컬럼 (읽기 전용)
if (col.isSourceDisplay) {
return (
<span className="truncate text-xs" title={String(value)}>
{value || "-"}
</span>
);
}
// 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링
if (col.inputType === "category") {
const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : "";
const options = categoryOptionsMap[categoryRef] || [];
return (
<Select
value={String(value || "")}
onValueChange={(val) => onFieldChange(node.tempId, col.key, val)}
>
<SelectTrigger className="h-7 w-full min-w-[70px] text-xs">
<SelectValue placeholder={col.title} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 편집 불가능 컬럼
if (col.editable === false) {
return (
<span className="text-muted-foreground truncate text-xs">
{value || "-"}
</span>
);
}
// 숫자 입력
if (col.inputType === "number" || col.inputType === "decimal") {
return (
<Input
type="number"
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-center text-xs"
placeholder={col.title}
/>
);
}
// 기본 텍스트 입력
return (
<Input
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-xs"
placeholder={col.title}
/>
);
};
return (
<div
className={cn(
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30",
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
isDragOver && "border-primary bg-primary/5 border-dashed",
)}
style={{ marginLeft: `${indentPx}px` }}
draggable
onDragStart={(e) => onDragStart(e, node.tempId)}
onDragOver={(e) => onDragOver(e, node.tempId)}
onDrop={(e) => onDrop(e, node.tempId)}
>
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
<button
onClick={onToggle}
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded",
hasChildren
? "hover:bg-accent cursor-pointer"
: "cursor-default opacity-0",
)}
>
{hasChildren &&
(expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
))}
</button>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
{node.seq_no}
</span>
{node.level > 0 && (
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
L{node.level}
</span>
)}
{/* config.columns 기반 동적 셀 렌더링 */}
{visibleColumns.map((col) => (
<div
key={col.key}
className={cn(
"shrink-0",
col.isSourceDisplay ? "min-w-[60px] flex-1" : "min-w-[50px]",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{renderCell(col)}
</div>
))}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onAddChild(node.tempId)}
title="하위 품목 추가"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7 shrink-0"
onClick={() => onDelete(node.tempId)}
title="삭제"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
// ─── 메인 컴포넌트 ───
export function BomItemEditorComponent({
component,
formData,
companyCode,
isDesignMode = false,
selectedRowsData,
onChange,
bomId: propBomId,
}: BomItemEditorProps) {
const [treeData, setTreeData] = useState<BomItemNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(null);
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
// 설정값 추출
const cfg = useMemo(() => component?.componentConfig || {}, [component]);
const mainTableName = cfg.mainTableName || "bom_detail";
const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? cfg.parentKeyColumn : "parent_detail_id";
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
const fkColumn = cfg.foreignKeyColumn || "bom_id";
// BOM ID 결정
const bomId = useMemo(() => {
if (propBomId) return propBomId;
if (formData?.id) return formData.id as string;
if (selectedRowsData?.[0]?.id) return selectedRowsData[0].id as string;
return null;
}, [propBomId, formData, selectedRowsData]);
// BOM 전용 API로 현재 current_version_id 조회
const fetchCurrentVersionId = useCallback(async (id: string): Promise<string | null> => {
try {
const res = await apiClient.get(`/bom/${id}/versions`);
if (res.data?.success) {
// bom.current_version_id를 직접 반환 (불러오기와 사용확정 구분)
if (res.data.currentVersionId) return res.data.currentVersionId;
// fallback: active 상태 버전
const activeVersion = res.data.data?.find((v: any) => v.status === "active");
if (activeVersion) return activeVersion.id;
}
} catch (e) {
console.error("[BomItemEditor] current_version_id 조회 실패:", e);
}
return null;
}, []);
// formData에서 가져오는 versionId (fallback용)
const propsVersionId = (formData?.current_version_id as string)
|| (selectedRowsData?.[0]?.current_version_id as string)
|| null;
// ─── 카테고리 옵션 로드 (리피터 방식) ───
useEffect(() => {
const loadCategoryOptions = async () => {
const categoryColumns = visibleColumns.filter((col) => col.inputType === "category");
if (categoryColumns.length === 0) return;
for (const col of categoryColumns) {
const categoryRef = `${mainTableName}.${col.key}`;
const alreadyLoaded = await new Promise<boolean>((resolve) => {
setCategoryOptionsMap((prev) => {
resolve(!!prev[categoryRef]);
return prev;
});
});
if (alreadyLoaded) continue;
try {
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
if (response.data?.success && response.data.data) {
const options = response.data.data.map((item: any) => ({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label,
}));
setCategoryOptionsMap((prev) => ({ ...prev, [categoryRef]: options }));
}
} catch (error) {
console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error);
}
}
};
if (!isDesignMode) {
loadCategoryOptions();
}
}, [visibleColumns, mainTableName, isDesignMode]);
// ─── 데이터 로드 ───
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
const sourceTable = cfg.dataSource?.sourceTable || "item_info";
const loadBomDetails = useCallback(
async (id: string) => {
if (!id) return;
setLoading(true);
try {
// isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청
const displayCols = columns.filter((c) => c.isSourceDisplay);
const additionalJoinColumns = displayCols.map((col) => ({
sourceTable,
sourceColumn: sourceFk,
joinAlias: `${sourceFk}_${col.key}`,
referenceTable: sourceTable,
}));
// 서버에서 최신 current_version_id 조회 (항상 최신 보장)
const freshVersionId = await fetchCurrentVersionId(id);
const effectiveVersionId = freshVersionId || propsVersionId;
const searchFilter: Record<string, any> = { [fkColumn]: id };
if (effectiveVersionId) {
searchFilter.version_id = effectiveVersionId;
}
// autoFilter 비활성화: BOM 전용 API로 company_code 관리
const res = await apiClient.get(`/table-management/tables/${mainTableName}/data-with-joins`, {
params: {
page: 1,
size: 500,
search: JSON.stringify(searchFilter),
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
additionalJoinColumns: additionalJoinColumns.length > 0 ? JSON.stringify(additionalJoinColumns) : undefined,
autoFilter: JSON.stringify({ enabled: false }),
},
});
const rawData = res.data?.data?.data || res.data?.data || [];
const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record<string, any>) => {
const mapped = { ...row };
for (const key of Object.keys(row)) {
if (key.startsWith(`${sourceFk}_`)) {
const shortKey = key.replace(`${sourceFk}_`, "");
if (!mapped[shortKey]) mapped[shortKey] = row[key];
}
}
return mapped;
});
const tree = buildTree(rows);
setTreeData(tree);
const firstLevelIds = new Set<string>(
tree.map((n) => n.tempId || n.id || ""),
);
setExpandedNodes(firstLevelIds);
} catch (error) {
console.error("[BomItemEditor] 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
},
[mainTableName, fkColumn, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId],
);
// formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영)
const formVersionRef = useRef<string | null>(null);
useEffect(() => {
if (!bomId || isDesignMode) return;
const currentFormVersion = formData?.current_version_id as string || null;
// bomId가 바뀌거나, formData의 current_version_id가 바뀌면 재로드
if (formVersionRef.current !== currentFormVersion || !formVersionRef.current) {
formVersionRef.current = currentFormVersion;
loadBomDetails(bomId);
}
}, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]);
// ─── 트리 빌드 (동적 데이터) ───
const buildTree = (flatData: any[]): BomItemNode[] => {
const nodeMap = new Map<string, BomItemNode>();
const roots: BomItemNode[] = [];
flatData.forEach((item) => {
const tempId = item.id || generateTempId();
nodeMap.set(item.id || tempId, {
tempId,
id: item.id,
parent_detail_id: item[parentKeyColumn] || null,
seq_no: Number(item.seq_no) || 0,
level: Number(item.level) || 0,
children: [],
data: { ...item },
});
});
flatData.forEach((item) => {
const nodeId = item.id || "";
const node = nodeMap.get(nodeId);
if (!node) return;
const parentId = item[parentKeyColumn];
if (parentId && nodeMap.has(parentId)) {
nodeMap.get(parentId)!.children.push(node);
} else {
roots.push(node);
}
});
const sortChildren = (nodes: BomItemNode[]) => {
nodes.sort((a, b) => a.seq_no - b.seq_no);
nodes.forEach((n) => sortChildren(n.children));
};
sortChildren(roots);
return roots;
};
// ─── 트리 -> 평면 변환 (onChange 콜백용) ───
const flattenTree = useCallback((nodes: BomItemNode[]): any[] => {
const result: any[] = [];
const traverse = (
items: BomItemNode[],
parentId: string | null,
level: number,
) => {
items.forEach((node, idx) => {
result.push({
...node.data,
id: node.id,
tempId: node.tempId,
[parentKeyColumn]: parentId,
[fkColumn]: bomId,
seq_no: String(idx + 1),
level: String(level),
_isNew: node._isNew,
_targetTable: mainTableName,
_fkColumn: fkColumn,
_deferSave: true,
});
if (node.children.length > 0) {
traverse(node.children, node.id || node.tempId, level + 1);
}
});
};
traverse(nodes, null, 0);
return result;
}, [parentKeyColumn, mainTableName, fkColumn, bomId]);
// 트리 변경 시 부모에게 알림
const notifyChange = useCallback(
(newTree: BomItemNode[]) => {
setTreeData(newTree);
onChange?.(flattenTree(newTree));
},
[onChange, flattenTree],
);
// ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ───
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const originalDataRef = React.useRef<Set<string>>(new Set());
useEffect(() => {
if (treeData.length > 0 && originalDataRef.current.size === 0) {
const collectIds = (nodes: BomItemNode[]) => {
nodes.forEach((n) => {
if (n.id) originalDataRef.current.add(n.id);
collectIds(n.children);
});
};
collectIds(treeData);
}
}, [treeData]);
const markChanged = useCallback(() => setHasChanges(true), []);
const originalNotifyChange = notifyChange;
const notifyChangeWithDirty = useCallback(
(newTree: BomItemNode[]) => {
originalNotifyChange(newTree);
markChanged();
},
[originalNotifyChange, markChanged],
);
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
useEffect(() => {
if (isDesignMode || !bomId) return;
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (handleSaveAllRef.current) {
const savePromise = handleSaveAllRef.current();
if (detail?.pendingPromises) {
detail.pendingPromises.push(savePromise);
}
}
};
window.addEventListener("beforeFormSave", handler);
return () => window.removeEventListener("beforeFormSave", handler);
}, [isDesignMode, bomId]);
const handleSaveAll = useCallback(async () => {
if (!bomId) return;
setSaving(true);
try {
// version_id 확보: 없으면 서버에서 자동 초기화
let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
if (!saveVersionId) {
try {
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
if (initRes.data?.success && initRes.data.data?.versionId) {
saveVersionId = initRes.data.data.versionId;
}
} catch (e) {
console.error("[BomItemEditor] 버전 초기화 실패:", e);
}
}
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
const result: any[] = [];
nodes.forEach((node, idx) => {
result.push({
node,
parentRealId,
level,
seqNo: idx + 1,
});
if (node.children.length > 0) {
result.push(...collectAll(node.children, node.id || node.tempId, level + 1));
}
});
return result;
};
const allNodes = collectAll(treeData, null, 0);
const tempToReal: Record<string, string> = {};
let savedCount = 0;
for (const { node, parentRealId, level, seqNo } of allNodes) {
const realParentId = parentRealId
? tempToReal[parentRealId] || parentRealId
: null;
if (node._isNew) {
const raw: Record<string, any> = {
...node.data,
[fkColumn]: bomId,
[parentKeyColumn]: realParentId,
seq_no: String(seqNo),
level: String(level),
company_code: companyCode || undefined,
version_id: saveVersionId || undefined,
};
// bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거)
const payload: Record<string, any> = {};
const validKeys = new Set([
fkColumn, parentKeyColumn, "seq_no", "level", "child_item_id",
"quantity", "unit", "loss_rate", "remark", "process_type",
"base_qty", "revision", "version_id", "company_code", "writer",
]);
Object.keys(raw).forEach((k) => {
if (validKeys.has(k)) payload[k] = raw[k];
});
const resp = await apiClient.post(
`/table-management/tables/${mainTableName}/add`,
payload,
);
const newId = resp.data?.data?.id;
if (newId) tempToReal[node.tempId] = newId;
savedCount++;
} else if (node.id) {
const updatedData: Record<string, any> = {
id: node.id,
[fkColumn]: bomId,
[parentKeyColumn]: realParentId,
seq_no: String(seqNo),
level: String(level),
};
["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => {
if (node.data[k] !== undefined) updatedData[k] = node.data[k];
});
await apiClient.put(
`/table-management/tables/${mainTableName}/edit`,
{ originalData: { id: node.id }, updatedData },
);
savedCount++;
}
}
const currentIds = new Set(allNodes.filter((a) => a.node.id).map((a) => a.node.id));
for (const oldId of originalDataRef.current) {
if (!currentIds.has(oldId)) {
await apiClient.delete(
`/table-management/tables/${mainTableName}/delete`,
{ data: [{ id: oldId }] },
);
savedCount++;
}
}
originalDataRef.current = new Set(allNodes.filter((a) => a.node.id || tempToReal[a.node.tempId]).map((a) => a.node.id || tempToReal[a.node.tempId]));
setHasChanges(false);
if (bomId) loadBomDetails(bomId);
window.dispatchEvent(new CustomEvent("refreshTable"));
console.log(`[BomItemEditor] ${savedCount}건 저장 완료`);
} catch (error) {
console.error("[BomItemEditor] 저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
}, [bomId, treeData, fkColumn, parentKeyColumn, mainTableName, companyCode, sourceFk, loadBomDetails, fetchCurrentVersionId, propsVersionId]);
useEffect(() => {
handleSaveAllRef.current = handleSaveAll;
}, [handleSaveAll]);
// ─── 노드 조작 함수들 ───
// 트리에서 특정 노드 찾기 (재귀)
const findAndUpdate = (
nodes: BomItemNode[],
targetTempId: string,
updater: (node: BomItemNode) => BomItemNode | null,
): BomItemNode[] => {
const result: BomItemNode[] = [];
for (const node of nodes) {
if (node.tempId === targetTempId) {
const updated = updater(node);
if (updated) result.push(updated);
} else {
result.push({
...node,
children: findAndUpdate(node.children, targetTempId, updater),
});
}
}
return result;
};
// 필드 변경 (data Record 내부 업데이트)
const handleFieldChange = useCallback(
(tempId: string, field: string, value: string) => {
const newTree = findAndUpdate(treeData, tempId, (node) => ({
...node,
data: { ...node.data, [field]: value },
}));
notifyChangeWithDirty(newTree);
},
[treeData, notifyChangeWithDirty],
);
// 노드 삭제
const handleDelete = useCallback(
(tempId: string) => {
const newTree = findAndUpdate(treeData, tempId, () => null);
notifyChangeWithDirty(newTree);
},
[treeData, notifyChangeWithDirty],
);
// 하위 품목 추가 시작 (모달 열기)
const handleAddChild = useCallback((parentTempId: string) => {
setAddTargetParentId(parentTempId);
setItemSearchOpen(true);
}, []);
// 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용)
const existingItemIds = useMemo(() => {
const ids = new Set<string>();
const fkField = cfg.dataSource?.foreignKey || "child_item_id";
if (addTargetParentId === null) {
// 루트 레벨 추가: 루트 노드의 형제들만 체크
for (const n of treeData) {
const fk = n.data[fkField];
if (fk) ids.add(fk);
}
} else {
// 하위 추가: 해당 부모의 직속 자식들만 체크
const findParent = (nodes: BomItemNode[]): BomItemNode | null => {
for (const n of nodes) {
if (n.tempId === addTargetParentId) return n;
const found = findParent(n.children);
if (found) return found;
}
return null;
};
const parent = findParent(treeData);
if (parent) {
for (const child of parent.children) {
const fk = child.data[fkField];
if (fk) ids.add(fk);
}
}
}
return ids;
}, [treeData, cfg, addTargetParentId]);
// 루트 품목 추가 시작
const handleAddRoot = useCallback(() => {
setAddTargetParentId(null);
setItemSearchOpen(true);
}, []);
// 품목 선택 후 추가 (다중 선택 지원)
const handleItemSelect = useCallback(
(selectedItemsList: ItemInfo[]) => {
let newTree = [...treeData];
for (const item of selectedItemsList) {
const sourceData: Record<string, any> = {};
const sourceTable = cfg.dataSource?.sourceTable;
if (sourceTable) {
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
sourceData[sourceFk] = item.id;
Object.keys(item).forEach((key) => {
sourceData[`_display_${key}`] = (item as any)[key];
sourceData[key] = (item as any)[key];
});
}
const newNode: BomItemNode = {
tempId: generateTempId(),
parent_detail_id: null,
seq_no: 0,
level: 0,
children: [],
_isNew: true,
data: {
...sourceData,
quantity: "1",
loss_rate: "0",
remark: "",
},
};
if (addTargetParentId === null) {
newNode.seq_no = newTree.length + 1;
newNode.level = 0;
newTree = [...newTree, newNode];
} else {
newTree = findAndUpdate(newTree, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
return {
...parent,
children: [...parent.children, newNode],
};
});
}
}
if (addTargetParentId !== null) {
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
}
notifyChangeWithDirty(newTree);
},
[addTargetParentId, treeData, notifyChangeWithDirty, cfg],
);
// 펼침/접기 토글
const toggleExpand = useCallback((tempId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(tempId)) next.delete(tempId);
else next.add(tempId);
return next;
});
}, []);
// ─── 드래그 재정렬 ───
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
// 트리에서 노드를 제거하고 반환
const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => {
const result: BomItemNode[] = [];
let removed: BomItemNode | null = null;
for (const node of nodes) {
if (node.tempId === tempId) {
removed = node;
} else {
const childResult = removeNode(node.children, tempId);
if (childResult.removed) removed = childResult.removed;
result.push({ ...node, children: childResult.tree });
}
}
return { tree: result, removed };
};
// 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지)
const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => {
const find = (list: BomItemNode[]): BomItemNode | null => {
for (const n of list) {
if (n.tempId === parentId) return n;
const found = find(n.children);
if (found) return found;
}
return null;
};
const parent = find(nodes);
if (!parent) return false;
const check = (children: BomItemNode[]): boolean => {
for (const c of children) {
if (c.tempId === childId) return true;
if (check(c.children)) return true;
}
return false;
};
return check(parent.children);
};
const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => {
setDragId(tempId);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", tempId);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverId(tempId);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => {
e.preventDefault();
setDragOverId(null);
if (!dragId || dragId === targetTempId) return;
// 자기 자신의 하위로 드래그 방지
if (isDescendant(treeData, dragId, targetTempId)) return;
const { tree: treeWithout, removed } = removeNode(treeData, dragId);
if (!removed) return;
// 대상 노드 바로 뒤에 같은 레벨로 삽입
const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => {
const result: BomItemNode[] = [];
let inserted = false;
for (const n of nodes) {
result.push(n);
if (n.tempId === afterId) {
result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id });
inserted = true;
} else if (!inserted) {
const childResult = insertAfter(n.children, afterId, node);
if (childResult.inserted) {
result[result.length - 1] = { ...n, children: childResult.result };
inserted = true;
}
}
}
return { result, inserted };
};
const { result, inserted } = insertAfter(treeWithout, targetTempId, removed);
if (inserted) {
const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] =>
nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) }));
notifyChangeWithDirty(reindex(result));
}
setDragId(null);
}, [dragId, treeData, notifyChangeWithDirty]);
// ─── 재귀 렌더링 ───
const renderNodes = (nodes: BomItemNode[], depth: number) => {
return nodes.map((node) => {
const isExpanded = expandedNodes.has(node.tempId);
return (
<React.Fragment key={node.tempId}>
<TreeNodeRow
node={node}
depth={depth}
expanded={isExpanded}
hasChildren={node.children.length > 0}
columns={visibleColumns}
categoryOptionsMap={categoryOptionsMap}
mainTableName={mainTableName}
onToggle={() => toggleExpand(node.tempId)}
onFieldChange={handleFieldChange}
onDelete={handleDelete}
onAddChild={handleAddChild}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
isDragOver={dragOverId === node.tempId}
/>
{isExpanded &&
node.children.length > 0 &&
renderNodes(node.children, depth + 1)}
</React.Fragment>
);
});
};
// ─── 디자인 모드 미리보기 ───
if (isDesignMode) {
const cfg = component?.componentConfig || {};
const hasConfig =
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
if (!hasConfig) {
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm font-medium">
BOM
</p>
<p className="text-muted-foreground text-xs">
</p>
</div>
);
}
const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
const DUMMY_DATA: Record<string, string[]> = {
item_name: ["본체 조립", "프레임", "커버", "전장 조립", "PCB 보드"],
item_number: ["ASM-001", "PRT-010", "PRT-011", "ASM-002", "PRT-020"],
specification: ["100×50", "200mm", "ABS", "50×30", "4-Layer"],
material: ["AL6061", "SUS304", "ABS", "FR-4", "구리"],
stock_unit: ["EA", "EA", "EA", "EA", "EA"],
quantity: ["1", "2", "1", "1", "3"],
loss_rate: ["0", "5", "3", "0", "2"],
unit: ["EA", "EA", "EA", "EA", "EA"],
remark: ["", "외주", "", "", ""],
seq_no: ["1", "2", "3", "4", "5"],
};
const DUMMY_DEPTHS = [0, 1, 1, 0, 1];
const getDummyValue = (col: any, rowIdx: number): string => {
const vals = DUMMY_DATA[col.key];
if (vals) return vals[rowIdx % vals.length];
return "";
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button size="sm" className="h-7 text-xs" disabled>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 설정 요약 뱃지 */}
<div className="flex flex-wrap gap-1">
{cfg.mainTableName && (
<span className="rounded bg-orange-100 px-1.5 py-0.5 text-[10px] text-orange-700">
: {cfg.mainTableName}
</span>
)}
{cfg.dataSource?.sourceTable && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
: {cfg.dataSource.sourceTable}
</span>
)}
{cfg.parentKeyColumn && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
: {cfg.parentKeyColumn}
</span>
)}
</div>
{/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
<div className="overflow-hidden rounded-md border">
{visibleColumns.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6">
<Package className="text-muted-foreground mb-1.5 h-6 w-6" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<table className="w-full text-[10px]">
<thead className="bg-muted/60">
<tr>
<th className="w-6 px-1 py-1.5 text-center font-medium" />
<th className="w-5 px-0.5 py-1.5 text-center font-medium">#</th>
{visibleColumns.map((col: any) => (
<th
key={col.key}
className={cn(
"px-2 py-1.5 text-left font-medium",
col.isSourceDisplay && "text-blue-600",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{col.title}
</th>
))}
<th className="w-14 px-1 py-1.5 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{DUMMY_DEPTHS.map((depth, rowIdx) => (
<tr
key={rowIdx}
className={cn(
"border-t transition-colors",
rowIdx === 0 && "bg-accent/20",
)}
>
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5" style={{ paddingLeft: `${depth * 10}px` }}>
{depth === 0 ? (
<ChevronDown className="h-3 w-3 opacity-50" />
) : (
<span className="text-primary/40 text-[10px]"></span>
)}
</div>
</td>
<td className="text-muted-foreground px-0.5 py-1 text-center">
{rowIdx + 1}
</td>
{visibleColumns.map((col: any) => (
<td key={col.key} className="px-1.5 py-0.5">
{col.isSourceDisplay ? (
<span className="truncate text-blue-600">
{getDummyValue(col, rowIdx) || col.title}
</span>
) : col.editable !== false ? (
<div className="h-5 rounded border bg-background px-1.5 text-[10px] leading-5">
{getDummyValue(col, rowIdx)}
</div>
) : (
<span className="text-muted-foreground">
{getDummyValue(col, rowIdx)}
</span>
)}
</td>
))}
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5">
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<Plus className="h-3 w-3" />
</div>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<X className="h-3 w-3" />
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
// ─── 메인 렌더링 ───
return (
<div className="space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">
{hasChanges && <span className="ml-1.5 text-[10px] text-amber-500">()</span>}
</h4>
<div className="flex gap-1.5">
<Button
onClick={handleAddRoot}
variant="outline"
size="sm"
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
onClick={handleSaveAll}
disabled={saving || !hasChanges}
size="sm"
className="h-7 text-xs"
>
{saving ? "저장중..." : "저장"}
</Button>
</div>
</div>
{/* 트리 목록 */}
<div className="max-h-[400px] space-y-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : treeData.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-8">
<Package className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
.
</p>
<p className="text-muted-foreground text-xs">
&quot;&quot; .
</p>
</div>
) : (
renderNodes(treeData, 0)
)}
</div>
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect}
companyCode={companyCode}
existingItemIds={existingItemIds}
/>
</div>
);
}
export default BomItemEditorComponent;