ERP-node/frontend/components/unified/UnifiedHierarchy.tsx

502 lines
14 KiB
TypeScript
Raw Normal View History

2025-12-19 15:44:38 +09:00
"use client";
/**
* UnifiedHierarchy
*
*
* - tree: 트리
* - org: 조직도
* - bom: BOM
* - cascading: 연쇄
*/
import React, { forwardRef, useCallback, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { UnifiedHierarchyProps, HierarchyNode } from "@/types/unified-components";
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
File,
Plus,
Minus,
GripVertical,
User,
Users,
Building
} from "lucide-react";
/**
*
*/
const TreeNode = forwardRef<HTMLDivElement, {
node: HierarchyNode;
level: number;
maxLevel?: number;
selectedNode?: HierarchyNode;
onSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
showQty?: boolean;
className?: string;
}>(({
node,
level,
maxLevel,
selectedNode,
onSelect,
editable,
draggable,
showQty,
className
}, ref) => {
const [isOpen, setIsOpen] = useState(level < 2);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedNode?.id === node.id;
// 최대 레벨 제한
if (maxLevel && level >= maxLevel) {
return null;
}
return (
<div ref={ref} className={className}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
"flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-primary/10 text-primary"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelect?.(node)}
>
{/* 드래그 핸들 */}
{draggable && (
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
)}
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-5 w-5 p-0">
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
) : (
<span className="w-5" />
)}
{/* 폴더/파일 아이콘 */}
{hasChildren ? (
isOpen ? (
<FolderOpen className="h-4 w-4 text-amber-500" />
) : (
<Folder className="h-4 w-4 text-amber-500" />
)
) : (
<File className="h-4 w-4 text-muted-foreground" />
)}
{/* 라벨 */}
<span className="flex-1 text-sm truncate">{node.label}</span>
{/* 수량 (BOM용) */}
{showQty && node.data?.qty && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
x{String(node.data.qty)}
</span>
)}
{/* 편집 버튼 */}
{editable && (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); }}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
</div>
{/* 자식 노드 */}
{hasChildren && (
<CollapsibleContent>
{node.children!.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
maxLevel={maxLevel}
selectedNode={selectedNode}
onSelect={onSelect}
editable={editable}
draggable={draggable}
showQty={showQty}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
</div>
);
});
TreeNode.displayName = "TreeNode";
/**
*
*/
const TreeView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
return (
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : (
data.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
maxLevel={maxLevel}
selectedNode={selectedNode}
onSelect={onNodeSelect}
editable={editable}
draggable={draggable}
/>
))
)}
</div>
);
});
TreeView.displayName = "TreeView";
/**
*
*/
const OrgView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
className?: string;
}>(({ data, selectedNode, onNodeSelect, className }, ref) => {
const renderOrgNode = (node: HierarchyNode, isRoot = false) => {
const isSelected = selectedNode?.id === node.id;
const hasChildren = node.children && node.children.length > 0;
return (
<div key={node.id} className="flex flex-col items-center">
{/* 노드 카드 */}
<div
className={cn(
"flex flex-col items-center p-3 border rounded-lg cursor-pointer hover:border-primary transition-colors",
isSelected && "border-primary bg-primary/5",
isRoot && "bg-primary/10"
)}
onClick={() => onNodeSelect?.(node)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center mb-2",
isRoot ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{isRoot ? (
<Building className="h-5 w-5" />
) : hasChildren ? (
<Users className="h-5 w-5" />
) : (
<User className="h-5 w-5" />
)}
</div>
<div className="text-center">
<div className="font-medium text-sm">{node.label}</div>
{node.data?.title && (
<div className="text-xs text-muted-foreground">{String(node.data.title)}</div>
)}
</div>
</div>
{/* 자식 노드 */}
{hasChildren && (
<>
{/* 연결선 */}
<div className="w-px h-4 bg-border" />
<div className="flex gap-4">
{node.children!.map((child, index) => (
<React.Fragment key={child.id}>
{index > 0 && <div className="w-4" />}
{renderOrgNode(child)}
</React.Fragment>
))}
</div>
</>
)}
</div>
);
};
return (
<div ref={ref} className={cn("overflow-auto p-4", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : (
<div className="flex flex-col items-center gap-4">
{data.map((node) => renderOrgNode(node, true))}
</div>
)}
</div>
);
});
OrgView.displayName = "OrgView";
/**
* BOM ( )
*/
const BomView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
return (
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
BOM
</div>
) : (
data.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
selectedNode={selectedNode}
onSelect={onNodeSelect}
editable={editable}
showQty={true}
/>
))
)}
</div>
);
});
BomView.displayName = "BomView";
/**
*
*/
const CascadingView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
const [selections, setSelections] = useState<string[]>([]);
// 레벨별 옵션 가져오기
const getOptionsForLevel = (level: number): HierarchyNode[] => {
if (level === 0) return data;
let currentNodes = data;
for (let i = 0; i < level; i++) {
const selectedId = selections[i];
if (!selectedId) return [];
const selectedNode = currentNodes.find((n) => n.id === selectedId);
if (!selectedNode?.children) return [];
currentNodes = selectedNode.children;
}
return currentNodes;
};
// 선택 핸들러
const handleSelect = (level: number, nodeId: string) => {
const newSelections = [...selections.slice(0, level), nodeId];
setSelections(newSelections);
// 마지막 선택된 노드 찾기
let node = data.find((n) => n.id === newSelections[0]);
for (let i = 1; i < newSelections.length; i++) {
node = node?.children?.find((n) => n.id === newSelections[i]);
}
if (node) {
onNodeSelect?.(node);
}
};
return (
<div ref={ref} className={cn("flex gap-2", className)}>
{Array.from({ length: maxLevel }, (_, level) => {
const options = getOptionsForLevel(level);
const isDisabled = level > 0 && !selections[level - 1];
return (
<Select
key={level}
value={selections[level] || ""}
onValueChange={(value) => handleSelect(level, value)}
disabled={isDisabled || options.length === 0}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder={`${level + 1}단계 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
})}
</div>
);
});
CascadingView.displayName = "CascadingView";
/**
* UnifiedHierarchy
*/
export const UnifiedHierarchy = forwardRef<HTMLDivElement, UnifiedHierarchyProps>(
(props, ref) => {
const {
id,
label,
required,
style,
size,
config: configProp,
data = [],
selectedNode,
onNodeSelect,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
// 뷰모드별 렌더링
const renderHierarchy = () => {
const viewMode = config.viewMode || config.type || "tree";
switch (viewMode) {
case "tree":
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
draggable={config.draggable}
maxLevel={config.maxLevel}
/>
);
case "org":
return (
<OrgView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
case "bom":
return (
<BomView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
/>
);
case "dropdown":
case "cascading":
return (
<CascadingView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
maxLevel={config.maxLevel}
/>
);
default:
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderHierarchy()}
</div>
</div>
);
}
);
UnifiedHierarchy.displayName = "UnifiedHierarchy";
export default UnifiedHierarchy;