jskim-node #393

Merged
kjs merged 11 commits from jskim-node into main 2026-02-24 15:31:32 +09:00
3 changed files with 331 additions and 190 deletions
Showing only changes of commit 72068d003a - Show all commits

View File

@ -1721,18 +1721,28 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
} }
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*)) // V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴
// layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음
let v2Layout = await queryOne<{ layout_data: any }>( let v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 `SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`, WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, companyCode], [screenId, companyCode],
); );
// 회사별 레이아웃 없으면 공통(*) 조회 // 최고관리자(*): 화면 정의의 company_code로 재조회
if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") {
v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, existingScreen.company_code],
);
}
// 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회
if (!v2Layout && companyCode !== "*") { if (!v2Layout && companyCode !== "*") {
v2Layout = await queryOne<{ layout_data: any }>( v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 `SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`, WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`,
[screenId], [screenId],
); );
} }
@ -5302,7 +5312,22 @@ export class ScreenManagementService {
[screenId, companyCode, layerId], [screenId, companyCode, layerId],
); );
// 회사별 레이어가 없으면 공통(*) 조회 // 최고관리자(*): 화면 정의의 company_code로 재조회
if (!layout && companyCode === "*") {
const screenDef = await queryOne<{ company_code: string }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screenDef && screenDef.company_code && screenDef.company_code !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, screenDef.company_code, layerId],
);
}
}
// 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회
if (!layout && companyCode !== "*") { if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2

View File

@ -376,12 +376,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try { try {
setLoading(true); setLoading(true);
// 화면 정보와 레이아웃 데이터 로딩 // 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선)
const [screenInfo, layoutData] = await Promise.all([ const [screenInfo, v2LayoutData] = await Promise.all([
screenApi.getScreen(screenId), screenApi.getScreen(screenId),
screenApi.getLayout(screenId), screenApi.getLayoutV2(screenId),
]); ]);
// V2 → Legacy 변환 (ScreenModal과 동일한 패턴)
let layoutData: any = null;
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
layoutData = convertV2ToLegacy(v2LayoutData);
if (layoutData) {
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
}
}
// V2 없으면 기존 API fallback
if (!layoutData) {
layoutData = await screenApi.getLayout(screenId);
}
if (screenInfo && layoutData) { if (screenInfo && layoutData) {
const components = layoutData.components || []; const components = layoutData.components || [];

View File

@ -35,21 +35,23 @@ import { apiClient } from "@/lib/api/client";
interface BomItemNode { interface BomItemNode {
tempId: string; tempId: string;
id?: string; id?: string;
bom_id?: string;
parent_detail_id: string | null; parent_detail_id: string | null;
seq_no: number; seq_no: number;
level: number; level: number;
child_item_id: string;
child_item_code: string;
child_item_name: string;
child_item_type: string;
quantity: string;
unit: string;
loss_rate: string;
remark: string;
children: BomItemNode[]; children: BomItemNode[];
_isNew?: boolean; _isNew?: boolean;
_isDeleted?: 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 { interface ItemInfo {
@ -211,13 +213,16 @@ function ItemSearchModal({
); );
} }
// ─── 트리 노드 행 렌더링 ─── // ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ───
interface TreeNodeRowProps { interface TreeNodeRowProps {
node: BomItemNode; node: BomItemNode;
depth: number; depth: number;
expanded: boolean; expanded: boolean;
hasChildren: boolean; hasChildren: boolean;
columns: BomColumnConfig[];
categoryOptionsMap: Record<string, { value: string; label: string }[]>;
mainTableName?: string;
onToggle: () => void; onToggle: () => void;
onFieldChange: (tempId: string, field: string, value: string) => void; onFieldChange: (tempId: string, field: string, value: string) => void;
onDelete: (tempId: string) => void; onDelete: (tempId: string) => void;
@ -229,12 +234,84 @@ function TreeNodeRow({
depth, depth,
expanded, expanded,
hasChildren, hasChildren,
columns,
categoryOptionsMap,
mainTableName,
onToggle, onToggle,
onFieldChange, onFieldChange,
onDelete, onDelete,
onAddChild, onAddChild,
}: TreeNodeRowProps) { }: TreeNodeRowProps) {
const indentPx = depth * 32; 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 ( return (
<div <div
@ -245,10 +322,8 @@ function TreeNodeRow({
)} )}
style={{ marginLeft: `${indentPx}px` }} style={{ marginLeft: `${indentPx}px` }}
> >
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" /> <GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
{/* 펼침/접기 */}
<button <button
onClick={onToggle} onClick={onToggle}
className={cn( className={cn(
@ -266,57 +341,30 @@ function TreeNodeRow({
))} ))}
</button> </button>
{/* 순번 */}
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium"> <span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
{node.seq_no} {node.seq_no}
</span> </span>
{/* 품목코드 */}
<span className="w-24 shrink-0 truncate font-mono text-xs font-medium">
{node.child_item_code || "-"}
</span>
{/* 품목명 */}
<span className="min-w-[80px] flex-1 truncate text-xs">
{node.child_item_name || "-"}
</span>
{/* 레벨 뱃지 */}
{node.level > 0 && ( {node.level > 0 && (
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold"> <span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
L{node.level} L{node.level}
</span> </span>
)} )}
{/* 수량 */} {/* config.columns 기반 동적 셀 렌더링 */}
<Input {visibleColumns.map((col) => (
value={node.quantity} <div
onChange={(e) => key={col.key}
onFieldChange(node.tempId, "quantity", e.target.value) className={cn(
} "shrink-0",
className="h-7 w-16 shrink-0 text-center text-xs" col.isSourceDisplay ? "min-w-[60px] flex-1" : "min-w-[50px]",
placeholder="수량" )}
/> style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{renderCell(col)}
</div>
))}
{/* 품목구분 셀렉트 */}
<Select
value={node.child_item_type || ""}
onValueChange={(val) =>
onFieldChange(node.tempId, "child_item_type", val)
}
>
<SelectTrigger className="h-7 w-20 shrink-0 text-xs">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
<SelectItem value="assembly"></SelectItem>
<SelectItem value="process"></SelectItem>
<SelectItem value="purchase"></SelectItem>
<SelectItem value="outsource"></SelectItem>
</SelectContent>
</Select>
{/* 하위 추가 버튼 */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -327,7 +375,6 @@ function TreeNodeRow({
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
{/* 삭제 버튼 */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -356,9 +403,16 @@ export function BomItemEditorComponent({
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set()); const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [itemSearchOpen, setItemSearchOpen] = useState(false); const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [addTargetParentId, setAddTargetParentId] = useState<string | null>( const [addTargetParentId, setAddTargetParentId] = useState<string | null>(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 || "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 결정 // BOM ID 결정
const bomId = useMemo(() => { const bomId = useMemo(() => {
@ -368,6 +422,37 @@ export function BomItemEditorComponent({
return null; return null;
}, [propBomId, formData, selectedRowsData]); }, [propBomId, formData, selectedRowsData]);
// ─── 카테고리 옵션 로드 (리피터 방식) ───
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}`;
if (categoryOptionsMap[categoryRef]) 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 loadBomDetails = useCallback( const loadBomDetails = useCallback(
@ -375,10 +460,10 @@ export function BomItemEditorComponent({
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
try { try {
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", { const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
page: 1, page: 1,
size: 500, size: 500,
search: { bom_id: id }, search: { [fkColumn]: id },
sortBy: "seq_no", sortBy: "seq_no",
sortOrder: "asc", sortOrder: "asc",
enableEntityJoin: true, enableEntityJoin: true,
@ -388,7 +473,6 @@ export function BomItemEditorComponent({
const tree = buildTree(rows); const tree = buildTree(rows);
setTreeData(tree); setTreeData(tree);
// 1레벨 기본 펼침
const firstLevelIds = new Set<string>( const firstLevelIds = new Set<string>(
tree.map((n) => n.tempId || n.id || ""), tree.map((n) => n.tempId || n.id || ""),
); );
@ -399,7 +483,7 @@ export function BomItemEditorComponent({
setLoading(false); setLoading(false);
} }
}, },
[], [mainTableName, fkColumn],
); );
useEffect(() => { useEffect(() => {
@ -408,7 +492,7 @@ export function BomItemEditorComponent({
} }
}, [bomId, isDesignMode, loadBomDetails]); }, [bomId, isDesignMode, loadBomDetails]);
// ─── 트리 빌드 ─── // ─── 트리 빌드 (동적 데이터) ───
const buildTree = (flatData: any[]): BomItemNode[] => { const buildTree = (flatData: any[]): BomItemNode[] => {
const nodeMap = new Map<string, BomItemNode>(); const nodeMap = new Map<string, BomItemNode>();
@ -419,19 +503,11 @@ export function BomItemEditorComponent({
nodeMap.set(item.id || tempId, { nodeMap.set(item.id || tempId, {
tempId, tempId,
id: item.id, id: item.id,
bom_id: item.bom_id, parent_detail_id: item[parentKeyColumn] || null,
parent_detail_id: item.parent_detail_id || null,
seq_no: Number(item.seq_no) || 0, seq_no: Number(item.seq_no) || 0,
level: Number(item.level) || 0, level: Number(item.level) || 0,
child_item_id: item.child_item_id || "",
child_item_code: item.child_item_code || "",
child_item_name: item.child_item_name || "",
child_item_type: item.child_item_type || "",
quantity: item.quantity || "1",
unit: item.unit || "EA",
loss_rate: item.loss_rate || "0",
remark: item.remark || "",
children: [], children: [],
data: { ...item },
}); });
}); });
@ -440,14 +516,14 @@ export function BomItemEditorComponent({
const node = nodeMap.get(nodeId); const node = nodeMap.get(nodeId);
if (!node) return; if (!node) return;
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) { const parentId = item[parentKeyColumn];
nodeMap.get(item.parent_detail_id)!.children.push(node); if (parentId && nodeMap.has(parentId)) {
nodeMap.get(parentId)!.children.push(node);
} else { } else {
roots.push(node); roots.push(node);
} }
}); });
// 순번 정렬
const sortChildren = (nodes: BomItemNode[]) => { const sortChildren = (nodes: BomItemNode[]) => {
nodes.sort((a, b) => a.seq_no - b.seq_no); nodes.sort((a, b) => a.seq_no - b.seq_no);
nodes.forEach((n) => sortChildren(n.children)); nodes.forEach((n) => sortChildren(n.children));
@ -468,22 +544,14 @@ export function BomItemEditorComponent({
) => { ) => {
items.forEach((node, idx) => { items.forEach((node, idx) => {
result.push({ result.push({
...node.data,
id: node.id, id: node.id,
tempId: node.tempId, tempId: node.tempId,
bom_id: node.bom_id, [parentKeyColumn]: parentId,
parent_detail_id: parentId,
seq_no: String(idx + 1), seq_no: String(idx + 1),
level: String(level), level: String(level),
child_item_id: node.child_item_id,
child_item_code: node.child_item_code,
child_item_name: node.child_item_name,
child_item_type: node.child_item_type,
quantity: node.quantity,
unit: node.unit,
loss_rate: node.loss_rate,
remark: node.remark,
_isNew: node._isNew, _isNew: node._isNew,
_targetTable: "bom_detail", _targetTable: mainTableName,
}); });
if (node.children.length > 0) { if (node.children.length > 0) {
traverse(node.children, node.id || node.tempId, level + 1); traverse(node.children, node.id || node.tempId, level + 1);
@ -492,7 +560,7 @@ export function BomItemEditorComponent({
}; };
traverse(nodes, null, 0); traverse(nodes, null, 0);
return result; return result;
}, []); }, [parentKeyColumn, mainTableName]);
// 트리 변경 시 부모에게 알림 // 트리 변경 시 부모에게 알림
const notifyChange = useCallback( const notifyChange = useCallback(
@ -526,12 +594,12 @@ export function BomItemEditorComponent({
return result; return result;
}; };
// 필드 변경 // 필드 변경 (data Record 내부 업데이트)
const handleFieldChange = useCallback( const handleFieldChange = useCallback(
(tempId: string, field: string, value: string) => { (tempId: string, field: string, value: string) => {
const newTree = findAndUpdate(treeData, tempId, (node) => ({ const newTree = findAndUpdate(treeData, tempId, (node) => ({
...node, ...node,
[field]: value, data: { ...node.data, [field]: value },
})); }));
notifyChange(newTree); notifyChange(newTree);
}, },
@ -559,35 +627,44 @@ export function BomItemEditorComponent({
setItemSearchOpen(true); setItemSearchOpen(true);
}, []); }, []);
// 품목 선택 후 추가 // 품목 선택 후 추가 (동적 데이터)
const handleItemSelect = useCallback( const handleItemSelect = useCallback(
(item: ItemInfo) => { (item: ItemInfo) => {
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
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 = { const newNode: BomItemNode = {
tempId: generateTempId(), tempId: generateTempId(),
parent_detail_id: null, parent_detail_id: null,
seq_no: 0, seq_no: 0,
level: 0, level: 0,
child_item_id: item.id,
child_item_code: item.item_number || "",
child_item_name: item.item_name || "",
child_item_type: item.type || "",
quantity: "1",
unit: item.unit || "EA",
loss_rate: "0",
remark: "",
children: [], children: [],
_isNew: true, _isNew: true,
data: {
...sourceData,
quantity: "1",
loss_rate: "0",
remark: "",
},
}; };
let newTree: BomItemNode[]; let newTree: BomItemNode[];
if (addTargetParentId === null) { if (addTargetParentId === null) {
// 루트에 추가
newNode.seq_no = treeData.length + 1; newNode.seq_no = treeData.length + 1;
newNode.level = 0; newNode.level = 0;
newTree = [...treeData, newNode]; newTree = [...treeData, newNode];
} else { } else {
// 특정 노드 하위에 추가
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => { newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId; newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1; newNode.seq_no = parent.children.length + 1;
@ -597,13 +674,12 @@ export function BomItemEditorComponent({
children: [...parent.children, newNode], children: [...parent.children, newNode],
}; };
}); });
// 부모 노드 펼침
setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
} }
notifyChange(newTree); notifyChange(newTree);
}, },
[addTargetParentId, treeData, notifyChange], [addTargetParentId, treeData, notifyChange, cfg],
); );
// 펼침/접기 토글 // 펼침/접기 토글
@ -628,6 +704,9 @@ export function BomItemEditorComponent({
depth={depth} depth={depth}
expanded={isExpanded} expanded={isExpanded}
hasChildren={node.children.length > 0} hasChildren={node.children.length > 0}
columns={visibleColumns}
categoryOptionsMap={categoryOptionsMap}
mainTableName={mainTableName}
onToggle={() => toggleExpand(node.tempId)} onToggle={() => toggleExpand(node.tempId)}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
onDelete={handleDelete} onDelete={handleDelete}
@ -648,9 +727,6 @@ export function BomItemEditorComponent({
const hasConfig = const hasConfig =
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0); cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
const sourceColumns = (cfg.columns || []).filter((c: any) => c.isSourceDisplay);
const inputColumns = (cfg.columns || []).filter((c: any) => !c.isSourceDisplay);
if (!hasConfig) { if (!hasConfig) {
return ( return (
<div className="rounded-md border border-dashed p-6 text-center"> <div className="rounded-md border border-dashed p-6 text-center">
@ -659,23 +735,36 @@ export function BomItemEditorComponent({
BOM BOM
</p> </p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
</p> </p>
</div> </div>
); );
} }
const dummyRows = [ const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
{ depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" },
{ depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" }, const DUMMY_DATA: Record<string, string[]> = {
{ depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" }, item_name: ["본체 조립", "프레임", "커버", "전장 조립", "PCB 보드"],
{ depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" }, item_number: ["ASM-001", "PRT-010", "PRT-011", "ASM-002", "PRT-020"],
{ depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" }, 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
{/* 헤더 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4> <h4 className="text-sm font-semibold"> </h4>
<Button size="sm" className="h-7 text-xs" disabled> <Button size="sm" className="h-7 text-xs" disabled>
@ -701,78 +790,91 @@ export function BomItemEditorComponent({
: {cfg.parentKeyColumn} : {cfg.parentKeyColumn}
</span> </span>
)} )}
{inputColumns.length > 0 && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">
{inputColumns.length}
</span>
)}
{sourceColumns.length > 0 && (
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-600">
{sourceColumns.length}
</span>
)}
</div> </div>
{/* 더미 트리 미리보기 */} {/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
<div className="space-y-0.5 rounded-md border p-1.5"> <div className="overflow-hidden rounded-md border">
{dummyRows.map((row, i) => ( {visibleColumns.length === 0 ? (
<div <div className="flex flex-col items-center justify-center py-6">
key={i} <Package className="text-muted-foreground mb-1.5 h-6 w-6" />
className={cn( <p className="text-muted-foreground text-xs">
"flex items-center gap-1.5 rounded px-1.5 py-1",
row.depth > 0 && "border-l-2 border-l-primary/20", </p>
i === 0 && "bg-accent/30",
)}
style={{ marginLeft: `${row.depth * 20}px` }}
>
<GripVertical className="text-muted-foreground h-3 w-3 shrink-0 opacity-40" />
{row.depth === 0 ? (
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
) : (
<span className="w-3" />
)}
<span className="text-muted-foreground w-4 text-center text-[10px]">
{i + 1}
</span>
<span className="w-16 shrink-0 truncate font-mono text-[10px] font-medium">
{row.code}
</span>
<span className="min-w-[50px] flex-1 truncate text-[10px]">
{row.name}
</span>
{/* 소스 표시 컬럼 미리보기 */}
{sourceColumns.slice(0, 2).map((col: any) => (
<span
key={col.key}
className="w-12 shrink-0 truncate text-center text-[10px] text-blue-500"
>
{col.title}
</span>
))}
{/* 입력 컬럼 미리보기 */}
{inputColumns.slice(0, 2).map((col: any) => (
<div
key={col.key}
className="h-5 w-12 shrink-0 rounded border bg-background text-center text-[10px] leading-5"
>
{col.key === "quantity" || col.title === "수량"
? row.qty
: ""}
</div>
))}
<div className="flex shrink-0 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>
</div> </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>
</div> </div>
); );