ERP-node/frontend/components/report/designer/modals/SettingsModalShell.tsx

193 lines
6.4 KiB
TypeScript
Raw Normal View History

"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 };
}