193 lines
6.4 KiB
TypeScript
193 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* SettingsModalShell.tsx — 컴포넌트 설정 모달 공통 Shell
|
|
*
|
|
* [역할]
|
|
* 모든 컴포넌트 설정 모달이 동일한 형식을 유지하도록 하는 재사용 모듈.
|
|
* 파란색 그라데이션 헤더 + 탭(헤더 하단 인라인) + 스크롤 콘텐츠 + 하단 Footer 구조.
|
|
*/
|
|
|
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { X, Save, AlertTriangle, CheckCircle } from "lucide-react";
|
|
|
|
const DND_ROOT_ID = "report-designer-dnd-root";
|
|
|
|
export interface ModalTabDef {
|
|
key: string;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
}
|
|
|
|
export interface ModalAlert {
|
|
type: "success" | "warning";
|
|
message: string;
|
|
}
|
|
|
|
interface SettingsModalShellProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
title: string;
|
|
icon?: React.ReactNode;
|
|
tabs: ModalTabDef[];
|
|
activeTab: string;
|
|
onTabChange: (key: string) => void;
|
|
onSave: () => void;
|
|
onClose: () => void;
|
|
alert?: ModalAlert | null;
|
|
children: React.ReactNode;
|
|
maxWidth?: string;
|
|
saveLabel?: string;
|
|
}
|
|
|
|
export function SettingsModalShell({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
icon,
|
|
tabs,
|
|
activeTab,
|
|
onTabChange,
|
|
onSave,
|
|
onClose,
|
|
alert,
|
|
children,
|
|
maxWidth = "max-w-4xl",
|
|
saveLabel = "저장",
|
|
}: SettingsModalShellProps) {
|
|
const [dndContainer, setDndContainer] = useState<HTMLElement | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (open) {
|
|
const el = document.getElementById(DND_ROOT_ID);
|
|
setDndContainer(el ?? undefined);
|
|
}
|
|
}, [open]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
container={dndContainer}
|
|
className={`flex h-[92vh] ${maxWidth} flex-col gap-0 overflow-hidden rounded-xl p-0 [&>button]:hidden`}
|
|
>
|
|
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
<DialogDescription className="sr-only">{title} 설정 모달</DialogDescription>
|
|
|
|
{/* Gradient Header */}
|
|
<div className="shrink-0 rounded-t-xl bg-linear-to-r from-blue-600 to-indigo-600">
|
|
{/* Title row */}
|
|
<div className="flex items-center gap-3 px-6 pt-4 pb-3">
|
|
{icon && <span className="shrink-0 text-white/90">{icon}</span>}
|
|
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-white">
|
|
{title}
|
|
</h2>
|
|
<button
|
|
className="ml-auto shrink-0 rounded-md p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
|
onClick={onClose}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">닫기</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tabs row */}
|
|
{tabs.length > 0 && (
|
|
<div className="flex items-end gap-1 px-6 pb-0">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => onTabChange(tab.key)}
|
|
className={`flex items-center gap-1.5 rounded-t-lg px-4 py-2 text-xs font-medium transition-all ${
|
|
activeTab === tab.key
|
|
? "bg-white text-blue-700 shadow-sm"
|
|
: "text-white/80 hover:bg-white/15 hover:text-white"
|
|
}`}
|
|
>
|
|
{tab.icon && <span className="shrink-0">{tab.icon}</span>}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 토스트 알림 */}
|
|
{alert && (
|
|
<div className="pointer-events-none absolute right-4 top-2 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
<div
|
|
className={`pointer-events-auto flex items-start gap-3 rounded-lg border bg-white px-4 py-3 shadow-lg ${
|
|
alert.type === "success" ? "border-gray-200" : "border-amber-200"
|
|
}`}
|
|
>
|
|
{alert.type === "success" ? (
|
|
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-500">
|
|
<CheckCircle className="h-3.5 w-3.5 text-white" />
|
|
</div>
|
|
) : (
|
|
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500">
|
|
<AlertTriangle className="h-3.5 w-3.5 text-white" />
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-semibold text-gray-900">
|
|
{alert.type === "success" ? "성공" : "알림"}
|
|
</span>
|
|
<span className="text-sm text-gray-500">{alert.message}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Area */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
|
{children}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="shrink-0 rounded-b-xl border-t bg-white px-6 py-3 flex items-center justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={onSave}
|
|
className="h-8 px-4 text-xs bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
|
|
>
|
|
<Save className="h-3.5 w-3.5" />
|
|
{saveLabel}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onClose}
|
|
className="h-8 px-4 text-xs"
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── 토스트 알림 훅 ──────────────────────────────────────────────────────────
|
|
|
|
export function useModalAlert() {
|
|
const [alert, setAlert] = useState<ModalAlert | null>(null);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const showAlert = useCallback((type: ModalAlert["type"], message: string) => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setAlert({ type, message });
|
|
timerRef.current = setTimeout(() => setAlert(null), 3000);
|
|
}, []);
|
|
|
|
const clearAlert = useCallback(() => setAlert(null), []);
|
|
|
|
return { alert, showAlert, clearAlert };
|
|
}
|