231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ComponentSettingsModal.tsx
|
|
*
|
|
* 인캔버스 설정 모달 — 기능 설정 / 표시 조건 / 미리보기 3탭 구조.
|
|
* SettingsModalShell 모듈을 사용하여 모든 컴포넌트가 동일한 모달 형식을 유지.
|
|
* card 타입은 Draft 기반 저장/취소 지원.
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { Eye as EyeIcon, Sliders, Layers } from "lucide-react";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { SettingsModalShell, useModalAlert } from "./SettingsModalShell";
|
|
import type { ModalTabDef } from "./SettingsModalShell";
|
|
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
|
|
|
import { ConditionalSettingsTab } from "./ConditionalSettingsTab";
|
|
import { ComponentPreviewPanel } from "./ComponentPreviewPanel";
|
|
|
|
import { TextProperties } from "../properties/TextProperties";
|
|
import { ImageProperties } from "../properties/ImageProperties";
|
|
import { TableProperties } from "../properties/TableProperties";
|
|
import { CardProperties } from "../properties/CardProperties";
|
|
import { CalculationProperties } from "../properties/CalculationProperties";
|
|
import { BarcodeProperties } from "../properties/BarcodeProperties";
|
|
import { CheckboxProperties } from "../properties/CheckboxProperties";
|
|
import { SignatureProperties } from "../properties/SignatureProperties";
|
|
import { PageNumberProperties } from "../properties/PageNumberProperties";
|
|
import type { ComponentConfig } from "@/types/report";
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
text: "텍스트",
|
|
label: "레이블",
|
|
table: "테이블",
|
|
image: "이미지",
|
|
divider: "구분선",
|
|
signature: "서명",
|
|
stamp: "도장",
|
|
pageNumber: "페이지 번호",
|
|
card: "카드",
|
|
calculation: "계산",
|
|
barcode: "바코드",
|
|
checkbox: "체크박스",
|
|
};
|
|
|
|
interface DataTabProps {
|
|
component: ComponentConfig;
|
|
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
|
}
|
|
|
|
function DataTab({ component, onConfigChange }: DataTabProps) {
|
|
switch (component.type) {
|
|
case "text":
|
|
case "label":
|
|
return <TextProperties component={component} section="data" />;
|
|
case "table":
|
|
return <TableProperties component={component} section="data" />;
|
|
case "image":
|
|
return <ImageProperties component={component} section="data" />;
|
|
case "signature":
|
|
case "stamp":
|
|
return <SignatureProperties component={component} section="data" />;
|
|
case "pageNumber":
|
|
return <PageNumberProperties component={component} />;
|
|
case "card":
|
|
return (
|
|
<CardProperties
|
|
component={component}
|
|
section="data"
|
|
onConfigChange={onConfigChange}
|
|
/>
|
|
);
|
|
case "calculation":
|
|
return <CalculationProperties component={component} section="data" />;
|
|
case "barcode":
|
|
return <BarcodeProperties component={component} section="data" />;
|
|
case "checkbox":
|
|
return <CheckboxProperties component={component} section="data" />;
|
|
default:
|
|
return <p className="p-4 text-sm text-gray-500">이 타입은 추가 기능 설정이 없습니다.</p>;
|
|
}
|
|
}
|
|
|
|
const TYPES_WITH_DATA_TAB = new Set([
|
|
"text",
|
|
"label",
|
|
"table",
|
|
"image",
|
|
"signature",
|
|
"stamp",
|
|
"pageNumber",
|
|
"card",
|
|
"calculation",
|
|
"barcode",
|
|
"checkbox",
|
|
]);
|
|
|
|
export function ComponentSettingsModal() {
|
|
const { componentModalTargetId, closeComponentModal, components, updateComponent } = useReportDesigner();
|
|
|
|
const component = components.find((c) => c.id === componentModalTargetId) ?? null;
|
|
const isDivider = component?.type === "divider";
|
|
const isOpen = componentModalTargetId !== null && component !== null && !isDivider;
|
|
|
|
const [activeTab, setActiveTab] = useState("content");
|
|
const [localDraft, setLocalDraft] = useState<ComponentConfig | null>(null);
|
|
const { alert, clearAlert } = useModalAlert();
|
|
const initialSnapshotRef = useRef<string>("");
|
|
|
|
useEffect(() => {
|
|
if (component) {
|
|
setLocalDraft(component);
|
|
initialSnapshotRef.current = JSON.stringify(component);
|
|
clearAlert();
|
|
const hasData = TYPES_WITH_DATA_TAB.has(component.type);
|
|
setActiveTab(hasData ? "content" : "preview");
|
|
}
|
|
}, [componentModalTargetId, clearAlert]);
|
|
|
|
const hasChanges = useCallback(() => {
|
|
if (component?.type === "card") {
|
|
if (!localDraft) return false;
|
|
return JSON.stringify(localDraft) !== initialSnapshotRef.current;
|
|
}
|
|
if (!component) return false;
|
|
return JSON.stringify(component) !== initialSnapshotRef.current;
|
|
}, [localDraft, component]);
|
|
|
|
const isSavingRef = useRef(false);
|
|
|
|
const guard = useUnsavedChangesGuard({
|
|
hasChanges,
|
|
onClose: () => {
|
|
if (!isSavingRef.current && initialSnapshotRef.current && component) {
|
|
const original = JSON.parse(initialSnapshotRef.current) as ComponentConfig;
|
|
updateComponent(component.id, original);
|
|
}
|
|
isSavingRef.current = false;
|
|
setLocalDraft(null);
|
|
clearAlert();
|
|
closeComponentModal();
|
|
},
|
|
});
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (component?.type === "card" && localDraft) {
|
|
updateComponent(localDraft.id, localDraft);
|
|
}
|
|
isSavingRef.current = true;
|
|
initialSnapshotRef.current = component?.type === "card" && localDraft
|
|
? JSON.stringify(localDraft)
|
|
: JSON.stringify(component);
|
|
guard.doClose();
|
|
}, [component, localDraft, updateComponent, guard]);
|
|
|
|
const handleDraftChange = useCallback(
|
|
(updates: Partial<ComponentConfig>) => {
|
|
setLocalDraft((prev) => {
|
|
if (!prev && component) return { ...component, ...updates };
|
|
if (!prev) return null;
|
|
return { ...prev, ...updates };
|
|
});
|
|
},
|
|
[component],
|
|
);
|
|
|
|
if (!component) return null;
|
|
|
|
const hasDataTab = TYPES_WITH_DATA_TAB.has(component.type);
|
|
const typeLabel = TYPE_LABELS[component.type] ?? component.type;
|
|
const isCard = component.type === "card";
|
|
const isTable = component.type === "table";
|
|
const isText = component.type === "text" || component.type === "label";
|
|
const isImage = component.type === "image";
|
|
const hideConditionTab = isText || isImage || isDivider;
|
|
const hasInternalConditionTab = isCard || isTable || hideConditionTab;
|
|
const displayComponent = isCard && localDraft ? localDraft : component;
|
|
|
|
const tabs: ModalTabDef[] = [
|
|
hasDataTab && {
|
|
key: "content",
|
|
icon: <Sliders className="h-4 w-4" />,
|
|
label: "기능 설정",
|
|
},
|
|
!hasInternalConditionTab && {
|
|
key: "conditional",
|
|
icon: <EyeIcon className="h-4 w-4" />,
|
|
label: "표시 조건",
|
|
},
|
|
{
|
|
key: "preview",
|
|
icon: <EyeIcon className="h-4 w-4" />,
|
|
label: "미리보기",
|
|
},
|
|
].filter(Boolean) as ModalTabDef[];
|
|
|
|
return (
|
|
<>
|
|
<SettingsModalShell
|
|
open={isOpen}
|
|
onOpenChange={guard.handleOpenChange}
|
|
title={`${typeLabel} 설정`}
|
|
icon={<Layers className="h-5 w-5" />}
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
onSave={handleSave}
|
|
onClose={guard.tryClose}
|
|
alert={alert}
|
|
>
|
|
{activeTab === "content" && hasDataTab && (
|
|
<DataTab
|
|
component={displayComponent}
|
|
onConfigChange={isCard ? handleDraftChange : undefined}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === "conditional" && !hasInternalConditionTab && (
|
|
<ConditionalSettingsTab component={component} />
|
|
)}
|
|
|
|
{activeTab === "preview" && (
|
|
<ComponentPreviewPanel component={displayComponent} />
|
|
)}
|
|
</SettingsModalShell>
|
|
<UnsavedChangesDialog guard={guard} />
|
|
</>
|
|
);
|
|
}
|