488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* CardLayoutTabs.tsx — 카드 컴포넌트 설정 탭
|
|
*
|
|
* [역할]
|
|
* - 카드 컴포넌트의 레이아웃 구성 / 데이터 연결 / 표시 조건을 3탭 구조로 제공
|
|
* - ComponentSettingsModal 내에 직접 임베드되어 사용
|
|
*
|
|
* [사용처]
|
|
* - CardProperties.tsx (section="data"일 때)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
LayoutGrid,
|
|
Database,
|
|
CreditCard as CreditCardIcon,
|
|
Eye,
|
|
Type,
|
|
CreditCard,
|
|
Minus,
|
|
Tag,
|
|
ImageIcon,
|
|
Hash,
|
|
Calendar,
|
|
Link2,
|
|
Circle,
|
|
Space,
|
|
FileText,
|
|
} from "lucide-react";
|
|
import { reportApi } from "@/lib/api/reportApi";
|
|
import type {
|
|
CardLayoutConfig,
|
|
CardLayoutRow,
|
|
CardDataCellElement,
|
|
CardBadgeElement,
|
|
CardNumberElement,
|
|
CardDateElement,
|
|
CardLinkElement,
|
|
CardStatusElement,
|
|
CardImageElement,
|
|
ComponentConfig,
|
|
} from "@/types/report";
|
|
import { CardElementPalette } from "./CardElementPalette";
|
|
import { CardCanvasEditor } from "./CardCanvasEditor";
|
|
import { ConditionalProperties } from "../properties/ConditionalProperties";
|
|
import type { CardColumnLabel } from "../properties/ConditionalProperties";
|
|
|
|
interface CardLayoutTabsProps {
|
|
config: CardLayoutConfig;
|
|
onConfigChange: (config: CardLayoutConfig) => void;
|
|
component?: ComponentConfig;
|
|
onComponentChange?: (updates: Partial<ComponentConfig>) => void;
|
|
}
|
|
|
|
type TabType = "layout" | "binding" | "condition";
|
|
|
|
interface TableInfo {
|
|
table_name: string;
|
|
table_type: string;
|
|
}
|
|
|
|
interface ColumnInfo {
|
|
column_name: string;
|
|
data_type: string;
|
|
is_nullable: string;
|
|
}
|
|
|
|
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" />;
|
|
case "image":
|
|
return <ImageIcon className="w-3 h-3" />;
|
|
case "number":
|
|
return <Hash className="w-3 h-3" />;
|
|
case "date":
|
|
return <Calendar className="w-3 h-3" />;
|
|
case "link":
|
|
return <Link2 className="w-3 h-3" />;
|
|
case "status":
|
|
return <Circle className="w-3 h-3" />;
|
|
case "spacer":
|
|
return <Space className="w-3 h-3" />;
|
|
case "staticText":
|
|
return <FileText className="w-3 h-3" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export function CardLayoutTabs({
|
|
config,
|
|
onConfigChange,
|
|
component,
|
|
onComponentChange,
|
|
}: CardLayoutTabsProps) {
|
|
const [activeTab, setActiveTab] = useState<TabType>("layout");
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchTables();
|
|
}, []);
|
|
|
|
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) {
|
|
onConfigChange({
|
|
...config,
|
|
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) => {
|
|
const colName =
|
|
(el as CardDataCellElement | CardBadgeElement | CardNumberElement | CardDateElement | CardLinkElement | CardStatusElement | CardImageElement)
|
|
.columnName;
|
|
if (colName) used.add(colName);
|
|
});
|
|
});
|
|
return used;
|
|
}, [config.rows]);
|
|
|
|
const handleTableChange = useCallback(
|
|
(tableName: string) => {
|
|
onConfigChange({
|
|
...config,
|
|
tableName,
|
|
primaryKey: "",
|
|
rows: config.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;
|
|
}),
|
|
})),
|
|
});
|
|
},
|
|
[config, onConfigChange],
|
|
);
|
|
|
|
const handleRowsChange = useCallback(
|
|
(rows: CardLayoutRow[]) => {
|
|
onConfigChange({ ...config, rows });
|
|
},
|
|
[config, onConfigChange],
|
|
);
|
|
|
|
const handleColumnMapping = useCallback(
|
|
(rowIndex: number, elementIndex: number, columnName: string) => {
|
|
const newRows = [...config.rows];
|
|
const newElements = [...newRows[rowIndex].elements];
|
|
newElements[elementIndex] = { ...newElements[elementIndex], columnName } as typeof newElements[number];
|
|
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
|
onConfigChange({ ...config, rows: newRows });
|
|
},
|
|
[config, onConfigChange],
|
|
);
|
|
|
|
const updateConfig = useCallback(
|
|
(updates: Partial<CardLayoutConfig>) => {
|
|
onConfigChange({ ...config, ...updates });
|
|
},
|
|
[config, onConfigChange],
|
|
);
|
|
|
|
const renderLayoutTab = () => (
|
|
<div className="space-y-4">
|
|
<div className="sticky top-0 z-10 bg-white pb-2">
|
|
<CardElementPalette />
|
|
</div>
|
|
<CardCanvasEditor rows={config.rows} onRowsChange={handleRowsChange} />
|
|
</div>
|
|
);
|
|
|
|
const renderBindingTab = () => (
|
|
<div className="space-y-4">
|
|
{/* 데이터 소스 섹션 */}
|
|
<div className="bg-gray-50 border border-gray-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) => updateConfig({ 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>
|
|
|
|
{/* 카드 WYSIWYG 미리보기 + 인라인 드롭다운 */}
|
|
<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,
|
|
marginBottom: row.marginBottom || undefined,
|
|
}}
|
|
>
|
|
{row.elements.map((element, elementIndex) => {
|
|
const needsBinding = ["dataCell", "badge", "number", "date", "link", "status", "image"]
|
|
.includes(element.type);
|
|
const currentColumn =
|
|
(element as CardDataCellElement | CardBadgeElement | CardNumberElement | CardDateElement | CardLinkElement | CardStatusElement | CardImageElement)
|
|
.columnName;
|
|
|
|
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 || "뱃지")}
|
|
{element.type === "image" && "이미지"}
|
|
{element.type === "number" && ((element as CardNumberElement).label || "숫자/금액")}
|
|
{element.type === "date" && ((element as CardDateElement).label || "날짜")}
|
|
{element.type === "link" && ((element as CardLinkElement).label || "링크")}
|
|
{element.type === "status" && "상태"}
|
|
{element.type === "spacer" && "빈 공간"}
|
|
{element.type === "staticText" && "고정 텍스트"}
|
|
</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 cardColumnLabels = useMemo<CardColumnLabel[]>(() => {
|
|
const labels: CardColumnLabel[] = [];
|
|
config.rows.forEach((row) => {
|
|
row.elements.forEach((el) => {
|
|
const colName = (el as any).columnName as string | undefined;
|
|
if (!colName) return;
|
|
|
|
let label = "";
|
|
if (el.type === "dataCell") label = (el as any).label || "";
|
|
else if (el.type === "badge") label = (el as any).label || "뱃지";
|
|
else if (el.type === "number") label = (el as any).label || "숫자/금액";
|
|
else if (el.type === "date") label = (el as any).label || "날짜";
|
|
else if (el.type === "link") label = (el as any).label || "링크";
|
|
else if (el.type === "status") label = "상태";
|
|
else if (el.type === "image") label = "이미지";
|
|
|
|
if (label) labels.push({ columnName: colName, label });
|
|
});
|
|
});
|
|
return labels;
|
|
}, [config.rows]);
|
|
|
|
const renderConditionTab = () => {
|
|
if (!component) {
|
|
return (
|
|
<div className="text-center text-sm text-muted-foreground py-8">
|
|
표시 조건을 설정하려면 컴포넌트 정보가 필요합니다.
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<ConditionalProperties
|
|
component={component}
|
|
onConfigChange={onComponentChange}
|
|
cardColumns={columns}
|
|
cardTableName={config.tableName}
|
|
cardColumnLabels={cardColumnLabels}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const tabs: { key: TabType; icon: React.ReactNode; label: string }[] = [
|
|
{ key: "layout", icon: <LayoutGrid className="w-3.5 h-3.5" />, label: "레이아웃 구성" },
|
|
{ key: "binding", icon: <Database className="w-3.5 h-3.5" />, label: "데이터 연결" },
|
|
{ key: "condition", icon: <Eye className="w-3.5 h-3.5" />, label: "표시 조건" },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4 px-6 py-5">
|
|
{/* 헤더 + 탭 */}
|
|
<div>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<CreditCardIcon className="h-3.5 w-3.5 text-blue-600" />
|
|
<span className="text-xs font-medium text-foreground">카드 기능 설정</span>
|
|
</div>
|
|
<div className="inline-flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
|
activeTab === tab.key
|
|
? "bg-white text-blue-700 shadow-sm"
|
|
: "text-gray-500 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 콘텐츠 */}
|
|
<div>
|
|
{activeTab === "layout" && renderLayoutTab()}
|
|
{activeTab === "binding" && renderBindingTab()}
|
|
{activeTab === "condition" && renderConditionTab()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|