457 lines
12 KiB
TypeScript
457 lines
12 KiB
TypeScript
"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;
|
|
|