601 lines
22 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|