"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[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 (
계산 방식 선택 {cell.field && ({cell.field})}
{GRID_SUMMARY_OPTIONS.map((opt) => (

{opt.description}

))}
); } // ─── 메인 컴포넌트 ────────────────────────────────────────────────────────────── export function TableLayoutTabs({ component }: Props) { const { updateComponent } = useReportDesigner(); const [activeTab, setActiveTab] = useState("layout"); // 스키마 상태 const [tables, setTables] = useState([]); const [schemaColumns, setSchemaColumns] = useState([]); 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(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) => { 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) => { 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) => { updateComponent(component.id, updates); }, [component.id, updateComponent], ); // ─── 탭1: 레이아웃 구성 ───────────────────────────────────────────────────── const renderLayoutTab = () => (
{/* 테이블 모드 선택 */}
{/* 데이터 소스 선택 */}
{selectedTable && (

선택된 테이블: {selectedTable} {schemaColumns.length > 0 && ` (${schemaColumns.length}개 컬럼)`}

)}
{/* 모드별 에디터 */} {isGridMode ? ( ) : ( <> { setRowCount(count); updateComponent(component.id, { tableRowCount: count }); }} /> {!selectedTable && (
테이블을 먼저 선택해주세요.
)} )}
); // ─── 탭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 (
!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 && (
상단 updateComponent(component.id, { gridHeaderRows: Math.max(0, Math.min(gridRowCount - 1, parseInt(e.target.value) || 0)), }) } />
좌측 updateComponent(component.id, { gridHeaderCols: Math.max(0, Math.min(gridColCount - 1, parseInt(e.target.value) || 0)), }) } />
)} {gridCells.length === 0 && (
레이아웃 탭에서 격자를 먼저 구성하세요.
)} {/* 푸터(요약) 설정 — 테이블 모드와 동일 구조 */} {gridCells.length > 0 && (
하단 통계 설정

표 아래쪽에 합계/평균/개수 등을 자동으로 계산하는 행을 추가합니다. 각 열마다 원하는 계산 방식을 지정할 수 있습니다.

{ const updates: Partial = { gridShowFooter: checked }; if (checked && gridFooterRows === 0) { updates.gridFooterRows = 1; } if (!checked) { updates.gridFooterRows = 0; } updateComponent(component.id, updates); }} />
{gridShowFooter && (
{/* 컬럼별 집계 유형 미리보기 테이블 */} {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 (
열별 계산 방식

각 열을 클릭하면 합계/평균/개수 중 계산 방식을 선택할 수 있습니다.

{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 ( ); })} {Array.from({ length: gridFooterRows }).map((_, fRowIdx) => { const rowIdx = footerStartRow + fRowIdx; return ( {Array.from({ length: gridColCount }).map((_, colIdx) => { const cell = gridCells.find( (c: GridCell) => c.row === rowIdx && c.col === colIdx, ); if (!cell || cell.merged) { return ( ); } return (

{cell.summaryType && cell.summaryType !== "NONE" ? `${cell.summaryType} 계산 중 — 클릭하여 변경` : "클릭하여 계산 방식을 선택하세요"}

); })} ); })}
{label}

{fieldCell?.field ? `필드: ${fieldCell.field}` : "필드 미배치"}

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" ? ( {cell.summaryType} ) : ( 클릭하여 설정 )}
); })()} {/* exportSummaryId */}

다른 계산 영역에서 이 통계값을 가져다 쓸 수 있도록 이름을 지정합니다.

updateComponent(component.id, { exportSummaryId: e.target.value })} placeholder="예: table_total" className="h-8 max-w-[240px] text-xs" />

다른 계산 영역에서 이 값을 사용할 때 필요한 이름입니다.

)}
)} {/* 격자 푸터 셀 집계 설정 모달 */} {gridFooterModalOpen && gridFooterTargetCellData && ( { setGridFooterModalOpen(false); setGridFooterTargetCell(null); }} /> )}
); } const filledCount = cols.filter((c) => c.field).length; const allFilled = cols.length > 0 && filledCount === cols.length; return (
{/* 컬럼 팔레트 (레이아웃 열 수만큼만 선택 가능) */} c.field).map((c) => c.field))} onColumnRemove={handleColumnRemove} /> {/* 배치 안내 */} {cols.length > 0 && !allFilled && (
{cols.length}개의 열에 컬럼을 배치하세요 ({filledCount}/{cols.length} 배치됨)
)} {cols.length === 0 && (
레이아웃 탭에서 열을 먼저 추가하세요.
)} {/* 드롭 존 */} {/* 푸터(요약) 설정 */}
하단 통계 설정

표 아래쪽에 합계/평균/개수 등을 자동으로 계산하는 행을 추가합니다. 각 열마다 원하는 계산 방식을 지정할 수 있습니다.

updateComponent(component.id, { showFooter: checked })} />
{component.showFooter && (
{/* 푸터 미리보기 테이블 */} {cols.length > 0 && (
열별 계산 방식

각 열을 클릭하면 합계/평균/개수 중 계산 방식을 선택할 수 있습니다.

{cols.map((col, idx) => ( ))} {cols.map((col, idx) => (

{col.summaryType && col.summaryType !== "NONE" ? `${col.summaryType} 계산 중 — 클릭하여 변경` : "클릭하여 계산 방식을 선택하세요"}

))}
{col.header || col.field || `열 ${idx + 1}`}

{col.field ? `필드: ${col.field}` : "필드 미배치"}

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" ? ( {col.summaryType} ) : ( 클릭하여 설정 )}
)} {/* exportSummaryId */}

다른 계산 영역에서 이 통계값을 가져다 쓸 수 있도록 이름을 지정합니다.

updateComponent(component.id, { exportSummaryId: e.target.value })} placeholder="예: table_total" className="h-8 max-w-[240px] text-xs" />

다른 계산 영역에서 이 값을 사용할 때 필요한 이름입니다.

)}
{/* 집계 설정 모달 */}
); }; // ─── 탭3: 표시 조건 ───────────────────────────────────────────────────── const tableColumnLabels = useMemo(() => { if (isGridMode) { // 격자 양식: gridCells에서 field가 설정된 셀의 컬럼명 수집 const gridCells = component.gridCells ?? []; const labels: Array<{ columnName: string; label: string }> = []; const seen = new Set(); 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 = () => ( ); // ─── 탭 정의 ──────────────────────────────────────────────────────────────── const tabs: { key: TabType; icon: React.ReactNode; label: string }[] = [ { key: "layout", icon: , label: "레이아웃 구성" }, { key: "data", icon: , label: "데이터 연결" }, { key: "condition", icon: , label: "표시 조건" }, ]; return (
{/* 헤더 + 탭 */}
테이블 기능 설정
{tabs.map((tab) => ( ))}
{/* 탭 콘텐츠 */}
{activeTab === "layout" && renderLayoutTab()} {activeTab === "data" && renderDataTab()} {activeTab === "condition" && renderConditionTab()}
); }