Files
vexplor/frontend/components/v2/V2Hierarchy.tsx
kjs 43523a0bba feat: Implement NOT NULL validation for form fields based on table metadata
- 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.
2026-03-10 14:16:02 +09:00

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;