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

457 lines
12 KiB
TypeScript
Raw Normal View History

2025-12-19 15:44:38 +09:00
"use client";
/**
* UnifiedGroup
*
*
* - tabs:
* - accordion: 아코디언
* - section: 섹션
* - card-section: 카드
* - modal: 모달
* - form-modal:
*/
import React, { forwardRef, useState, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { UnifiedGroupProps, TabItem } from "@/types/unified-components";
import { ChevronDown, ChevronRight, X } from "lucide-react";
/**
*
*/
const TabsGroup = forwardRef<HTMLDivElement, {
tabs?: TabItem[];
activeTab?: string;
onTabChange?: (tabId: string) => void;
children?: React.ReactNode;
className?: string;
}>(({ tabs = [], activeTab, onTabChange, children, className }, ref) => {
const [internalActiveTab, setInternalActiveTab] = useState(activeTab || tabs[0]?.id || "");
const currentTab = activeTab || internalActiveTab;
const handleTabChange = useCallback((tabId: string) => {
setInternalActiveTab(tabId);
onTabChange?.(tabId);
}, [onTabChange]);
// 탭 정보가 있으면 탭 사용, 없으면 children 그대로 렌더링
if (tabs.length === 0) {
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
return (
<Tabs
ref={ref}
value={currentTab}
onValueChange={handleTabChange}
className={className}
>
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${tabs.length}, 1fr)` }}>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.title}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4">
{tab.content || children}
</TabsContent>
))}
</Tabs>
);
});
TabsGroup.displayName = "TabsGroup";
/**
*
*/
const AccordionGroup = forwardRef<HTMLDivElement, {
title?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, collapsible = true, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (!collapsible) {
return (
<div ref={ref} className={cn("border rounded-lg", className)}>
{title && (
<div className="p-4 border-b bg-muted/50">
<h3 className="font-medium">{title}</h3>
</div>
)}
<div className="p-4">{children}</div>
</div>
);
}
return (
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("border rounded-lg", className)}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/50">
<h3 className="font-medium">{title || "그룹"}</h3>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 pt-0 border-t">{children}</div>
</CollapsibleContent>
</Collapsible>
);
});
AccordionGroup.displayName = "AccordionGroup";
/**
*
*/
const SectionGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (collapsible) {
return (
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("space-y-2", className)}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer">
<div>
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pt-2">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
return (
<div ref={ref} className={cn("space-y-2", className)}>
{(title || description) && (
<div>
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
});
SectionGroup.displayName = "SectionGroup";
/**
*
*/
const CardSectionGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
collapsible?: boolean;
defaultExpanded?: boolean;
children?: React.ReactNode;
className?: string;
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
if (collapsible) {
return (
<Card ref={ref} className={className}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">{children}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
return (
<Card ref={ref} className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent className={title || description ? "pt-0" : ""}>{children}</CardContent>
</Card>
);
});
CardSectionGroup.displayName = "CardSectionGroup";
/**
*
*/
const ModalGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
modalSize?: "sm" | "md" | "lg" | "xl";
children?: React.ReactNode;
className?: string;
}>(({ title, description, open = false, onOpenChange, modalSize = "md", children, className }, ref) => {
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
{children}
</DialogContent>
</Dialog>
);
});
ModalGroup.displayName = "ModalGroup";
/**
*
*/
const FormModalGroup = forwardRef<HTMLDivElement, {
title?: string;
description?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
modalSize?: "sm" | "md" | "lg" | "xl";
onSubmit?: () => void;
onCancel?: () => void;
submitLabel?: string;
cancelLabel?: string;
children?: React.ReactNode;
className?: string;
}>(({
title,
description,
open = false,
onOpenChange,
modalSize = "md",
onSubmit,
onCancel,
submitLabel = "저장",
cancelLabel = "취소",
children,
className
}, ref) => {
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
const handleCancel = useCallback(() => {
onCancel?.();
onOpenChange?.(false);
}, [onCancel, onOpenChange]);
const handleSubmit = useCallback(() => {
onSubmit?.();
}, [onSubmit]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
<div className="py-4">{children}</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={handleCancel}>
{cancelLabel}
</Button>
<Button onClick={handleSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
FormModalGroup.displayName = "FormModalGroup";
/**
* UnifiedGroup
*/
export const UnifiedGroup = forwardRef<HTMLDivElement, UnifiedGroupProps>(
(props, ref) => {
const {
id,
style,
size,
config: configProp,
children,
open,
onOpenChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "section" as const, tabs: [] };
// 타입별 그룹 렌더링
const renderGroup = () => {
const groupType = config.type || "section";
switch (groupType) {
case "tabs":
return (
<TabsGroup
tabs={config.tabs}
activeTab={config.activeTab}
>
{children}
</TabsGroup>
);
case "accordion":
return (
<AccordionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</AccordionGroup>
);
case "section":
return (
<SectionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</SectionGroup>
);
case "card-section":
return (
<CardSectionGroup
title={config.title}
collapsible={config.collapsible}
defaultExpanded={config.defaultExpanded}
>
{children}
</CardSectionGroup>
);
case "modal":
return (
<ModalGroup
title={config.title}
open={open}
onOpenChange={onOpenChange}
modalSize={config.modalSize}
>
{children}
</ModalGroup>
);
case "form-modal":
return (
<FormModalGroup
title={config.title}
open={open}
onOpenChange={onOpenChange}
modalSize={config.modalSize}
>
{children}
</FormModalGroup>
);
default:
return (
<SectionGroup title={config.title}>
{children}
</SectionGroup>
);
}
};
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
}}
>
{renderGroup()}
</div>
);
}
);
UnifiedGroup.displayName = "UnifiedGroup";
export default UnifiedGroup;