diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 82c1db6c..e2e9d837 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -354,8 +354,17 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 컬럼의 inputType 가져오기 (entity 타입인지 확인용) const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; - // unified-select의 경우 inputType 전달 - const extraProps = componentId === "unified-select" ? { inputType } : {}; + // 현재 화면의 테이블명 가져오기 + const currentTableName = tables?.[0]?.tableName; + + // 컴포넌트별 추가 props + const extraProps: Record = {}; + if (componentId === "unified-select") { + extraProps.inputType = inputType; + } + if (componentId === "unified-list") { + extraProps.currentTableName = currentTableName; + } return (
diff --git a/frontend/components/unified/UnifiedList.tsx b/frontend/components/unified/UnifiedList.tsx index 2feb3dac..4a18478b 100644 --- a/frontend/components/unified/UnifiedList.tsx +++ b/frontend/components/unified/UnifiedList.tsx @@ -2,554 +2,180 @@ /** * UnifiedList - * + * * 통합 리스트 컴포넌트 - * - table: 테이블 뷰 - * - card: 카드 뷰 - * - kanban: 칸반 뷰 - * - list: 단순 리스트 뷰 + * 기존 TableListComponent를 래핑하여 동일한 기능 제공 */ -import React, { forwardRef, useCallback, useMemo, useState } from "react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; -import { cn } from "@/lib/utils"; -import { UnifiedListProps, ListColumn } from "@/types/unified-components"; -import { Search, ChevronUp, ChevronDown, MoreHorizontal, GripVertical } from "lucide-react"; - -/** - * 테이블 뷰 컴포넌트 - */ -const TableView = forwardRef[]; - selectedRows: Record[]; - onRowSelect?: (rows: Record[]) => void; - onRowClick?: (row: Record) => void; - editable?: boolean; - sortColumn?: string; - sortDirection?: "asc" | "desc"; - onSort?: (column: string) => void; - className?: string; -}>(({ - columns, - data, - selectedRows = [], - onRowSelect, - onRowClick, - editable, - sortColumn, - sortDirection, - onSort, - className -}, ref) => { - // 행 선택 처리 - const isRowSelected = useCallback((row: Record) => { - return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row)); - }, [selectedRows]); - - const handleSelectAll = useCallback((checked: boolean) => { - if (checked) { - onRowSelect?.(data); - } else { - onRowSelect?.([]); - } - }, [data, onRowSelect]); - - const handleSelectRow = useCallback((row: Record, checked: boolean) => { - if (checked) { - onRowSelect?.([...selectedRows, row]); - } else { - onRowSelect?.(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row))); - } - }, [selectedRows, onRowSelect]); - - const allSelected = data.length > 0 && selectedRows.length === data.length; - const someSelected = selectedRows.length > 0 && selectedRows.length < data.length; - - return ( -
- - - - {onRowSelect && ( - - - - )} - {columns.map((column) => ( - column.sortable && onSort?.(column.field)} - > -
- {column.header} - {column.sortable && sortColumn === column.field && ( - sortDirection === "asc" - ? - : - )} -
-
- ))} - {editable && } -
-
- - {data.length === 0 ? ( - - - 데이터가 없습니다 - - - ) : ( - data.map((row, index) => ( - onRowClick?.(row)} - > - {onRowSelect && ( - e.stopPropagation()}> - handleSelectRow(row, checked as boolean)} - /> - - )} - {columns.map((column) => ( - - {formatCellValue(row[column.field], column.format)} - - ))} - {editable && ( - e.stopPropagation()}> - - - )} - - )) - )} - -
-
- ); -}); -TableView.displayName = "TableView"; - -/** - * 카드 뷰 컴포넌트 - */ -const CardView = forwardRef[]; - selectedRows: Record[]; - onRowSelect?: (rows: Record[]) => void; - onRowClick?: (row: Record) => void; - className?: string; -}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => { - const isRowSelected = useCallback((row: Record) => { - return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row)); - }, [selectedRows]); - - const handleCardClick = useCallback((row: Record) => { - if (onRowSelect) { - const isSelected = isRowSelected(row); - if (isSelected) { - onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row))); - } else { - onRowSelect([...selectedRows, row]); - } - } - onRowClick?.(row); - }, [selectedRows, isRowSelected, onRowSelect, onRowClick]); - - // 주요 컬럼 (첫 번째)과 나머지 구분 - const [primaryColumn, ...otherColumns] = columns; - - return ( -
- {data.length === 0 ? ( -
- 데이터가 없습니다 -
- ) : ( - data.map((row, index) => ( - handleCardClick(row)} - > - - - {primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)} - - - -
- {otherColumns.slice(0, 4).map((column) => ( -
-
{column.header}
-
- {formatCellValue(row[column.field], column.format)} -
-
- ))} -
-
-
- )) - )} -
- ); -}); -CardView.displayName = "CardView"; - -/** - * 리스트 뷰 컴포넌트 - */ -const ListView = forwardRef[]; - selectedRows: Record[]; - onRowSelect?: (rows: Record[]) => void; - onRowClick?: (row: Record) => void; - className?: string; -}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => { - const isRowSelected = useCallback((row: Record) => { - return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row)); - }, [selectedRows]); - - const handleItemClick = useCallback((row: Record) => { - if (onRowSelect) { - const isSelected = isRowSelected(row); - if (isSelected) { - onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row))); - } else { - onRowSelect([...selectedRows, row]); - } - } - onRowClick?.(row); - }, [selectedRows, isRowSelected, onRowSelect, onRowClick]); - - const [primaryColumn, secondaryColumn] = columns; - - return ( -
- {data.length === 0 ? ( -
- 데이터가 없습니다 -
- ) : ( - data.map((row, index) => ( -
handleItemClick(row)} - > - {onRowSelect && ( - e.stopPropagation()} - onCheckedChange={(checked) => { - if (checked) { - onRowSelect([...selectedRows, row]); - } else { - onRowSelect(selectedRows.filter((r) => r !== row)); - } - }} - /> - )} - -
-
- {primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)} -
- {secondaryColumn && ( -
- {formatCellValue(row[secondaryColumn.field], secondaryColumn.format)} -
- )} -
-
- )) - )} -
- ); -}); -ListView.displayName = "ListView"; - -/** - * 셀 값 포맷팅 - */ -function formatCellValue(value: unknown, format?: string): React.ReactNode { - if (value === null || value === undefined) return "-"; - - if (format) { - switch (format) { - case "date": - return new Date(String(value)).toLocaleDateString("ko-KR"); - case "datetime": - return new Date(String(value)).toLocaleString("ko-KR"); - case "currency": - return Number(value).toLocaleString("ko-KR") + "원"; - case "number": - return Number(value).toLocaleString("ko-KR"); - case "percent": - return Number(value).toFixed(1) + "%"; - default: - return String(value); - } - } - - return String(value); -} +import React, { forwardRef, useMemo } from "react"; +import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent"; +import { UnifiedListProps } from "@/types/unified-components"; /** * 메인 UnifiedList 컴포넌트 + * 기존 TableListComponent의 모든 기능을 그대로 사용 */ -export const UnifiedList = forwardRef( - (props, ref) => { - const { +export const UnifiedList = forwardRef((props, ref) => { + const { id, style, size, config: configProp, onRowSelect } = props; + + // config가 없으면 기본값 사용 + const config = configProp || { + viewMode: "table" as const, + source: "static" as const, + columns: [], + }; + + // 테이블명 추출 + const tableName = config.dataSource?.table || (props as any).tableName; + + // columns 형식 변환 (UnifiedListConfigPanel 형식 -> TableListComponent 형식) + const tableColumns = useMemo( + () => + (config.columns || []).map((col: any, index: number) => ({ + columnName: col.key || col.field || "", + displayName: col.title || col.header || col.key || col.field || "", + width: col.width ? parseInt(col.width, 10) : undefined, + visible: true, + sortable: true, + searchable: true, + align: "left" as const, + order: index, + isEntityJoin: col.isJoinColumn || false, + })), + [config.columns], + ); + + // 디버깅: config.cardConfig 확인 + console.log("📋 UnifiedList config.cardConfig:", config.cardConfig); + + // TableListComponent에 전달할 component 객체 생성 + const componentObj = useMemo( + () => ({ + id: id || "unified-list", + type: "table-list", + config: { + selectedTable: tableName, + tableName: tableName, + columns: tableColumns, + displayMode: config.viewMode === "card" ? "card" : "table", + cardConfig: { + idColumn: tableColumns[0]?.columnName || "id", + titleColumn: tableColumns[0]?.columnName || "", + subtitleColumn: undefined, + descriptionColumn: undefined, + imageColumn: undefined, + cardsPerRow: 3, + cardSpacing: 16, + showActions: false, + ...config.cardConfig, + }, + showHeader: true, + showFooter: false, + checkbox: { + enabled: !!onRowSelect, + position: "left" as const, + showHeader: true, + }, + height: "fixed" as const, + autoWidth: true, + stickyHeader: true, + autoLoad: true, + horizontalScroll: { + enabled: true, + minColumnWidth: 100, + maxColumnWidth: 300, + }, + pagination: { + enabled: config.pageable !== false, + pageSize: config.pageSize || 20, + position: "bottom" as const, + showPageSize: true, + pageSizeOptions: [10, 20, 50, 100], + }, + filter: { + enabled: config.searchable !== false, + position: "top" as const, + searchPlaceholder: "검색...", + }, + actions: { + enabled: false, + items: [], + }, + tableStyle: { + striped: false, + bordered: true, + hover: true, + compact: false, + }, + toolbar: { + showRefresh: true, + showExport: false, + showColumnToggle: false, + }, + }, + style: {}, + gridColumns: 1, + }), + [ id, - label, - style, - size, - config: configProp, - data = [], - selectedRows = [], + tableName, + tableColumns, + config.viewMode, + config.pageable, + config.searchable, + config.pageSize, + config.cardConfig, onRowSelect, - onRowClick, - } = props; - - // config가 없으면 기본값 사용 - const config = configProp || { viewMode: "table" as const, source: "static" as const, columns: [] }; - - // 내부 상태 - const [searchTerm, setSearchTerm] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [sortColumn, setSortColumn] = useState(); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - - const pageSize = config.pageSize || 10; - const columns = config.columns || []; - - // 검색 필터링 - const filteredData = useMemo(() => { - if (!searchTerm || !config.searchable) return data; - - const term = searchTerm.toLowerCase(); - return data.filter((row) => - columns.some((col) => { - const value = row[col.field]; - return value && String(value).toLowerCase().includes(term); - }) - ); - }, [data, searchTerm, config.searchable, columns]); - - // 정렬 - const sortedData = useMemo(() => { - if (!sortColumn) return filteredData; - - return [...filteredData].sort((a, b) => { - const aVal = a[sortColumn]; - const bVal = b[sortColumn]; - - if (aVal === bVal) return 0; - if (aVal === null || aVal === undefined) return 1; - if (bVal === null || bVal === undefined) return -1; - - const comparison = String(aVal).localeCompare(String(bVal), "ko-KR", { numeric: true }); - return sortDirection === "asc" ? comparison : -comparison; - }); - }, [filteredData, sortColumn, sortDirection]); - - // 페이지네이션 - const paginatedData = useMemo(() => { - if (!config.pageable) return sortedData; - - const start = (currentPage - 1) * pageSize; - return sortedData.slice(start, start + pageSize); - }, [sortedData, currentPage, pageSize, config.pageable]); - - const totalPages = Math.ceil(sortedData.length / pageSize); - - // 정렬 핸들러 - const handleSort = useCallback((column: string) => { - if (sortColumn === column) { - setSortDirection((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortColumn(column); - setSortDirection("asc"); - } - }, [sortColumn]); - - // 뷰모드별 렌더링 - const renderView = () => { - const viewProps = { - columns, - data: paginatedData, - selectedRows, - onRowSelect, - onRowClick, - editable: config.editable, - sortColumn, - sortDirection, - onSort: handleSort, - }; - - switch (config.viewMode) { - case "table": - return ; - case "card": - return ; - case "list": - return ; - case "kanban": - // TODO: 칸반 뷰 구현 - return ( -
- 칸반 뷰 (미구현) -
- ); - default: - return ; - } - }; - - const showLabel = label && style?.labelDisplay !== false; - const showSearch = config.searchable; - const componentWidth = size?.width || style?.width; - const componentHeight = size?.height || style?.height; + ], + ); + // 테이블이 없으면 안내 메시지 + if (!tableName) { return (
- {/* 헤더 영역 */} - {(showLabel || showSearch) && ( -
- {showLabel && ( - - )} - - {/* 검색 */} - {showSearch && ( -
- - { - setSearchTerm(e.target.value); - setCurrentPage(1); - }} - className="pl-10 h-9" - /> -
- )} -
- )} - - {/* 데이터 뷰 */} -
- {renderView()} -
- - {/* 페이지네이션 */} - {config.pageable && totalPages > 1 && ( -
-
- 총 {sortedData.length}건 중 {(currentPage - 1) * pageSize + 1}- - {Math.min(currentPage * pageSize, sortedData.length)}건 -
- - - - setCurrentPage((p) => Math.max(1, p - 1))} - className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} - /> - - {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - const page = i + 1; - return ( - - setCurrentPage(page)} - isActive={currentPage === page} - className="cursor-pointer" - > - {page} - - - ); - })} - - setCurrentPage((p) => Math.min(totalPages, p + 1))} - className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} - /> - - - -
- )} +

테이블이 설정되지 않았습니다.

); } -); + + return ( +
+ { + onRowSelect(selectedData); + } + : undefined + } + /> +
+ ); +}); UnifiedList.displayName = "UnifiedList"; - -export default UnifiedList; - diff --git a/frontend/components/unified/config-panels/UnifiedListConfigPanel.tsx b/frontend/components/unified/config-panels/UnifiedListConfigPanel.tsx index e529d988..fd42e3ae 100644 --- a/frontend/components/unified/config-panels/UnifiedListConfigPanel.tsx +++ b/frontend/components/unified/config-panels/UnifiedListConfigPanel.tsx @@ -3,121 +3,224 @@ /** * UnifiedList 설정 패널 * 통합 목록 컴포넌트의 세부 설정을 관리합니다. + * - 현재 화면의 테이블 데이터를 사용 + * - 테이블 컬럼 + 엔티티 조인 컬럼 선택 지원 */ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, Trash2 } from "lucide-react"; +import { Database, Link2, GripVertical, ChevronDown, ChevronRight } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; +import { cn } from "@/lib/utils"; interface UnifiedListConfigPanelProps { config: Record; onChange: (config: Record) => void; -} - -interface TableOption { - tableName: string; - displayName: string; + /** 현재 화면의 테이블명 */ + currentTableName?: string; } interface ColumnOption { columnName: string; displayName: string; + isJoinColumn?: boolean; + sourceTable?: string; + inputType?: string; } export const UnifiedListConfigPanel: React.FC = ({ config, onChange, + currentTableName, }) => { - // 테이블 목록 - const [tables, setTables] = useState([]); - const [loadingTables, setLoadingTables] = useState(false); - - // 컬럼 목록 + // 컬럼 목록 (테이블 컬럼 + 엔티티 조인 컬럼) const [columns, setColumns] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); + const [expandedJoinSections, setExpandedJoinSections] = useState>(new Set()); // 설정 업데이트 핸들러 const updateConfig = (field: string, value: any) => { - onChange({ ...config, [field]: value }); + const newConfig = { ...config, [field]: value }; + console.log("⚙️ UnifiedListConfigPanel updateConfig:", { field, value, newConfig }); + onChange(newConfig); }; - // 테이블 목록 로드 - useEffect(() => { - const loadTables = async () => { - setLoadingTables(true); - try { - const data = await tableTypeApi.getTables(); - setTables(data.map(t => ({ - tableName: t.tableName, - displayName: t.displayName || t.tableName - }))); - } catch (error) { - console.error("테이블 목록 로드 실패:", error); - } finally { - setLoadingTables(false); - } - }; - loadTables(); - }, []); + // 테이블명 (현재 화면의 테이블 사용) + const tableName = currentTableName || config.tableName; - // 테이블 선택 시 컬럼 목록 로드 + // 테이블 컬럼 및 엔티티 조인 컬럼 로드 useEffect(() => { const loadColumns = async () => { - if (!config.tableName) { + if (!tableName) { setColumns([]); return; } - + setLoadingColumns(true); try { - const data = await tableTypeApi.getColumns(config.tableName); - setColumns(data.map((c: any) => ({ + // 1. 테이블 컬럼 로드 + const columnData = await tableTypeApi.getColumns(tableName); + const baseColumns: ColumnOption[] = columnData.map((c: any) => ({ columnName: c.columnName || c.column_name, - displayName: c.displayName || c.columnName || c.column_name - }))); + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + isJoinColumn: false, + inputType: c.inputType || c.input_type || c.webType || c.web_type, + })); + + // 2. 엔티티 타입 컬럼 찾기 및 조인 컬럼 정보 로드 + const entityColumns = columnData.filter((c: any) => (c.inputType || c.input_type) === "entity"); + + const joinColumnOptions: ColumnOption[] = []; + + for (const entityCol of entityColumns) { + const colName = entityCol.columnName || entityCol.column_name; + + // referenceTable 우선순위: + // 1. 컬럼의 reference_table 필드 + // 2. detailSettings.referenceTable + let referenceTable = entityCol.referenceTable || entityCol.reference_table; + + if (!referenceTable) { + let detailSettings = entityCol.detailSettings || entityCol.detail_settings; + if (typeof detailSettings === "string") { + try { + detailSettings = JSON.parse(detailSettings); + } catch { + continue; + } + } + referenceTable = detailSettings?.referenceTable; + } + + if (referenceTable) { + try { + const refColumnData = await tableTypeApi.getColumns(referenceTable); + + refColumnData.forEach((refCol: any) => { + const refColName = refCol.columnName || refCol.column_name; + const refDisplayName = refCol.displayName || refCol.columnLabel || refColName; + + joinColumnOptions.push({ + columnName: `${colName}.${refColName}`, + displayName: refDisplayName, + isJoinColumn: true, + sourceTable: referenceTable, + }); + }); + } catch (error) { + console.error(`참조 테이블 ${referenceTable} 컬럼 로드 실패:`, error); + } + } + } + + setColumns([...baseColumns, ...joinColumnOptions]); } catch (error) { console.error("컬럼 목록 로드 실패:", error); + setColumns([]); } finally { setLoadingColumns(false); } }; + loadColumns(); - }, [config.tableName]); + }, [tableName]); - // 컬럼 관리 - const configColumns = config.columns || []; + // 컬럼 설정 + const configColumns: Array<{ key: string; title: string; width?: string; isJoinColumn?: boolean }> = + config.columns || []; - const addColumn = () => { - const newColumns = [...configColumns, { key: "", title: "", width: "" }]; + // 컬럼이 추가되었는지 확인 + const isColumnAdded = (columnName: string) => { + return configColumns.some((col) => col.key === columnName); + }; + + // 컬럼 토글 (추가/제거) + const toggleColumn = (column: ColumnOption) => { + if (isColumnAdded(column.columnName)) { + // 제거 + const newColumns = configColumns.filter((col) => col.key !== column.columnName); + updateConfig("columns", newColumns); + } else { + // 추가 + const newColumn = { + key: column.columnName, + title: column.displayName, + width: "", + isJoinColumn: column.isJoinColumn || false, + }; + updateConfig("columns", [...configColumns, newColumn]); + } + }; + + // 컬럼 제목 수정 + const updateColumnTitle = (columnKey: string, title: string) => { + const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, title } : col)); updateConfig("columns", newColumns); }; - const updateColumn = (index: number, field: string, value: string) => { - const newColumns = [...configColumns]; - newColumns[index] = { ...newColumns[index], [field]: value }; + // 컬럼 너비 수정 + const updateColumnWidth = (columnKey: string, width: string) => { + const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, width } : col)); updateConfig("columns", newColumns); }; - const removeColumn = (index: number) => { - const newColumns = configColumns.filter((_: any, i: number) => i !== index); - updateConfig("columns", newColumns); + // 그룹별 컬럼 분리 + const baseColumns = useMemo(() => columns.filter((col) => !col.isJoinColumn), [columns]); + + // 조인 컬럼을 소스 테이블별로 그룹화 + const joinColumnsByTable = useMemo(() => { + const grouped: Record = {}; + columns + .filter((col) => col.isJoinColumn) + .forEach((col) => { + const table = col.sourceTable || "unknown"; + if (!grouped[table]) { + grouped[table] = []; + } + grouped[table].push(col); + }); + return grouped; + }, [columns]); + + // 조인 섹션 토글 + const toggleJoinSection = (tableName: string) => { + setExpandedJoinSections((prev) => { + const newSet = new Set(prev); + if (newSet.has(tableName)) { + newSet.delete(tableName); + } else { + newSet.add(tableName); + } + return newSet; + }); }; return (
+ {/* 데이터 소스 정보 (읽기 전용) */} +
+ + {tableName ? ( +
+ + {tableName} +
+ ) : ( +

화면에 테이블이 설정되지 않았습니다

+ )} +
+ + + {/* 뷰 모드 */}
- updateConfig("viewMode", value)}> @@ -130,153 +233,226 @@ export const UnifiedListConfigPanel: React.FC = ({
- + {/* 카드 모드 설정 */} + {config.viewMode === "card" && ( + <> + +
+ - {/* 데이터 소스 */} -
- - -
- - {/* DB 설정 */} - {config.source === "db" && ( -
- - -
- )} - - {/* API 설정 */} - {config.source === "api" && ( -
- - updateConfig("apiEndpoint", e.target.value)} - placeholder="/api/list" - className="h-8 text-xs" - /> -
- )} - - - - {/* 컬럼 설정 */} -
-
- - -
-
- {configColumns.map((column: any, index: number) => ( -
- {/* 컬럼 키 - 드롭다운 */} + {/* 제목 컬럼 */} +
+ - updateColumn(index, "title", e.target.value)} - placeholder="제목" - className="h-7 text-xs flex-1" - /> - updateColumn(index, "width", e.target.value)} - placeholder="너비" - className="h-7 text-xs w-16" - /> -
- ))} - {configColumns.length === 0 && ( -

- 컬럼을 추가해주세요 -

- )} -
+ + {/* 부제목 컬럼 */} +
+ + +
+ + {/* 행당 카드 수 */} +
+ + +
+
+ + )} + + + + {/* 컬럼 선택 */} +
+ + + {loadingColumns ? ( +

컬럼 로딩 중...

+ ) : !tableName ? ( +

테이블을 선택해주세요

+ ) : ( +
+ {/* 테이블 컬럼 */} +
+ {baseColumns.map((column) => ( +
toggleColumn(column)} + > + toggleColumn(column)} + className="pointer-events-none h-3.5 w-3.5 flex-shrink-0" + /> + + {column.displayName} +
+ ))} +
+ + {/* 조인 컬럼 (테이블별 그룹) */} + {Object.keys(joinColumnsByTable).length > 0 && ( +
+
+ + 엔티티 조인 컬럼 +
+ {Object.entries(joinColumnsByTable).map(([refTable, refColumns]) => ( +
+
toggleJoinSection(refTable)} + > + {expandedJoinSections.has(refTable) ? ( + + ) : ( + + )} + {refTable} + ({refColumns.length}) +
+ + {expandedJoinSections.has(refTable) && ( +
+ {refColumns.map((column) => ( +
toggleColumn(column)} + > + toggleColumn(column)} + className="pointer-events-none h-3.5 w-3.5 flex-shrink-0" + /> + {column.displayName} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ )}
+ {/* 선택된 컬럼 상세 설정 */} + {configColumns.length > 0 && ( + <> + +
+ +
+ {configColumns.map((column, index) => { + const colInfo = columns.find((c) => c.columnName === column.key); + return ( +
+ + {column.isJoinColumn ? ( + + ) : ( + + )} + updateColumnTitle(column.key, e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + updateColumnWidth(column.key, e.target.value)} + placeholder="너비" + className="h-6 w-14 text-xs" + /> + +
+ ); + })} +
+
+ + )} + {/* 기능 옵션 */}
- +
updateConfig("sortable", checked)} /> - +
@@ -285,7 +461,9 @@ export const UnifiedListConfigPanel: React.FC = ({ checked={config.pagination !== false} onCheckedChange={(checked) => updateConfig("pagination", checked)} /> - +
@@ -294,7 +472,9 @@ export const UnifiedListConfigPanel: React.FC = ({ checked={config.searchable || false} onCheckedChange={(checked) => updateConfig("searchable", checked)} /> - +
@@ -303,30 +483,35 @@ export const UnifiedListConfigPanel: React.FC = ({ checked={config.editable || false} onCheckedChange={(checked) => updateConfig("editable", checked)} /> - +
{/* 페이지 크기 */} {config.pagination !== false && ( -
- - -
+ <> + +
+ + +
+ )}
); diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 4de9d4f7..4e3e343d 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -42,7 +42,14 @@ export interface ComponentRenderer { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; + onSelectedRowsChange?: ( + selectedRows: any[], + selectedRowsData: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + tableDisplayData?: any[], + ) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; @@ -126,7 +133,14 @@ export interface DynamicComponentRendererProps { // 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트) disabledFields?: string[]; selectedRowsData?: any[]; - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; + onSelectedRowsChange?: ( + selectedRows: any[], + selectedRowsData: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + tableDisplayData?: any[], + ) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; @@ -146,7 +160,7 @@ export interface DynamicComponentRendererProps { // 모달 내에서 렌더링 여부 isInModal?: boolean; // 탭 관련 정보 (탭 내부의 컴포넌트에서 사용) - parentTabId?: string; // 부모 탭 ID + parentTabId?: string; // 부모 탭 ID parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID // 🆕 조건부 비활성화 상태 conditionalDisabled?: boolean; @@ -172,7 +186,7 @@ export const DynamicComponentRenderer: React.FC = const config = (component as any).componentConfig || {}; const fieldName = (component as any).columnName || component.id; const currentValue = props.formData?.[fieldName]; - + const handleChange = (value: any) => { if (props.onFormDataChange) { props.onFormDataChange(fieldName, value); @@ -259,6 +273,9 @@ export const DynamicComponentRenderer: React.FC = ); case "unified-list": + // 데이터 소스: config.data > props.tableDisplayData > [] + const listData = config.data?.length > 0 ? config.data : props.tableDisplayData || []; + return ( = pagination: config.pagination, searchable: config.searchable, editable: config.editable, + pageable: config.pageable, + pageSize: config.pageSize, + cardConfig: config.cardConfig, + dataSource: { + table: config.dataSource?.table || props.tableName, + }, }} - data={config.data || []} + data={listData} + selectedRows={props.selectedRowsData || []} + onRowSelect={ + props.onSelectedRowsChange + ? (rows) => + props.onSelectedRowsChange?.( + rows.map((r: any) => r.id || r.objid), + rows, + props.sortBy, + props.sortOrder, + undefined, + props.tableDisplayData, + ) + : undefined + } /> ); @@ -372,17 +409,22 @@ export const DynamicComponentRenderer: React.FC = const webType = (component as any).componentConfig?.webType; const tableName = (component as any).tableName; const columnName = (component as any).columnName; - + // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 // ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원) - if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") { + if ( + (inputType === "category" || webType === "category") && + tableName && + columnName && + componentType === "select-basic" + ) { // select-basic은 ComponentRegistry에서 처리하도록 아래로 통과 } else if ((inputType === "category" || webType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; const currentValue = props.formData?.[fieldName] || ""; - + const handleChange = (value: any) => { if (props.onFormDataChange) { props.onFormDataChange(fieldName, value); @@ -496,7 +538,7 @@ export const DynamicComponentRenderer: React.FC = // 컴포넌트의 columnName에 해당하는 formData 값 추출 const fieldName = (component as any).columnName || component.id; - + // modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화 let currentValue; if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") { @@ -505,7 +547,7 @@ export const DynamicComponentRenderer: React.FC = } else { currentValue = formData?.[fieldName] || ""; } - + // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { // autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지 @@ -551,10 +593,11 @@ export const DynamicComponentRenderer: React.FC = }; // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블) - const useConfigTableName = componentType === "entity-search-input" || - componentType === "autocomplete-search-input" || - componentType === "modal-repeater-table"; - + const useConfigTableName = + componentType === "entity-search-input" || + componentType === "autocomplete-search-input" || + componentType === "modal-repeater-table"; + const rendererProps = { component, isSelected, @@ -578,7 +621,7 @@ export const DynamicComponentRenderer: React.FC = onFormDataChange, onChange: handleChange, // 개선된 onChange 핸들러 전달 // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용 - tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName, + tableName: useConfigTableName ? component.componentConfig?.tableName || tableName : tableName, menuId, // 🆕 메뉴 ID menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) selectedScreen, // 🆕 화면 정보 @@ -677,10 +720,10 @@ export const DynamicComponentRenderer: React.FC = // 폴백 렌더링 - 기본 플레이스홀더 return ( -
+
-
{component.label || component.id}
-
미구현 컴포넌트: {componentType}
+
{component.label || component.id}
+
미구현 컴포넌트: {componentType}
); diff --git a/frontend/lib/registry/components/table-list/CardModeRenderer.tsx b/frontend/lib/registry/components/table-list/CardModeRenderer.tsx index ad686e51..f80eee19 100644 --- a/frontend/lib/registry/components/table-list/CardModeRenderer.tsx +++ b/frontend/lib/registry/components/table-list/CardModeRenderer.tsx @@ -11,10 +11,9 @@ interface CardModeRendererProps { data: Record[]; cardConfig: CardDisplayConfig; visibleColumns: ColumnConfig[]; - onRowClick?: (row: Record) => void; + onRowClick?: (row: Record, index: number, e: React.MouseEvent) => void; onRowSelect?: (row: Record, selected: boolean) => void; selectedRows?: string[]; - showActions?: boolean; } /** @@ -26,19 +25,24 @@ export const CardModeRenderer: React.FC = ({ cardConfig, visibleColumns, onRowClick, - onRowSelect, selectedRows = [], - showActions = true, }) => { - // 기본값 설정 + // 기본값과 병합 const config = { - cardsPerRow: 3, - cardSpacing: 16, - showActions: true, - cardHeight: "auto", - ...cardConfig, + idColumn: cardConfig?.idColumn || "", + titleColumn: cardConfig?.titleColumn || "", + subtitleColumn: cardConfig?.subtitleColumn, + descriptionColumn: cardConfig?.descriptionColumn, + imageColumn: cardConfig?.imageColumn, + cardsPerRow: cardConfig?.cardsPerRow ?? 3, + cardSpacing: cardConfig?.cardSpacing ?? 16, + showActions: cardConfig?.showActions ?? true, + cardHeight: cardConfig?.cardHeight as number | "auto" | undefined, }; + // 디버깅: cardConfig 확인 + console.log("🃏 CardModeRenderer config:", { cardConfig, mergedConfig: config }); + // 카드 그리드 스타일 계산 const gridStyle: React.CSSProperties = { display: "grid", @@ -60,11 +64,11 @@ export const CardModeRenderer: React.FC = ({ }; // 액션 버튼 렌더링 - const renderActions = (row: Record) => { - if (!showActions || !config.showActions) return null; + const renderActions = (_row: Record) => { + if (!config.showActions) return null; return ( -
+