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

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