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

1105 lines
48 KiB
TypeScript

"use client";
/**
* TableLayoutTabs.tsx — 테이블 컴포넌트 설정 (카드 컴포넌트와 통일된 3탭 구조)
*
* - 탭1 "레이아웃 구성": 테이블 선택 + 열 추가/삭제 + 열 너비 드래그 조절
* - 탭2 "데이터 연결": 컬럼 팔레트(D&D) → 드롭 존 배치 + 인라인 설정 + 푸터 집계
* - 탭3 "표시 조건": ConditionalProperties
*/
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
LayoutGrid,
Database,
Eye,
Table2,
Loader2,
Grid3X3,
TableProperties,
HelpCircle,
Calculator,
} from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ConditionalProperties } from "../properties/ConditionalProperties";
import { reportApi } from "@/lib/api/reportApi";
import { TableCanvasEditor } from "./TableCanvasEditor";
import { GridEditor } from "./GridEditor";
import { TableColumnPalette, type SchemaColumn } from "./TableColumnPalette";
import { TableColumnDropZone } from "./TableColumnDropZone";
import { GridCellDropZone } from "./GridCellDropZone";
import { FooterAggregateModal } from "./FooterAggregateModal";
import type { ComponentConfig, GridCell } from "@/types/report";
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
type TabType = "layout" | "data" | "condition";
type GridSummaryType = "SUM" | "AVG" | "COUNT" | "NONE";
interface TableInfo {
table_name: string;
table_type: string;
}
interface Props {
component: ComponentConfig;
}
const GRID_SUMMARY_OPTIONS: { value: GridSummaryType; label: string; description: string }[] = [
{ value: "NONE", label: "없음", description: "계산하지 않습니다" },
{ value: "SUM", label: "합계", description: "해당 열의 모든 값을 더합니다" },
{ value: "AVG", label: "평균", description: "해당 열의 평균값을 계산합니다" },
{ value: "COUNT", label: "개수", description: "해당 열의 데이터 수를 셉니다" },
];
// ─── 격자 푸터 셀 집계 설정 (인라인) ────────────────────────────────────────────
function GridFooterAggregateInline({
cell,
onSave,
onClose,
}: {
cell: GridCell;
onSave: (summaryType: GridSummaryType) => void;
onClose: () => void;
}) {
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Calculator className="h-3.5 w-3.5 text-blue-600" />
<span className="text-xs font-bold text-gray-800">
{cell.field && <span className="ml-1 font-mono text-blue-600">({cell.field})</span>}
</span>
</div>
<button onClick={onClose} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
<span className="text-xs"></span>
</button>
</div>
<div className="p-3">
<div className="grid grid-cols-2 gap-2">
{GRID_SUMMARY_OPTIONS.map((opt) => (
<TooltipProvider key={opt.value}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onSave(opt.value)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-all ${
cell.summaryType === opt.value || (!cell.summaryType && opt.value === "NONE")
? "border-blue-400 bg-blue-50 text-blue-700"
: "border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:bg-blue-50/50"
}`}
>
{opt.value !== "NONE" && <Calculator className="h-3 w-3" />}
{opt.label}
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{opt.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
</div>
);
}
// ─── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
export function TableLayoutTabs({ component }: Props) {
const { updateComponent } = useReportDesigner();
const [activeTab, setActiveTab] = useState<TabType>("layout");
// 스키마 상태
const [tables, setTables] = useState<TableInfo[]>([]);
const [schemaColumns, setSchemaColumns] = useState<SchemaColumn[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
// 행 개수 (component에 저장된 값을 초기값으로 사용)
const [rowCount, setRowCount] = useState(component.tableRowCount ?? 3);
// 푸터 집계 모달 (일반 테이블)
const [aggregateModalOpen, setAggregateModalOpen] = useState(false);
const [aggregateTargetIdx, setAggregateTargetIdx] = useState<number>(0);
// 격자 모드 푸터 셀 집계 모달
const [gridFooterModalOpen, setGridFooterModalOpen] = useState(false);
const [gridFooterTargetCell, setGridFooterTargetCell] = useState<{ row: number; col: number } | null>(null);
const cols: TableColumn[] = component.tableColumns ?? [];
const selectedTable = component.dataTableName ?? "";
// ─ 테이블 목록 로드
useEffect(() => {
let cancelled = false;
setLoadingTables(true);
reportApi
.getSchemaTableList()
.then((res) => {
if (!cancelled && res.success) setTables(res.data);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoadingTables(false);
});
return () => {
cancelled = true;
};
}, []);
// ─ 테이블 변경 시 컬럼 목록 로드
useEffect(() => {
if (!selectedTable) {
setSchemaColumns([]);
return;
}
let cancelled = false;
setLoadingColumns(true);
reportApi
.getSchemaTableColumns(selectedTable)
.then((res) => {
if (!cancelled && res.success) setSchemaColumns(res.data);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoadingColumns(false);
});
return () => {
cancelled = true;
};
}, [selectedTable]);
// ─ 테이블 선택
const handleTableChange = useCallback(
(tableName: string) => {
updateComponent(component.id, {
dataTableName: tableName === "none" ? "" : tableName,
tableColumns: [],
});
},
[component.id, updateComponent],
);
// ─ 탭1: 열 구조 변경 (캔버스 에디터)
const handleColumnsChange = useCallback(
(newCols: TableColumn[]) => {
updateComponent(component.id, { tableColumns: newCols });
},
[component.id, updateComponent],
);
// ─ 탭2: 컬럼 편집
const updateColumn = useCallback(
(idx: number, updates: Partial<TableColumn>) => {
const newColumns = cols.map((col, i) => (i === idx ? { ...col, ...updates } : col));
updateComponent(component.id, { tableColumns: newColumns });
},
[cols, component.id, updateComponent],
);
// ─ 탭2: D&D 드롭 핸들러 (팔레트 → 슬롯)
const handleColumnDrop = useCallback(
(slotIndex: number, columnName: string, _dataType: string) => {
const targetCol = cols[slotIndex];
const isTargetOccupied = !!targetCol?.field;
if (isTargetOccupied) {
// 타겟 슬롯에 이미 컬럼이 있으면, 가장 가까운 빈 슬롯으로 밀어냄
const emptyIdx = cols.findIndex((c, i) => i !== slotIndex && !c.field);
const newCols = cols.map((col, i) => {
if (i === slotIndex) {
return {
...col,
field: columnName,
header: col.header === `${i + 1}` || !col.header ? columnName : col.header,
};
}
if (emptyIdx >= 0 && i === emptyIdx) {
return { ...col, field: targetCol.field, header: targetCol.header };
}
return col;
});
updateComponent(component.id, { tableColumns: newCols });
} else {
const newCols = cols.map((col, i) =>
i === slotIndex
? {
...col,
field: columnName,
header: col.header === `${i + 1}` || !col.header ? columnName : col.header,
}
: col,
);
updateComponent(component.id, { tableColumns: newCols });
}
},
[cols, component.id, updateComponent],
);
// ─ 탭2: 슬롯 간 이동 (이미 배치된 컬럼을 다른 슬롯으로 이동)
const handleColumnMove = useCallback(
(fromIndex: number, toIndex: number) => {
const fromCol = cols[fromIndex];
const toCol = cols[toIndex];
if (!fromCol?.field) return;
// 두 슬롯의 field/header를 교환
const newCols = cols.map((col, i) => {
if (i === fromIndex) {
return {
...col,
field: toCol.field || "",
header: toCol.field ? toCol.header : `${i + 1}`,
numberFormat: toCol.field ? toCol.numberFormat : ("none" as const),
summaryType: toCol.field ? toCol.summaryType : undefined,
};
}
if (i === toIndex) {
return {
...col,
field: fromCol.field,
header: fromCol.header,
numberFormat: fromCol.numberFormat,
summaryType: fromCol.summaryType,
};
}
return col;
});
updateComponent(component.id, { tableColumns: newCols });
},
[cols, component.id, updateComponent],
);
// ─ 탭2: 슬롯 비우기
const handleColumnClear = useCallback(
(slotIndex: number) => {
const newCols = cols.map((col, i) => (i === slotIndex ? { ...col, field: "", header: `${i + 1}` } : col));
updateComponent(component.id, { tableColumns: newCols });
},
[cols, component.id, updateComponent],
);
// ─ 푸터 집계 모달
const handleFooterColumnClick = useCallback((idx: number) => {
setAggregateTargetIdx(idx);
setAggregateModalOpen(true);
}, []);
const handleAggregateSave = useCallback(
(idx: number, updates: Partial<TableColumn>) => {
updateColumn(idx, updates);
},
[updateColumn],
);
// ─ 격자 모드: 푸터 셀 클릭 → 집계 설정
const handleGridFooterCellClick = useCallback((row: number, col: number) => {
setGridFooterTargetCell({ row, col });
setGridFooterModalOpen(true);
}, []);
const handleGridFooterAggregateSave = useCallback(
(summaryType: "SUM" | "AVG" | "COUNT" | "NONE") => {
if (!gridFooterTargetCell) return;
const { row, col } = gridFooterTargetCell;
const gridCells = component.gridCells ?? [];
const newCells = gridCells.map((c: GridCell) => (c.row === row && c.col === col ? { ...c, summaryType } : c));
updateComponent(component.id, { gridCells: newCells });
setGridFooterModalOpen(false);
setGridFooterTargetCell(null);
},
[gridFooterTargetCell, component.id, component.gridCells, updateComponent],
);
// ─── 그리드 모드 전환 ────────────────────────────────────────────────────────
const isGridMode = component.gridMode === true;
const handleToggleGridMode = useCallback(
(enabled: boolean) => {
if (enabled) {
// 단순 테이블 → 그리드: 현재 행/열 크기를 그리드 초기값으로 반영
const currentCols = cols.length || 6;
const currentRows = rowCount || 4;
const hasExistingGrid = (component.gridCells ?? []).length > 0;
if (!hasExistingGrid) {
// 그리드 셀이 아직 없으면 현재 크기로 초기화
const newCells: import("@/types/report").GridCell[] = [];
for (let r = 0; r < currentRows; r++) {
for (let c = 0; c < currentCols; c++) {
newCells.push({
id: `r${r}c${c}`,
row: r,
col: c,
rowSpan: 1,
colSpan: 1,
cellType: "static",
value: "",
align: "center",
verticalAlign: "middle",
fontWeight: "normal",
fontSize: 12,
borderStyle: "thin",
});
}
}
updateComponent(component.id, {
gridMode: true,
gridCells: newCells,
gridRowCount: currentRows,
gridColCount: currentCols,
gridColWidths: Array(currentCols).fill(100),
gridRowHeights: Array(currentRows).fill(32),
});
} else {
updateComponent(component.id, { gridMode: true });
}
} else {
// 그리드 → 단순 테이블: 그리드 행/열 크기를 단순 테이블에 반영
const gridRows = component.gridRowCount ?? rowCount;
const gridCols = component.gridColCount ?? cols.length;
setRowCount(gridRows);
// 열 개수가 다르면 단순 테이블 열을 맞춤
if (gridCols !== cols.length) {
const newCols = Array.from({ length: gridCols }, (_, i) => {
if (i < cols.length) return cols[i];
return {
field: "",
header: `${i + 1}`,
width: 120,
align: "left" as const,
mappingType: "field" as const,
summaryType: "NONE" as const,
visible: true,
numberFormat: "none" as const,
};
});
updateComponent(component.id, { gridMode: false, tableColumns: newCols });
} else {
updateComponent(component.id, { gridMode: false });
}
}
},
[
component.id,
updateComponent,
cols,
rowCount,
component.gridCells,
component.gridRowCount,
component.gridColCount,
],
);
const handleGridUpdate = useCallback(
(updates: Partial<ComponentConfig>) => {
updateComponent(component.id, updates);
},
[component.id, updateComponent],
);
// ─── 탭1: 레이아웃 구성 ─────────────────────────────────────────────────────
const renderLayoutTab = () => (
<div className="min-w-0 space-y-3">
{/* 테이블 모드 선택 */}
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<Label className="text-foreground text-xs font-medium"> </Label>
<div className="flex items-center gap-1 rounded-lg border border-gray-200 bg-white p-0.5">
<button
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
!isGridMode ? "bg-blue-600 text-white shadow-sm" : "text-gray-500 hover:bg-gray-100 hover:text-gray-700"
}`}
onClick={() => handleToggleGridMode(false)}
>
<TableProperties className="h-3.5 w-3.5" />
</button>
<button
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
isGridMode ? "bg-blue-600 text-white shadow-sm" : "text-gray-500 hover:bg-gray-100 hover:text-gray-700"
}`}
onClick={() => handleToggleGridMode(true)}
>
<Grid3X3 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* 데이터 소스 선택 */}
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<div className="space-y-2">
<Label className="text-foreground text-xs font-medium"> </Label>
<Select value={selectedTable || "none"} onValueChange={handleTableChange} disabled={loadingTables}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{tables.map((t) => (
<SelectItem key={t.table_name} value={t.table_name}>
{t.table_name}
{t.table_type === "VIEW" && <span className="ml-1 text-[10px] text-gray-400">()</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTable && (
<p className="mt-2 text-[10px] text-gray-500">
: <span className="font-mono font-medium text-blue-600">{selectedTable}</span>
{schemaColumns.length > 0 && ` (${schemaColumns.length}개 컬럼)`}
</p>
)}
</div>
{/* 모드별 에디터 */}
{isGridMode ? (
<GridEditor component={component} onUpdate={handleGridUpdate} schemaColumns={schemaColumns} />
) : (
<>
<TableCanvasEditor
columns={cols}
onColumnsChange={handleColumnsChange}
rowCount={rowCount}
onRowCountChange={(count) => {
setRowCount(count);
updateComponent(component.id, { tableRowCount: count });
}}
/>
{!selectedTable && (
<div className="text-muted-foreground py-4 text-center text-sm"> .</div>
)}
</>
)}
</div>
);
// ─── 탭2: 데이터 연결 ─────────────────────────────────────────────────────
// ─── 그리드 셀 데이터 바인딩 핸들러 ─────────────────────────────────────────
const handleGridCellDrop = useCallback(
(row: number, col: number, columnName: string) => {
const gridCells = component.gridCells ?? [];
const newCells = gridCells.map((c: GridCell) =>
c.row === row && c.col === col ? { ...c, cellType: "field" as const, field: columnName } : c,
);
updateComponent(component.id, { gridCells: newCells });
},
[component.id, component.gridCells, updateComponent],
);
const handleGridCellClear = useCallback(
(row: number, col: number) => {
const gridCells = component.gridCells ?? [];
const newCells = gridCells.map((c: GridCell) =>
c.row === row && c.col === col ? { ...c, cellType: "static" as const, field: "", value: "" } : c,
);
updateComponent(component.id, { gridCells: newCells });
},
[component.id, component.gridCells, updateComponent],
);
const handleGridHeaderDrop = useCallback(
(row: number, col: number, columnName: string) => {
const gridCells = component.gridCells ?? [];
const newCells = gridCells.map((c: GridCell) =>
c.row === row && c.col === col ? { ...c, cellType: "static" as const, value: columnName, field: "" } : c,
);
updateComponent(component.id, { gridCells: newCells });
},
[component.id, component.gridCells, updateComponent],
);
const handleColumnRemove = useCallback(
(columnName: string) => {
if (isGridMode) {
const gridCells = component.gridCells ?? [];
const newCells = gridCells.map((c: GridCell) => {
if (c.merged) return c;
if (c.cellType === "field" && c.field === columnName) {
return { ...c, cellType: "static" as const, field: "", value: "" };
}
if (c.cellType === "static" && c.value === columnName) {
return { ...c, value: "" };
}
return c;
});
updateComponent(component.id, { gridCells: newCells });
} else {
const newCols = cols.map((col) =>
col.field === columnName ? { ...col, field: "", header: `${cols.indexOf(col) + 1}` } : col,
);
updateComponent(component.id, { tableColumns: newCols });
}
},
[isGridMode, component.id, component.gridCells, cols, updateComponent],
);
const renderDataTab = () => {
// 그리드 모드: 팔레트 + 셀 드롭 존 + 푸터 설정
if (isGridMode) {
const gridCells = component.gridCells ?? [];
const gridRowCount = component.gridRowCount ?? 0;
const gridColCount = component.gridColCount ?? 0;
const gridColWidths = component.gridColWidths ?? [];
const gridRowHeights = component.gridRowHeights ?? [];
const headerRows = component.gridHeaderRows ?? 1;
const headerCols = component.gridHeaderCols ?? 1;
const gridFooterRows = component.gridFooterRows ?? 0;
const gridShowFooter = component.gridShowFooter ?? false;
const gridFooterTargetCellData = gridFooterTargetCell
? gridCells.find((c: GridCell) => c.row === gridFooterTargetCell.row && c.col === gridFooterTargetCell.col)
: null;
return (
<TooltipProvider>
<div className="space-y-4">
<TableColumnPalette
columns={schemaColumns}
loading={loadingColumns}
placedColumns={
new Set(
gridCells
.filter((c: GridCell) => !c.merged && ((c.cellType === "field" && c.field) || (c.cellType === "static" && c.value)))
.map((c: GridCell) => c.field || c.value || "")
.filter(Boolean),
)
}
onColumnRemove={handleColumnRemove}
/>
{/* 헤더 영역 설정 */}
{gridCells.length > 0 && (
<div className="flex items-center gap-4 rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<Label className="text-foreground shrink-0 text-xs font-medium"> </Label>
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-500"></span>
<Input
type="number"
className="h-7 w-14 text-center text-xs"
value={headerRows}
min={0}
max={gridRowCount - 1}
onChange={(e) =>
updateComponent(component.id, {
gridHeaderRows: Math.max(0, Math.min(gridRowCount - 1, parseInt(e.target.value) || 0)),
})
}
/>
<span className="text-[10px] text-gray-500"></span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-500"></span>
<Input
type="number"
className="h-7 w-14 text-center text-xs"
value={headerCols}
min={0}
max={gridColCount - 1}
onChange={(e) =>
updateComponent(component.id, {
gridHeaderCols: Math.max(0, Math.min(gridColCount - 1, parseInt(e.target.value) || 0)),
})
}
/>
<span className="text-[10px] text-gray-500"></span>
</div>
</div>
)}
{gridCells.length === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-orange-200 bg-orange-50 px-4 py-2.5">
<span className="text-xs font-medium text-orange-700"> .</span>
</div>
)}
<GridCellDropZone
cells={gridCells}
rowCount={gridRowCount}
colCount={gridColCount}
colWidths={gridColWidths}
rowHeights={gridRowHeights}
headerRows={headerRows}
headerCols={headerCols}
footerRows={gridShowFooter ? gridFooterRows : 0}
onCellDrop={handleGridCellDrop}
onHeaderDrop={handleGridHeaderDrop}
onCellClear={handleGridCellClear}
onFooterCellClick={handleGridFooterCellClick}
/>
{/* 푸터(요약) 설정 — 테이블 모드와 동일 구조 */}
{gridCells.length > 0 && (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Calculator className="h-3.5 w-3.5 text-blue-600" />
<span className="text-sm font-bold text-gray-800"> </span>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 cursor-help text-gray-400" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[280px]">
<p className="text-xs leading-relaxed">
// .
.
</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
checked={gridShowFooter}
onCheckedChange={(checked) => {
const updates: Partial<ComponentConfig> = { gridShowFooter: checked };
if (checked && gridFooterRows === 0) {
updates.gridFooterRows = 1;
}
if (!checked) {
updates.gridFooterRows = 0;
}
updateComponent(component.id, updates);
}}
/>
</div>
{gridShowFooter && (
<div className="space-y-4 p-4">
{/* 컬럼별 집계 유형 미리보기 테이블 */}
{gridColCount > 0 &&
(() => {
const footerStartRow = gridRowCount - gridFooterRows;
const footerCells = gridCells.filter((c: GridCell) => c.row >= footerStartRow && !c.merged);
const dataCols = gridCells.filter(
(c: GridCell) => c.cellType === "field" && c.field && !c.merged && c.row < footerStartRow,
);
const uniqueFields = Array.from(new Set(dataCols.map((c: GridCell) => c.field!)));
if (uniqueFields.length === 0 && footerCells.length === 0) return null;
return (
<div>
<div className="mb-2 flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700"> </span>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 cursor-help text-gray-400" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[220px]">
<p className="text-xs">
// .
</p>
</TooltipContent>
</Tooltip>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
{Array.from({ length: gridColCount }).map((_, colIdx) => {
const fieldCell = gridCells.find(
(c: GridCell) =>
c.col === colIdx && c.cellType === "field" && c.field && !c.merged,
);
const headerCell = gridCells.find(
(c: GridCell) =>
c.col === colIdx &&
c.row < headerRows &&
c.cellType === "static" &&
c.value &&
!c.merged,
);
const label = headerCell?.value || fieldCell?.field || `${colIdx + 1}`;
return (
<th
key={colIdx}
className="border-r border-gray-200 px-3 py-2 text-left font-medium text-gray-600 last:border-r-0"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{label}</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{fieldCell?.field ? `필드: ${fieldCell.field}` : "필드 미배치"}
</p>
</TooltipContent>
</Tooltip>
</th>
);
})}
</tr>
</thead>
<tbody>
{Array.from({ length: gridFooterRows }).map((_, fRowIdx) => {
const rowIdx = footerStartRow + fRowIdx;
return (
<tr key={fRowIdx}>
{Array.from({ length: gridColCount }).map((_, colIdx) => {
const cell = gridCells.find(
(c: GridCell) => c.row === rowIdx && c.col === colIdx,
);
if (!cell || cell.merged) {
return (
<td
key={colIdx}
className="border-r border-gray-100 px-3 py-2.5 text-center last:border-r-0"
>
<span className="text-[10px] text-gray-200"></span>
</td>
);
}
return (
<Tooltip key={colIdx}>
<TooltipTrigger asChild>
<td
onClick={() => handleGridFooterCellClick(rowIdx, colIdx)}
className="cursor-pointer border-r border-gray-100 px-3 py-2.5 text-center transition-colors last:border-r-0 hover:bg-blue-50"
>
{cell.summaryType && cell.summaryType !== "NONE" ? (
<span className="inline-flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700">
<Calculator className="h-2.5 w-2.5" />
{cell.summaryType}
</span>
) : (
<span className="text-[10px] text-gray-300"> </span>
)}
</td>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{cell.summaryType && cell.summaryType !== "NONE"
? `${cell.summaryType} 계산 중 — 클릭하여 변경`
: "클릭하여 계산 방식을 선택하세요"}
</p>
</TooltipContent>
</Tooltip>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})()}
{/* exportSummaryId */}
<div className="space-y-2 pl-1">
<div className="flex items-center gap-1.5">
<Label className="block text-xs font-medium text-gray-700"> </Label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 cursor-help text-gray-400" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[260px]">
<p className="text-xs leading-relaxed">
.
</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={component.exportSummaryId || ""}
onChange={(e) => updateComponent(component.id, { exportSummaryId: e.target.value })}
placeholder="예: table_total"
className="h-8 max-w-[240px] text-xs"
/>
<p className="text-[10px] text-gray-500">
.
</p>
</div>
</div>
)}
</div>
)}
{/* 격자 푸터 셀 집계 설정 모달 */}
{gridFooterModalOpen && gridFooterTargetCellData && (
<GridFooterAggregateInline
cell={gridFooterTargetCellData}
onSave={handleGridFooterAggregateSave}
onClose={() => {
setGridFooterModalOpen(false);
setGridFooterTargetCell(null);
}}
/>
)}
</div>
</TooltipProvider>
);
}
const filledCount = cols.filter((c) => c.field).length;
const allFilled = cols.length > 0 && filledCount === cols.length;
return (
<div className="space-y-4">
{/* 컬럼 팔레트 (레이아웃 열 수만큼만 선택 가능) */}
<TableColumnPalette
columns={schemaColumns}
loading={loadingColumns}
maxSelectable={cols.length}
placedColumns={new Set(cols.filter((c) => c.field).map((c) => c.field))}
onColumnRemove={handleColumnRemove}
/>
{/* 배치 안내 */}
{cols.length > 0 && !allFilled && (
<div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5">
<span className="text-xs font-medium text-amber-700">
{cols.length} ({filledCount}/{cols.length} )
</span>
</div>
)}
{cols.length === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-orange-200 bg-orange-50 px-4 py-2.5">
<span className="text-xs font-medium text-orange-700"> .</span>
</div>
)}
{/* 드롭 존 */}
<TableColumnDropZone
columns={cols}
onUpdate={updateColumn}
onDrop={handleColumnDrop}
onClear={handleColumnClear}
onMove={handleColumnMove}
/>
{/* 푸터(요약) 설정 */}
<TooltipProvider>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Calculator className="h-3.5 w-3.5 text-blue-600" />
<span className="text-sm font-bold text-gray-800"> </span>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 cursor-help text-gray-400" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[280px]">
<p className="text-xs leading-relaxed">
// .
.
</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
checked={component.showFooter || false}
onCheckedChange={(checked) => updateComponent(component.id, { showFooter: checked })}
/>
</div>
{component.showFooter && (
<div className="space-y-4 p-4">
{/* 푸터 미리보기 테이블 */}
{cols.length > 0 && (
<div>
<div className="mb-2 flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700"> </span>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 cursor-help text-gray-400" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[220px]">
<p className="text-xs"> // .</p>
</TooltipContent>
</Tooltip>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
{cols.map((col, idx) => (
<th
key={idx}
className="border-r border-gray-200 px-3 py-2 text-left font-medium text-gray-600 last:border-r-0"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{col.header || col.field || `${idx + 1}`}</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{col.field ? `필드: ${col.field}` : "필드 미배치"}</p>
</TooltipContent>
</Tooltip>
</th>
))}
</tr>
</thead>
<tbody>
<tr>
{cols.map((col, idx) => (
<Tooltip key={idx}>
<TooltipTrigger asChild>
<td
onClick={() => handleFooterColumnClick(idx)}
className="cursor-pointer border-r border-gray-100 px-3 py-2.5 text-center transition-colors last:border-r-0 hover:bg-blue-50"
>
{col.summaryType && col.summaryType !== "NONE" ? (
<span className="inline-flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700">
<Calculator className="h-2.5 w-2.5" />
{col.summaryType}
</span>
) : (
<span className="text-[10px] text-gray-300"> </span>
)}
</td>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{col.summaryType && col.summaryType !== "NONE"
? `${col.summaryType} 계산 중 — 클릭하여 변경`
: "클릭하여 계산 방식을 선택하세요"}
</p>
</TooltipContent>
</Tooltip>
))}
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* exportSummaryId */}
<div className="space-y-2 pl-1">
<div className="flex items-center gap-1.5">
<Label className="block text-xs font-medium text-gray-700"> </Label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 cursor-help text-gray-400" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[260px]">
<p className="text-xs leading-relaxed">
.
</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={component.exportSummaryId || ""}
onChange={(e) => updateComponent(component.id, { exportSummaryId: e.target.value })}
placeholder="예: table_total"
className="h-8 max-w-[240px] text-xs"
/>
<p className="text-[10px] text-gray-500"> .</p>
</div>
</div>
)}
</div>
</TooltipProvider>
{/* 집계 설정 모달 */}
<FooterAggregateModal
open={aggregateModalOpen}
onOpenChange={setAggregateModalOpen}
column={cols[aggregateTargetIdx] ?? null}
columnIndex={aggregateTargetIdx}
onSave={handleAggregateSave}
/>
</div>
);
};
// ─── 탭3: 표시 조건 ─────────────────────────────────────────────────────
const tableColumnLabels = useMemo(() => {
if (isGridMode) {
// 격자 양식: gridCells에서 field가 설정된 셀의 컬럼명 수집
const gridCells = component.gridCells ?? [];
const labels: Array<{ columnName: string; label: string }> = [];
const seen = new Set<string>();
gridCells.forEach((c) => {
if (c.cellType === "field" && c.field && !c.merged && !seen.has(c.field)) {
seen.add(c.field);
labels.push({ columnName: c.field, label: c.field });
}
});
return labels;
}
// 테이블 모드: tableColumns에서 field가 설정된 열 수집
return cols.filter((c) => c.field).map((c) => ({ columnName: c.field, label: c.header || c.field }));
}, [isGridMode, component.gridCells, cols]);
const tableConditionColumns = useMemo(() => {
return schemaColumns.map((sc) => ({
column_name: sc.column_name,
data_type: sc.data_type,
}));
}, [schemaColumns]);
const renderConditionTab = () => (
<ConditionalProperties
component={component}
cardColumns={tableConditionColumns}
cardTableName={selectedTable}
cardColumnLabels={tableColumnLabels}
/>
);
// ─── 탭 정의 ────────────────────────────────────────────────────────────────
const tabs: { key: TabType; icon: React.ReactNode; label: string }[] = [
{ key: "layout", icon: <LayoutGrid className="h-4 w-4" />, label: "레이아웃 구성" },
{ key: "data", icon: <Database className="h-4 w-4" />, label: "데이터 연결" },
{ key: "condition", icon: <Eye className="h-4 w-4" />, label: "표시 조건" },
];
return (
<div className="max-w-full min-w-0 space-y-4 px-6 py-5">
{/* 헤더 + 탭 */}
<div>
<div className="mb-3 flex items-center gap-2">
<Table2 className="h-3.5 w-3.5 text-blue-600" />
<span className="text-foreground text-xs font-medium"> </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 className="min-w-0 overflow-x-auto">
{activeTab === "layout" && renderLayoutTab()}
{activeTab === "data" && renderDataTab()}
{activeTab === "condition" && renderConditionTab()}
</div>
</div>
);
}