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

601 lines
22 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
LayoutGrid,
Database,
Palette,
X,
Type,
CreditCard,
Minus,
Tag,
} from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import type {
CardLayoutConfig,
CardLayoutRow,
CardElement,
CardDataCellElement,
CardBadgeElement,
} from "@/types/report";
import { CardElementPalette } from "./CardElementPalette";
import { CardCanvasEditor } from "./CardCanvasEditor";
interface CardLayoutModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialConfig?: CardLayoutConfig;
onSave: (config: CardLayoutConfig) => void;
}
type TabType = "layout" | "binding" | "style";
interface TableInfo {
table_name: string;
table_type: string;
}
interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable: string;
}
const generateId = () =>
`row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const DEFAULT_CONFIG: CardLayoutConfig = {
tableName: "",
primaryKey: "",
rows: [
{
id: generateId(),
gridColumns: 4,
elements: [],
},
],
padding: "12px",
gap: "8px",
borderStyle: "solid",
borderColor: "#e5e7eb",
backgroundColor: "#ffffff",
headerTitleFontSize: 14,
headerTitleColor: "#1e40af",
labelFontSize: 13,
labelColor: "#374151",
valueFontSize: 13,
valueColor: "#000000",
dividerThickness: 1,
dividerColor: "#e5e7eb",
};
const getElementIcon = (type: string) => {
switch (type) {
case "header":
return <Type className="w-3 h-3" />;
case "dataCell":
return <CreditCard className="w-3 h-3" />;
case "divider":
return <Minus className="w-3 h-3" />;
case "badge":
return <Tag className="w-3 h-3" />;
default:
return null;
}
};
export function CardLayoutModal({
open,
onOpenChange,
initialConfig,
onSave,
}: CardLayoutModalProps) {
const [activeTab, setActiveTab] = useState<TabType>("layout");
const [config, setConfig] = useState<CardLayoutConfig>(
initialConfig || DEFAULT_CONFIG,
);
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const initialSnapshotRef = useRef<string>("");
const hasChanges = useCallback(() => {
return JSON.stringify(config) !== initialSnapshotRef.current;
}, [config]);
const guard = useUnsavedChangesGuard({
hasChanges,
onClose: () => onOpenChange(false),
});
useEffect(() => {
if (open) {
const initConfig = initialConfig || DEFAULT_CONFIG;
setConfig(initConfig);
initialSnapshotRef.current = JSON.stringify(initConfig);
setActiveTab("layout");
fetchTables();
}
}, [open, initialConfig]);
useEffect(() => {
if (config.tableName) {
fetchColumns(config.tableName);
} else {
setColumns([]);
}
}, [config.tableName]);
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await reportApi.getSchemaTableList();
if (response.success) {
setTables(response.data);
}
} catch (error) {
console.error("테이블 목록 조회 실패:", error);
} finally {
setLoadingTables(false);
}
};
const fetchColumns = async (tableName: string) => {
setLoadingColumns(true);
try {
const response = await reportApi.getSchemaTableColumns(tableName);
if (response.success) {
setColumns(response.data);
const pkCandidate = response.data.find(
(col) =>
col.column_name.endsWith("_id") ||
col.column_name === "id" ||
col.column_name.endsWith("_pk"),
);
if (pkCandidate && !config.primaryKey) {
setConfig((prev) => ({
...prev,
primaryKey: pkCandidate.column_name,
}));
}
}
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
} finally {
setLoadingColumns(false);
}
};
const usedColumns = useMemo(() => {
const used = new Set<string>();
config.rows.forEach((row) => {
row.elements.forEach((el) => {
if (el.type === "dataCell" && (el as CardDataCellElement).columnName) {
used.add((el as CardDataCellElement).columnName!);
}
if (el.type === "badge" && (el as CardBadgeElement).columnName) {
used.add((el as CardBadgeElement).columnName!);
}
});
});
return used;
}, [config.rows]);
const handleTableChange = (tableName: string) => {
setConfig((prev) => ({
...prev,
tableName,
primaryKey: "",
rows: prev.rows.map((row) => ({
...row,
elements: row.elements.map((el) => {
if (el.type === "dataCell") {
return { ...el, columnName: undefined } as CardDataCellElement;
}
if (el.type === "badge") {
return { ...el, columnName: undefined } as CardBadgeElement;
}
return el;
}),
})),
}));
};
const handleRowsChange = (rows: CardLayoutRow[]) => {
setConfig((prev) => ({ ...prev, rows }));
};
const handleColumnMapping = (
rowIndex: number,
elementIndex: number,
columnName: string,
) => {
setConfig((prev) => {
const newRows = [...prev.rows];
const newElements = [...newRows[rowIndex].elements];
const element = newElements[elementIndex];
if (element.type === "dataCell") {
newElements[elementIndex] = {
...element,
columnName,
} as CardDataCellElement;
} else if (element.type === "badge") {
newElements[elementIndex] = {
...element,
columnName,
} as CardBadgeElement;
}
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
return { ...prev, rows: newRows };
});
};
const handleSave = () => {
onSave(config);
onOpenChange(false);
};
const renderLayoutTab = () => (
<div className="space-y-4">
<CardElementPalette />
<CardCanvasEditor rows={config.rows} onRowsChange={handleRowsChange} />
</div>
);
const renderBindingTab = () => (
<div className="space-y-4">
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4 space-y-3">
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground">
</Label>
<Select
value={config.tableName || ""}
onValueChange={handleTableChange}
disabled={loadingTables}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue
placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"}
/>
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
{table.table_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
<Select
value={config.primaryKey || ""}
onValueChange={(pk) => setConfig((prev) => ({ ...prev, primaryKey: pk }))}
disabled={!config.tableName || loadingColumns}
>
<SelectTrigger className="h-9 text-sm bg-muted">
<SelectValue placeholder="기본 키 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
<div
className="p-3"
style={{
backgroundColor: config.backgroundColor,
display: "flex",
flexDirection: "column",
gap: config.gap,
}}
>
{config.rows.map((row, rowIndex) => (
<div
key={row.id}
className="grid"
style={{
gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)`,
gap: config.gap,
}}
>
{row.elements.map((element, elementIndex) => {
const needsBinding =
element.type === "dataCell" || element.type === "badge";
const currentColumn =
element.type === "dataCell"
? (element as CardDataCellElement).columnName
: element.type === "badge"
? (element as CardBadgeElement).columnName
: undefined;
return (
<div
key={element.id}
className="border border-gray-100 rounded px-2 py-1.5 hover:bg-gray-50"
style={{ gridColumn: `span ${element.colspan || 1}` }}
>
<div className="flex items-center gap-1 mb-1">
<span className="text-gray-400">
{getElementIcon(element.type)}
</span>
<span className="text-xs text-muted-foreground">
{element.type === "header" && (element as any).title}
{element.type === "dataCell" &&
(element as CardDataCellElement).label}
{element.type === "divider" && "구분선"}
{element.type === "badge" &&
((element as CardBadgeElement).label || "뱃지")}
</span>
</div>
{needsBinding && (
<Select
value={currentColumn || ""}
onValueChange={(col) =>
handleColumnMapping(rowIndex, elementIndex, col)
}
disabled={!config.tableName}
>
<SelectTrigger
className={`h-7 text-xs ${
currentColumn
? "bg-blue-50 text-blue-700 border-blue-200"
: "border-dashed"
}`}
>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => {
const isUsed =
usedColumns.has(col.column_name) &&
currentColumn !== col.column_name;
return (
<SelectItem
key={col.column_name}
value={col.column_name}
disabled={isUsed}
className={isUsed ? "opacity-50" : ""}
>
{col.column_name}
{isUsed && " (사용 중)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
</div>
);
})}
</div>
))}
</div>
</div>
{!config.tableName && (
<div className="text-center text-sm text-muted-foreground py-4">
.
</div>
)}
{config.tableName && config.rows.every((r) => r.elements.length === 0) && (
<div className="text-center text-sm text-muted-foreground py-4">
.
</div>
)}
</div>
);
const renderStyleTab = () => (
<div className="space-y-4">
<div className="bg-white border border-border rounded-xl p-4 space-y-3">
<div className="text-xs font-medium text-foreground mb-2"> </div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"></Label>
<Select value={config.padding || "12px"} onValueChange={(v) => setConfig((prev) => ({ ...prev, padding: v }))}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="8px">8px</SelectItem>
<SelectItem value="12px">12px</SelectItem>
<SelectItem value="16px">16px</SelectItem>
<SelectItem value="20px">20px</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Select value={config.gap || "8px"} onValueChange={(v) => setConfig((prev) => ({ ...prev, gap: v }))}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="4px">4px</SelectItem>
<SelectItem value="8px">8px</SelectItem>
<SelectItem value="12px">12px</SelectItem>
<SelectItem value="16px">16px</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"></Label>
<Select value={config.borderStyle || "solid"} onValueChange={(v) => setConfig((prev) => ({ ...prev, borderStyle: v }))}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="color" value={config.borderColor || "#e5e7eb"} onChange={(e) => setConfig((prev) => ({ ...prev, borderColor: e.target.value }))} className="h-9 w-full p-1" />
</div>
<div className="space-y-2 col-span-2">
<Label className="text-xs font-medium text-foreground"></Label>
<Input type="color" value={config.backgroundColor || "#ffffff"} onChange={(e) => setConfig((prev) => ({ ...prev, backgroundColor: e.target.value }))} className="h-9 w-full p-1" />
</div>
</div>
</div>
<div className="bg-white border border-border rounded-xl p-4 space-y-3">
<div className="text-xs font-medium text-foreground mb-2"> </div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="number" min={10} max={24} value={config.headerTitleFontSize || 14} onChange={(e) => setConfig((prev) => ({ ...prev, headerTitleFontSize: parseInt(e.target.value) || 14 }))} className="h-9 text-sm" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="color" value={config.headerTitleColor || "#1e40af"} onChange={(e) => setConfig((prev) => ({ ...prev, headerTitleColor: e.target.value }))} className="h-9 w-full p-1" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="number" min={10} max={20} value={config.labelFontSize || 13} onChange={(e) => setConfig((prev) => ({ ...prev, labelFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="color" value={config.labelColor || "#374151"} onChange={(e) => setConfig((prev) => ({ ...prev, labelColor: e.target.value }))} className="h-9 w-full p-1" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="number" min={10} max={20} value={config.valueFontSize || 13} onChange={(e) => setConfig((prev) => ({ ...prev, valueFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="color" value={config.valueColor || "#000000"} onChange={(e) => setConfig((prev) => ({ ...prev, valueColor: e.target.value }))} className="h-9 w-full p-1" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="number" min={1} max={5} value={config.dividerThickness || 1} onChange={(e) => setConfig((prev) => ({ ...prev, dividerThickness: parseInt(e.target.value) || 1 }))} className="h-9 text-sm" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground"> </Label>
<Input type="color" value={config.dividerColor || "#e5e7eb"} onChange={(e) => setConfig((prev) => ({ ...prev, dividerColor: e.target.value }))} className="h-9 w-full p-1" />
</div>
</div>
</div>
</div>
);
return (
<>
<Dialog open={open} onOpenChange={guard.handleOpenChange}>
<DialogContent className="max-w-4xl h-[92vh] overflow-hidden flex flex-col p-0 [&>button]:hidden">
<DialogTitle className="sr-only"> </DialogTitle>
<DialogDescription className="sr-only">
, ,
</DialogDescription>
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-blue-600" />
<h2 className="text-base font-semibold text-foreground">
</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={guard.tryClose}
className="w-8 h-8"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Tab */}
<div className="mx-6 mt-3">
<div className="h-9 bg-muted/30 rounded-lg p-0.5 inline-flex">
<button
onClick={() => setActiveTab("layout")}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
activeTab === "layout"
? "bg-blue-50 text-blue-700 shadow-sm"
: "bg-transparent text-foreground hover:text-foreground/80"
}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setActiveTab("binding")}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
activeTab === "binding"
? "bg-blue-50 text-blue-700 shadow-sm"
: "bg-transparent text-foreground hover:text-foreground/80"
}`}
>
<Database className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setActiveTab("style")}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
activeTab === "style"
? "bg-blue-50 text-blue-700 shadow-sm"
: "bg-transparent text-foreground hover:text-foreground/80"
}`}
>
<Palette className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{activeTab === "layout" && renderLayoutTab()}
{activeTab === "binding" && renderBindingTab()}
{activeTab === "style" && renderStyleTab()}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border flex items-center justify-end gap-2">
<Button variant="outline" onClick={guard.tryClose}>
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleSave}>
</Button>
</div>
</DialogContent>
</Dialog>
<UnsavedChangesDialog guard={guard} />
</>
);
}