1380 lines
46 KiB
TypeScript
1380 lines
46 KiB
TypeScript
"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 collect = (nodes: BomItemNode[]) => {
|
||
for (const n of nodes) {
|
||
const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"];
|
||
if (fk) ids.add(fk);
|
||
collect(n.children);
|
||
}
|
||
};
|
||
collect(treeData);
|
||
return ids;
|
||
}, [treeData, cfg]);
|
||
|
||
// 루트 품목 추가 시작
|
||
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">
|
||
"품목추가" 버튼을 눌러 추가하세요.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
renderNodes(treeData, 0)
|
||
)}
|
||
</div>
|
||
|
||
{/* 품목 검색 모달 */}
|
||
<ItemSearchModal
|
||
open={itemSearchOpen}
|
||
onClose={() => setItemSearchOpen(false)}
|
||
onSelect={handleItemSelect}
|
||
companyCode={companyCode}
|
||
existingItemIds={existingItemIds}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default BomItemEditorComponent;
|