1105 lines
48 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|