502 lines
14 KiB
TypeScript
502 lines
14 KiB
TypeScript
"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;
|
|
|