- Added a new function `isColumnRequired` to determine if a column is required based on its NOT NULL status from the table schema. - Updated the `SaveModal` and `InteractiveScreenViewer` components to incorporate this validation, ensuring that required fields are accurately assessed during form submission. - Enhanced the `DynamicComponentRenderer` to reflect the NOT NULL requirement in the component's required state. - Improved user feedback by marking required fields with an asterisk based on both manual settings and database constraints. These changes enhance the form validation process, ensuring that users are prompted for all necessary information based on the underlying data structure.
509 lines
14 KiB
TypeScript
509 lines
14 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2Hierarchy
|
|
*
|
|
* 통합 계층 구조 컴포넌트
|
|
* - 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 { V2HierarchyProps, HierarchyNode } from "@/types/v2-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";
|
|
|
|
/**
|
|
* 메인 V2Hierarchy 컴포넌트
|
|
*/
|
|
export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
|
|
(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;
|
|
|
|
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className="relative"
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
|
{showLabel && (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
position: "absolute",
|
|
top: `-${estimatedLabelHeight}px`,
|
|
left: 0,
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: style?.labelColor || "#64748b",
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
}}
|
|
className="text-sm font-medium whitespace-nowrap"
|
|
>
|
|
{label}{required && <span className="text-orange-500">*</span>}
|
|
</Label>
|
|
)}
|
|
<div className="h-full w-full">
|
|
{renderHierarchy()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
V2Hierarchy.displayName = "V2Hierarchy";
|
|
|
|
export default V2Hierarchy;
|
|
|