카드 디스플레이 옵션 설정

This commit is contained in:
kjs 2025-12-23 13:53:22 +09:00
parent 01e47a1830
commit 9c26738604
8 changed files with 963 additions and 1065 deletions

View File

@ -354,8 +354,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 컬럼의 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<string, any> = {};
if (componentId === "unified-select") {
extraProps.inputType = inputType;
}
if (componentId === "unified-list") {
extraProps.currentTableName = currentTableName;
}
return (
<div key={selectedComponent.id} className="space-y-4">

View File

@ -4,552 +4,178 @@
* 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<HTMLDivElement, {
columns: ListColumn[];
data: Record<string, unknown>[];
selectedRows: Record<string, unknown>[];
onRowSelect?: (rows: Record<string, unknown>[]) => void;
onRowClick?: (row: Record<string, unknown>) => 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<string, unknown>) => {
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<string, unknown>, 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 (
<div ref={ref} className={cn("border rounded-md overflow-hidden", className)}>
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
{onRowSelect && (
<TableHead className="w-10">
<Checkbox
checked={allSelected}
// indeterminate 상태는 data-state로 처리
data-state={someSelected ? "indeterminate" : allSelected ? "checked" : "unchecked"}
onCheckedChange={handleSelectAll}
/>
</TableHead>
)}
{columns.map((column) => (
<TableHead
key={column.field}
className={cn(
column.sortable && "cursor-pointer select-none hover:bg-muted",
)}
style={{ width: column.width ? `${column.width}px` : "auto" }}
onClick={() => column.sortable && onSort?.(column.field)}
>
<div className="flex items-center gap-1">
{column.header}
{column.sortable && sortColumn === column.field && (
sortDirection === "asc"
? <ChevronUp className="h-4 w-4" />
: <ChevronDown className="h-4 w-4" />
)}
</div>
</TableHead>
))}
{editable && <TableHead className="w-10" />}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length + (onRowSelect ? 1 : 0) + (editable ? 1 : 0)}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={index}
className={cn(
"cursor-pointer hover:bg-muted/50",
isRowSelected(row) && "bg-primary/10"
)}
onClick={() => onRowClick?.(row)}
>
{onRowSelect && (
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isRowSelected(row)}
onCheckedChange={(checked) => handleSelectRow(row, checked as boolean)}
/>
</TableCell>
)}
{columns.map((column) => (
<TableCell key={column.field}>
{formatCellValue(row[column.field], column.format)}
</TableCell>
))}
{editable && (
<TableCell onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
});
TableView.displayName = "TableView";
/**
*
*/
const CardView = forwardRef<HTMLDivElement, {
columns: ListColumn[];
data: Record<string, unknown>[];
selectedRows: Record<string, unknown>[];
onRowSelect?: (rows: Record<string, unknown>[]) => void;
onRowClick?: (row: Record<string, unknown>) => void;
className?: string;
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
const isRowSelected = useCallback((row: Record<string, unknown>) => {
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
}, [selectedRows]);
const handleCardClick = useCallback((row: Record<string, unknown>) => {
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 (
<div ref={ref} className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", className)}>
{data.length === 0 ? (
<div className="col-span-full py-12 text-center text-muted-foreground">
</div>
) : (
data.map((row, index) => (
<Card
key={index}
className={cn(
"cursor-pointer transition-colors hover:border-primary",
isRowSelected(row) && "border-primary bg-primary/5"
)}
onClick={() => handleCardClick(row)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<dl className="space-y-1 text-sm">
{otherColumns.slice(0, 4).map((column) => (
<div key={column.field} className="flex justify-between">
<dt className="text-muted-foreground">{column.header}</dt>
<dd className="font-medium">
{formatCellValue(row[column.field], column.format)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
))
)}
</div>
);
});
CardView.displayName = "CardView";
/**
*
*/
const ListView = forwardRef<HTMLDivElement, {
columns: ListColumn[];
data: Record<string, unknown>[];
selectedRows: Record<string, unknown>[];
onRowSelect?: (rows: Record<string, unknown>[]) => void;
onRowClick?: (row: Record<string, unknown>) => void;
className?: string;
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
const isRowSelected = useCallback((row: Record<string, unknown>) => {
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
}, [selectedRows]);
const handleItemClick = useCallback((row: Record<string, unknown>) => {
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 (
<div ref={ref} className={cn("divide-y border rounded-md", className)}>
{data.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
</div>
) : (
data.map((row, index) => (
<div
key={index}
className={cn(
"flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50",
isRowSelected(row) && "bg-primary/10"
)}
onClick={() => handleItemClick(row)}
>
{onRowSelect && (
<Checkbox
checked={isRowSelected(row)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
if (checked) {
onRowSelect([...selectedRows, row]);
} else {
onRowSelect(selectedRows.filter((r) => r !== row));
}
}}
/>
)}
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
</div>
{secondaryColumn && (
<div className="text-sm text-muted-foreground truncate">
{formatCellValue(row[secondaryColumn.field], secondaryColumn.format)}
</div>
)}
</div>
</div>
))
)}
</div>
);
});
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<HTMLDivElement, UnifiedListProps>(
(props, ref) => {
const {
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>((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<string | undefined>();
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 <TableView {...viewProps} />;
case "card":
return <CardView {...viewProps} />;
case "list":
return <ListView {...viewProps} />;
case "kanban":
// TODO: 칸반 뷰 구현
return (
<div className="p-4 border rounded text-center text-muted-foreground">
()
</div>
);
default:
return <TableView {...viewProps} />;
}
};
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 (
<div
ref={ref}
id={id}
className="flex flex-col gap-4"
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
style={{
width: componentWidth,
height: componentHeight,
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400,
}}
>
{/* 헤더 영역 */}
{(showLabel || showSearch) && (
<div className="flex items-center justify-between gap-4 flex-shrink-0">
{showLabel && (
<Label className="text-lg font-semibold">{label}</Label>
)}
{/* 검색 */}
{showSearch && (
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="검색..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="pl-10 h-9"
/>
</div>
)}
</div>
)}
{/* 데이터 뷰 */}
<div className="flex-1 min-h-0 overflow-auto">
{renderView()}
</div>
{/* 페이지네이션 */}
{config.pageable && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{sortedData.length} {(currentPage - 1) * pageSize + 1}-
{Math.min(currentPage * pageSize, sortedData.length)}
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1;
return (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
}
);
return (
<div
ref={ref}
id={id}
className="flex flex-col overflow-hidden"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400,
}}
>
<TableListComponent
component={componentObj}
tableName={tableName}
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
onSelectedRowsChange={
onRowSelect
? (_, selectedData) => {
onRowSelect(selectedData);
}
: undefined
}
/>
</div>
);
});
UnifiedList.displayName = "UnifiedList";
export default UnifiedList;

View File

@ -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<string, any>;
onChange: (config: Record<string, any>) => 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<UnifiedListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록
// 컬럼 목록 (테이블 컬럼 + 엔티티 조인 컬럼)
const [columns, setColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [expandedJoinSections, setExpandedJoinSections] = useState<Set<string>>(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<string, ColumnOption[]> = {};
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 (
<div className="space-y-4">
{/* 데이터 소스 정보 (읽기 전용) */}
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
{tableName ? (
<div className="flex items-center gap-2">
<Database className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">{tableName}</span>
</div>
) : (
<p className="text-xs text-amber-600"> </p>
)}
</div>
<Separator />
{/* 뷰 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.viewMode || "table"}
onValueChange={(value) => updateConfig("viewMode", value)}
>
<Select value={config.viewMode || "table"} onValueChange={(value) => updateConfig("viewMode", value)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="방식 선택" />
</SelectTrigger>
@ -130,140 +233,211 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
</Select>
</div>
<Separator />
{/* 카드 모드 설정 */}
{config.viewMode === "card" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{/* 데이터 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.source || "static"}
onValueChange={(value) => updateConfig("source", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="db"></SelectItem>
<SelectItem value="api">API</SelectItem>
</SelectContent>
</Select>
</div>
{/* DB 설정 */}
{config.source === "db" && (
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Select
value={config.tableName || ""}
onValueChange={(value) => {
updateConfig("tableName", value);
updateConfig("columns", []); // 테이블 변경 시 컬럼 초기화
}}
disabled={loadingTables}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* API 설정 */}
{config.source === "api" && (
<div className="space-y-2">
<Label className="text-xs font-medium">API </Label>
<Input
value={config.apiEndpoint || ""}
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
placeholder="/api/list"
className="h-8 text-xs"
/>
</div>
)}
<Separator />
{/* 컬럼 설정 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addColumn}
className="h-6 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{configColumns.map((column: any, index: number) => (
<div key={index} className="flex items-center gap-2">
{/* 컬럼 키 - 드롭다운 */}
{/* 제목 컬럼 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={column.key || ""}
onValueChange={(value) => {
const selectedCol = columns.find(c => c.columnName === value);
updateColumn(index, "key", value);
// 제목을 자동으로 설정
if (selectedCol && !column.title) {
updateColumn(index, "title", selectedCol.displayName);
}
}}
disabled={loadingColumns || !config.tableName}
value={config.cardConfig?.titleColumn || ""}
onValueChange={(value) => updateConfig("cardConfig", { ...config.cardConfig, titleColumn: value })}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="컬럼" />
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
{configColumns.map((col: any) => (
<SelectItem key={col.key} value={col.key}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={column.title || ""}
onChange={(e) => updateColumn(index, "title", e.target.value)}
placeholder="제목"
className="h-7 text-xs flex-1"
/>
<Input
value={column.width || ""}
onChange={(e) => updateColumn(index, "width", e.target.value)}
placeholder="너비"
className="h-7 text-xs w-16"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeColumn(index)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{configColumns.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
</p>
)}
</div>
{/* 부제목 컬럼 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={config.cardConfig?.subtitleColumn || "_none_"}
onValueChange={(value) =>
updateConfig("cardConfig", { ...config.cardConfig, subtitleColumn: value === "_none_" ? "" : value })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"></SelectItem>
{configColumns.map((col: any) => (
<SelectItem key={col.key} value={col.key}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 행당 카드 수 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={String(config.cardConfig?.cardsPerRow || 3)}
onValueChange={(value) =>
updateConfig("cardConfig", { ...config.cardConfig, cardsPerRow: parseInt(value) })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
)}
<Separator />
{/* 컬럼 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{loadingColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : !tableName ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : (
<div className="max-h-60 space-y-2 overflow-y-auto rounded-md border p-2">
{/* 테이블 컬럼 */}
<div className="space-y-0.5">
{baseColumns.map((column) => (
<div
key={column.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleColumn(column)}
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.displayName}</span>
</div>
))}
</div>
{/* 조인 컬럼 (테이블별 그룹) */}
{Object.keys(joinColumnsByTable).length > 0 && (
<div className="mt-2 border-t pt-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 text-[10px] font-medium">
<Link2 className="h-3 w-3 text-blue-500" />
</div>
{Object.entries(joinColumnsByTable).map(([refTable, refColumns]) => (
<div key={refTable} className="mb-1">
<div
className="hover:bg-muted/30 flex cursor-pointer items-center gap-1 rounded px-1 py-0.5"
onClick={() => toggleJoinSection(refTable)}
>
{expandedJoinSections.has(refTable) ? (
<ChevronDown className="text-muted-foreground h-3 w-3 flex-shrink-0" />
) : (
<ChevronRight className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<span className="truncate text-[10px] font-medium text-blue-600">{refTable}</span>
<span className="text-muted-foreground text-[10px]">({refColumns.length})</span>
</div>
{expandedJoinSections.has(refTable) && (
<div className="ml-3 space-y-0.5">
{refColumns.map((column) => (
<div
key={column.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-blue-50",
)}
onClick={() => toggleColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleColumn(column)}
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-xs">{column.displayName}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
{/* 선택된 컬럼 상세 설정 */}
{configColumns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"> ({configColumns.length})</Label>
<div className="max-h-40 space-y-1 overflow-y-auto">
{configColumns.map((column, index) => {
const colInfo = columns.find((c) => c.columnName === column.key);
return (
<div key={column.key} className="bg-muted/30 flex items-center gap-2 rounded-md border p-2">
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab" />
{column.isJoinColumn ? (
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
value={column.title}
onChange={(e) => updateColumnTitle(column.key, e.target.value)}
placeholder="제목"
className="h-6 flex-1 text-xs"
/>
<Input
value={column.width || ""}
onChange={(e) => updateColumnWidth(column.key, e.target.value)}
placeholder="너비"
className="h-6 w-14 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleColumn(colInfo || { columnName: column.key, displayName: column.title })}
className="text-destructive h-6 w-6 p-0"
>
×
</Button>
</div>
);
})}
</div>
</div>
</>
)}
<Separator />
{/* 기능 옵션 */}
@ -276,7 +450,9 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
checked={config.sortable !== false}
onCheckedChange={(checked) => updateConfig("sortable", checked)}
/>
<label htmlFor="sortable" className="text-xs"> </label>
<label htmlFor="sortable" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
@ -285,7 +461,9 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
checked={config.pagination !== false}
onCheckedChange={(checked) => updateConfig("pagination", checked)}
/>
<label htmlFor="pagination" className="text-xs"></label>
<label htmlFor="pagination" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
@ -294,7 +472,9 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)}
/>
<label htmlFor="searchable" className="text-xs"> </label>
<label htmlFor="searchable" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
@ -303,30 +483,35 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
checked={config.editable || false}
onCheckedChange={(checked) => updateConfig("editable", checked)}
/>
<label htmlFor="editable" className="text-xs"> </label>
<label htmlFor="editable" className="text-xs">
</label>
</div>
</div>
{/* 페이지 크기 */}
{config.pagination !== false && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={String(config.pageSize || 10)}
onValueChange={(value) => updateConfig("pageSize", Number(value))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={String(config.pageSize || 10)}
onValueChange={(value) => updateConfig("pageSize", Number(value))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
);

View File

@ -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;
@ -259,6 +273,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
);
case "unified-list":
// 데이터 소스: config.data > props.tableDisplayData > []
const listData = config.data?.length > 0 ? config.data : props.tableDisplayData || [];
return (
<UnifiedList
unifiedType="UnifiedList"
@ -271,8 +288,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
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
}
/>
);
@ -375,7 +412,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 카테고리 셀렉트: 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 {
@ -551,9 +593,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
};
// 🆕 엔티티 검색 컴포넌트는 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,
@ -578,7 +621,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
// 폴백 렌더링 - 기본 플레이스홀더
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-border bg-muted p-4">
<div className="border-border bg-muted flex h-full w-full items-center justify-center rounded border-2 border-dashed p-4">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-muted-foreground">{component.label || component.id}</div>
<div className="text-xs text-muted-foreground/70"> : {componentType}</div>
<div className="text-muted-foreground mb-2 text-sm font-medium">{component.label || component.id}</div>
<div className="text-muted-foreground/70 text-xs"> : {componentType}</div>
</div>
</div>
);

View File

@ -11,10 +11,9 @@ interface CardModeRendererProps {
data: Record<string, any>[];
cardConfig: CardDisplayConfig;
visibleColumns: ColumnConfig[];
onRowClick?: (row: Record<string, any>) => void;
onRowClick?: (row: Record<string, any>, index: number, e: React.MouseEvent) => void;
onRowSelect?: (row: Record<string, any>, selected: boolean) => void;
selectedRows?: string[];
showActions?: boolean;
}
/**
@ -26,19 +25,24 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
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<CardModeRendererProps> = ({
};
// 액션 버튼 렌더링
const renderActions = (row: Record<string, any>) => {
if (!showActions || !config.showActions) return null;
const renderActions = (_row: Record<string, any>) => {
if (!config.showActions) return null;
return (
<div className="flex items-center justify-end space-x-1 mt-3 pt-3 border-t border-gray-100">
<div className="mt-3 flex items-center justify-end space-x-1 border-t border-gray-100 pt-3">
<Button
variant="ghost"
size="sm"
@ -113,11 +117,11 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
if (!data || data.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mb-4">
<div className="w-8 h-8 bg-muted-foreground/20 rounded-lg"></div>
<div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
<div className="bg-muted-foreground/20 h-8 w-8 rounded-lg"></div>
</div>
<div className="text-sm font-medium text-muted-foreground mb-1"> </div>
<div className="text-xs text-muted-foreground/60"> </div>
<div className="text-muted-foreground mb-1 text-sm font-medium"> </div>
<div className="text-muted-foreground/60 text-xs"> </div>
</div>
);
}
@ -138,21 +142,15 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
key={`card-${index}-${idValue}`}
style={cardStyle}
className={`transition-all duration-200 hover:shadow-md ${
isSelected ? "ring-2 ring-blue-500 bg-blue-50/30" : ""
isSelected ? "bg-blue-50/30 ring-2 ring-blue-500" : ""
}`}
onClick={() => onRowClick?.(row)}
onClick={(e) => onRowClick?.(row, index, e)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-sm font-medium truncate">
{titleValue || "제목 없음"}
</CardTitle>
{subtitleValue && (
<div className="text-xs text-gray-500 mt-1 truncate">
{subtitleValue}
</div>
)}
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-sm font-medium">{titleValue || "제목 없음"}</CardTitle>
{subtitleValue && <div className="mt-1 truncate text-xs text-gray-500">{subtitleValue}</div>}
</div>
{/* ID 뱃지 */}
@ -171,7 +169,7 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
<img
src={imageValue}
alt={titleValue}
className="w-full h-24 object-cover rounded-md bg-gray-100"
className="h-24 w-full rounded-md bg-gray-100 object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
@ -181,23 +179,20 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
)}
{/* 설명 표시 */}
{descriptionValue && (
<div className="text-xs text-gray-600 line-clamp-2 mb-3">
{descriptionValue}
</div>
)}
{descriptionValue && <div className="mb-3 line-clamp-2 text-xs text-gray-600">{descriptionValue}</div>}
{/* 추가 필드들 표시 (선택적) */}
<div className="space-y-1">
{visibleColumns
.filter(col =>
col.columnName !== config.idColumn &&
col.columnName !== config.titleColumn &&
col.columnName !== config.subtitleColumn &&
col.columnName !== config.descriptionColumn &&
col.columnName !== config.imageColumn &&
col.columnName !== "__checkbox__" &&
col.visible
{(visibleColumns || [])
.filter(
(col) =>
col.columnName !== config.idColumn &&
col.columnName !== config.titleColumn &&
col.columnName !== config.subtitleColumn &&
col.columnName !== config.descriptionColumn &&
col.columnName !== config.imageColumn &&
col.columnName !== "__checkbox__" &&
col.visible,
)
.slice(0, 3) // 최대 3개 추가 필드만 표시
.map((col) => {
@ -205,9 +200,9 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
if (!value) return null;
return (
<div key={col.columnName} className="flex justify-between items-center text-xs">
<span className="text-gray-500 truncate">{col.displayName}:</span>
<span className="font-medium truncate ml-2">{value}</span>
<div key={col.columnName} className="flex items-center justify-between text-xs">
<span className="truncate text-gray-500">{col.displayName}:</span>
<span className="ml-2 truncate font-medium">{value}</span>
</div>
);
})}

View File

@ -20,7 +20,7 @@ interface SingleTableWithStickyProps {
handleSort?: (columnName: string) => void;
onSort?: (columnName: string) => void;
handleSelectAll?: (checked: boolean) => void;
handleRowClick?: (row: any) => void;
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode;
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
@ -77,278 +77,290 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {});
const actualData = data || [];
return (
<div
className="relative flex flex-col bg-background shadow-sm"
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
style={{
width: "100%",
height: "100%",
boxSizing: "border-box",
}}
>
<div className="relative overflow-x-auto">
<Table
className="w-full"
style={{
width: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정
boxSizing: "border-box",
}}
>
<TableHeader
className={cn(
"border-b bg-background",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
)}
<div className="relative flex-1 overflow-auto">
<Table
className="w-full"
style={{
width: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정
boxSizing: "border-box",
}}
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
<TableHeader
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
return (
<TableHead
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background"
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일
column.fixed === "left" &&
"sticky z-40 border-r border-border bg-background shadow-sm",
column.fixed === "right" &&
"sticky z-40 border-l border-border bg-background shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
)
) : (
<>
<span className="flex-1 truncate">
{columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && sortColumn === column.columnName && (
<span className="ml-1 flex h-4 w-4 items-center justify-center rounded-md bg-background/50 shadow-sm sm:ml-2 sm:h-5 sm:w-5">
{sortDirection === "asc" ? (
<ArrowUp className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
) : (
<ArrowDown className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
)}
</span>
)}
</>
return (
<TableHead
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg className="h-6 w-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
</span>
</div>
</TableCell>
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
)
) : (
<>
<span className="flex-1 truncate">
{columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && sortColumn === column.columnName && (
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
{sortDirection === "asc" ? (
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
) : (
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
)}
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={`row-${index}`}
className={cn(
"h-14 cursor-pointer border-b transition-colors bg-background sm:h-16",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
)}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = visibleColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
</TableHeader>
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
<TableBody>
{actualData.length === 0 ? (
<TableRow>
<TableCell colSpan={actualColumns.length || 1} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
<svg
className="text-muted-foreground h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-muted-foreground text-sm font-medium"> </span>
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
</span>
</div>
</TableCell>
</TableRow>
) : (
actualData.map((row, index) => (
<TableRow
key={`row-${index}`}
className={cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
)}
onClick={(e) => handleRowClick?.(row, index, e)}
>
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 현재 셀이 편집 중인지 확인
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns
.slice(rightFixedIndex + 1)
.reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 현재 셀이 편집 중인지 확인
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted = column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult = isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted =
column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult =
isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
if (startIndex === -1) return cellValue;
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark
className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 font-semibold text-white"
: "bg-yellow-200 text-yellow-900",
)}
>
{match}
</mark>
{after}
</>
);
};
return (
<>
{before}
<mark className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 text-white font-semibold"
: "bg-yellow-200 text-yellow-900"
)}>
{match}
</mark>
{after}
</>
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxCell?.(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell>
);
};
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" &&
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
column.fixed === "right" &&
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxCell(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);

View File

@ -5193,13 +5193,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (tableConfig.displayMode === "card" && !isDesignMode) {
return (
<div {...domProps}>
<CardModeRenderer
data={data}
loading={loading}
error={error}
cardConfig={tableConfig.cardConfig}
onRowClick={handleRowClick}
/>
{loading ? (
<div className="flex h-40 items-center justify-center">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : error ? (
<div className="flex h-40 items-center justify-center">
<span className="text-destructive text-sm">{error}</span>
</div>
) : (
<CardModeRenderer
data={data}
cardConfig={
tableConfig.cardConfig || {
idColumn: visibleColumns[0]?.columnName || "id",
titleColumn: visibleColumns[0]?.columnName || "",
cardsPerRow: 3,
cardSpacing: 16,
showActions: false,
}
}
visibleColumns={visibleColumns}
onRowClick={handleRowClick}
selectedRows={Array.from(selectedRows)}
/>
)}
{paginationJSX}
</div>
);

View File

@ -251,6 +251,16 @@ export interface ListColumn {
format?: string;
}
export interface UnifiedListCardConfig {
titleColumn?: string;
subtitleColumn?: string;
descriptionColumn?: string;
imageColumn?: string;
cardsPerRow?: number;
cardSpacing?: number;
showActions?: boolean;
}
export interface UnifiedListConfig {
viewMode: UnifiedListViewMode;
editable?: boolean;
@ -259,6 +269,7 @@ export interface UnifiedListConfig {
pageSize?: number;
columns?: ListColumn[];
modal?: boolean;
cardConfig?: UnifiedListCardConfig;
// 데이터 소스
dataSource?: {
table?: string;
@ -499,4 +510,3 @@ export const LEGACY_TO_UNIFIED_MAP: Record<string, UnifiedComponentType> = {
// Button (Input의 버튼 모드)
"button-primary": "UnifiedInput",
};