카드 디스플레이 옵션 설정

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 타입인지 확인용) // 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; 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 ( return (
<div key={selectedComponent.id} className="space-y-4"> <div key={selectedComponent.id} className="space-y-4">

View File

@ -2,554 +2,180 @@
/** /**
* UnifiedList * UnifiedList
* *
* *
* - table: 테이블 * TableListComponent를
* - card: 카드
* - kanban: 칸반
* - list: 단순
*/ */
import React, { forwardRef, useCallback, useMemo, useState } from "react"; import React, { forwardRef, useMemo } from "react";
import { Label } from "@/components/ui/label"; import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent";
import { Input } from "@/components/ui/input"; import { UnifiedListProps } from "@/types/unified-components";
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);
}
/** /**
* UnifiedList * UnifiedList
* TableListComponent의
*/ */
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>( export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>((props, ref) => {
(props, ref) => { const { id, style, size, config: configProp, onRowSelect } = props;
const {
// 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, id,
label, tableName,
style, tableColumns,
size, config.viewMode,
config: configProp, config.pageable,
data = [], config.searchable,
selectedRows = [], config.pageSize,
config.cardConfig,
onRowSelect, 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 ( return (
<div <div
ref={ref} ref={ref}
id={id} id={id}
className="flex flex-col gap-4" className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
style={{ style={{
width: componentWidth, width: size?.width || style?.width || "100%",
height: componentHeight, height: size?.height || style?.height || 400,
}} }}
> >
{/* 헤더 영역 */} <p className="text-muted-foreground text-sm"> .</p>
{(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>
)}
</div> </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"; UnifiedList.displayName = "UnifiedList";
export default UnifiedList;

View File

@ -3,121 +3,224 @@
/** /**
* UnifiedList * UnifiedList
* . * .
* -
* - +
*/ */
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { tableTypeApi } from "@/lib/api/screen";
import { cn } from "@/lib/utils";
interface UnifiedListConfigPanelProps { interface UnifiedListConfigPanelProps {
config: Record<string, any>; config: Record<string, any>;
onChange: (config: Record<string, any>) => void; onChange: (config: Record<string, any>) => void;
} /** 현재 화면의 테이블명 */
currentTableName?: string;
interface TableOption {
tableName: string;
displayName: string;
} }
interface ColumnOption { interface ColumnOption {
columnName: string; columnName: string;
displayName: string; displayName: string;
isJoinColumn?: boolean;
sourceTable?: string;
inputType?: string;
} }
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
config, config,
onChange, onChange,
currentTableName,
}) => { }) => {
// 테이블 목록 // 컬럼 목록 (테이블 컬럼 + 엔티티 조인 컬럼)
const [tables, setTables] = useState<TableOption[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<ColumnOption[]>([]); const [columns, setColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false);
const [expandedJoinSections, setExpandedJoinSections] = useState<Set<string>>(new Set());
// 설정 업데이트 핸들러 // 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => { 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 tableName = currentTableName || config.tableName;
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();
}, []);
// 테이블 선택 시 컬럼 목록 로드 // 테이블 컬럼 및 엔티티 조인 컬럼 로드
useEffect(() => { useEffect(() => {
const loadColumns = async () => { const loadColumns = async () => {
if (!config.tableName) { if (!tableName) {
setColumns([]); setColumns([]);
return; return;
} }
setLoadingColumns(true); setLoadingColumns(true);
try { try {
const data = await tableTypeApi.getColumns(config.tableName); // 1. 테이블 컬럼 로드
setColumns(data.map((c: any) => ({ const columnData = await tableTypeApi.getColumns(tableName);
const baseColumns: ColumnOption[] = columnData.map((c: any) => ({
columnName: c.columnName || c.column_name, 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) { } catch (error) {
console.error("컬럼 목록 로드 실패:", error); console.error("컬럼 목록 로드 실패:", error);
setColumns([]);
} finally { } finally {
setLoadingColumns(false); setLoadingColumns(false);
} }
}; };
loadColumns(); 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); updateConfig("columns", newColumns);
}; };
const updateColumn = (index: number, field: string, value: string) => { // 컬럼 너비 수정
const newColumns = [...configColumns]; const updateColumnWidth = (columnKey: string, width: string) => {
newColumns[index] = { ...newColumns[index], [field]: value }; const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, width } : col));
updateConfig("columns", newColumns); updateConfig("columns", newColumns);
}; };
const removeColumn = (index: number) => { // 그룹별 컬럼 분리
const newColumns = configColumns.filter((_: any, i: number) => i !== index); const baseColumns = useMemo(() => columns.filter((col) => !col.isJoinColumn), [columns]);
updateConfig("columns", newColumns);
// 조인 컬럼을 소스 테이블별로 그룹화
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 ( return (
<div className="space-y-4"> <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"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<Select <Select value={config.viewMode || "table"} onValueChange={(value) => updateConfig("viewMode", value)}>
value={config.viewMode || "table"}
onValueChange={(value) => updateConfig("viewMode", value)}
>
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="방식 선택" /> <SelectValue placeholder="방식 선택" />
</SelectTrigger> </SelectTrigger>
@ -130,153 +233,226 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
</Select> </Select>
</div> </div>
<Separator /> {/* 카드 모드 설정 */}
{config.viewMode === "card" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{/* 데이터 소스 */} {/* 제목 컬럼 */}
<div className="space-y-2"> <div className="space-y-1">
<Label className="text-xs font-medium"> </Label> <Label className="text-muted-foreground text-[10px]"> </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">
{/* 컬럼 키 - 드롭다운 */}
<Select <Select
value={column.key || ""} value={config.cardConfig?.titleColumn || ""}
onValueChange={(value) => { onValueChange={(value) => updateConfig("cardConfig", { ...config.cardConfig, titleColumn: 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}
> >
<SelectTrigger className="h-7 text-xs flex-1"> <SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{columns.map((col) => ( {configColumns.map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.key} value={col.key}>
{col.displayName} {col.title}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </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> </div>
))}
{configColumns.length === 0 && ( {/* 부제목 컬럼 */}
<p className="text-xs text-muted-foreground text-center py-2"> <div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
</p> <Select
)} value={config.cardConfig?.subtitleColumn || "_none_"}
</div> 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> </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 /> <Separator />
{/* 기능 옵션 */} {/* 기능 옵션 */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="sortable" id="sortable"
checked={config.sortable !== false} checked={config.sortable !== false}
onCheckedChange={(checked) => updateConfig("sortable", checked)} onCheckedChange={(checked) => updateConfig("sortable", checked)}
/> />
<label htmlFor="sortable" className="text-xs"> </label> <label htmlFor="sortable" className="text-xs">
</label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -285,7 +461,9 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
checked={config.pagination !== false} checked={config.pagination !== false}
onCheckedChange={(checked) => updateConfig("pagination", checked)} onCheckedChange={(checked) => updateConfig("pagination", checked)}
/> />
<label htmlFor="pagination" className="text-xs"></label> <label htmlFor="pagination" className="text-xs">
</label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -294,7 +472,9 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
checked={config.searchable || false} checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)} onCheckedChange={(checked) => updateConfig("searchable", checked)}
/> />
<label htmlFor="searchable" className="text-xs"> </label> <label htmlFor="searchable" className="text-xs">
</label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -303,30 +483,35 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
checked={config.editable || false} checked={config.editable || false}
onCheckedChange={(checked) => updateConfig("editable", checked)} onCheckedChange={(checked) => updateConfig("editable", checked)}
/> />
<label htmlFor="editable" className="text-xs"> </label> <label htmlFor="editable" className="text-xs">
</label>
</div> </div>
</div> </div>
{/* 페이지 크기 */} {/* 페이지 크기 */}
{config.pagination !== false && ( {config.pagination !== false && (
<div className="space-y-2"> <>
<Label className="text-xs font-medium"> </Label> <Separator />
<Select <div className="space-y-2">
value={String(config.pageSize || 10)} <Label className="text-xs font-medium"> </Label>
onValueChange={(value) => updateConfig("pageSize", Number(value))} <Select
> value={String(config.pageSize || 10)}
<SelectTrigger className="h-8 text-xs"> onValueChange={(value) => updateConfig("pageSize", Number(value))}
<SelectValue placeholder="선택" /> >
</SelectTrigger> <SelectTrigger className="h-8 text-xs">
<SelectContent> <SelectValue placeholder="선택" />
<SelectItem value="5">5</SelectItem> </SelectTrigger>
<SelectItem value="10">10</SelectItem> <SelectContent>
<SelectItem value="20">20</SelectItem> <SelectItem value="5">5</SelectItem>
<SelectItem value="50">50</SelectItem> <SelectItem value="10">10</SelectItem>
<SelectItem value="100">100</SelectItem> <SelectItem value="20">20</SelectItem>
</SelectContent> <SelectItem value="50">50</SelectItem>
</Select> <SelectItem value="100">100</SelectItem>
</div> </SelectContent>
</Select>
</div>
</>
)} )}
</div> </div>
); );

View File

@ -42,7 +42,14 @@ export interface ComponentRenderer {
// 테이블 선택된 행 정보 (다중 선택 액션용) // 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[]; selectedRows?: any[];
selectedRowsData?: 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; sortBy?: string;
sortOrder?: "asc" | "desc"; sortOrder?: "asc" | "desc";
@ -126,7 +133,14 @@ export interface DynamicComponentRendererProps {
// 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트) // 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트)
disabledFields?: string[]; disabledFields?: string[];
selectedRowsData?: 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; sortBy?: string;
sortOrder?: "asc" | "desc"; sortOrder?: "asc" | "desc";
@ -146,7 +160,7 @@ export interface DynamicComponentRendererProps {
// 모달 내에서 렌더링 여부 // 모달 내에서 렌더링 여부
isInModal?: boolean; isInModal?: boolean;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용) // 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
// 🆕 조건부 비활성화 상태 // 🆕 조건부 비활성화 상태
conditionalDisabled?: boolean; conditionalDisabled?: boolean;
@ -172,7 +186,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const config = (component as any).componentConfig || {}; const config = (component as any).componentConfig || {};
const fieldName = (component as any).columnName || component.id; const fieldName = (component as any).columnName || component.id;
const currentValue = props.formData?.[fieldName]; const currentValue = props.formData?.[fieldName];
const handleChange = (value: any) => { const handleChange = (value: any) => {
if (props.onFormDataChange) { if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value); props.onFormDataChange(fieldName, value);
@ -259,6 +273,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
); );
case "unified-list": case "unified-list":
// 데이터 소스: config.data > props.tableDisplayData > []
const listData = config.data?.length > 0 ? config.data : props.tableDisplayData || [];
return ( return (
<UnifiedList <UnifiedList
unifiedType="UnifiedList" unifiedType="UnifiedList"
@ -271,8 +288,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
pagination: config.pagination, pagination: config.pagination,
searchable: config.searchable, searchable: config.searchable,
editable: config.editable, 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<DynamicComponentRendererProps> =
const webType = (component as any).componentConfig?.webType; const webType = (component as any).componentConfig?.webType;
const tableName = (component as any).tableName; const tableName = (component as any).tableName;
const columnName = (component as any).columnName; const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원) // ⚠️ 단, 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에서 처리하도록 아래로 통과 // select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
} else if ((inputType === "category" || webType === "category") && tableName && columnName) { } else if ((inputType === "category" || webType === "category") && tableName && columnName) {
try { try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id; const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || ""; const currentValue = props.formData?.[fieldName] || "";
const handleChange = (value: any) => { const handleChange = (value: any) => {
if (props.onFormDataChange) { if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value); props.onFormDataChange(fieldName, value);
@ -496,7 +538,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id; const fieldName = (component as any).columnName || component.id;
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화 // modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
let currentValue; let currentValue;
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") { if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
@ -505,7 +547,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
} else { } else {
currentValue = formData?.[fieldName] || ""; currentValue = formData?.[fieldName] || "";
} }
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => { const handleChange = (value: any) => {
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지 // autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
@ -551,10 +593,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}; };
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블) // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
const useConfigTableName = componentType === "entity-search-input" || const useConfigTableName =
componentType === "autocomplete-search-input" || componentType === "entity-search-input" ||
componentType === "modal-repeater-table"; componentType === "autocomplete-search-input" ||
componentType === "modal-repeater-table";
const rendererProps = { const rendererProps = {
component, component,
isSelected, isSelected,
@ -578,7 +621,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onFormDataChange, onFormDataChange,
onChange: handleChange, // 개선된 onChange 핸들러 전달 onChange: handleChange, // 개선된 onChange 핸들러 전달
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용 // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName, tableName: useConfigTableName ? component.componentConfig?.tableName || tableName : tableName,
menuId, // 🆕 메뉴 ID menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보 selectedScreen, // 🆕 화면 정보
@ -677,10 +720,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 폴백 렌더링 - 기본 플레이스홀더 // 폴백 렌더링 - 기본 플레이스홀더
return ( 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="text-center">
<div className="mb-2 text-sm font-medium text-muted-foreground">{component.label || component.id}</div> <div className="text-muted-foreground mb-2 text-sm font-medium">{component.label || component.id}</div>
<div className="text-xs text-muted-foreground/70"> : {componentType}</div> <div className="text-muted-foreground/70 text-xs"> : {componentType}</div>
</div> </div>
</div> </div>
); );

View File

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

View File

@ -20,7 +20,7 @@ interface SingleTableWithStickyProps {
handleSort?: (columnName: string) => void; handleSort?: (columnName: string) => void;
onSort?: (columnName: string) => void; onSort?: (columnName: string) => void;
handleSelectAll?: (checked: boolean) => 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; renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode; renderCheckboxHeader?: () => React.ReactNode;
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string; 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 checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || []; const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {}); const sortHandler = onSort || handleSort || (() => {});
const actualData = data || [];
return ( return (
<div <div
className="relative flex flex-col bg-background shadow-sm" className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
style={{ style={{
width: "100%", width: "100%",
height: "100%",
boxSizing: "border-box", boxSizing: "border-box",
}} }}
> >
<div className="relative overflow-x-auto"> <div className="relative flex-1 overflow-auto">
<Table <Table
className="w-full" className="w-full"
style={{ style={{
width: "100%", width: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정 tableLayout: "auto", // 테이블 크기 자동 조정
boxSizing: "border-box", boxSizing: "border-box",
}} }}
>
<TableHeader
className={cn(
"border-b bg-background",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
)}
> >
<TableRow className="border-b"> <TableHeader
{actualColumns.map((column, colIndex) => { className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
// 왼쪽 고정 컬럼들의 누적 너비 계산 >
const leftFixedWidth = actualColumns <TableRow className="border-b">
.slice(0, colIndex) {actualColumns.map((column, colIndex) => {
.filter((col) => col.fixed === "left") // 왼쪽 고정 컬럼들의 누적 너비 계산
.reduce((sum, col) => sum + getColumnWidth(col), 0); 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 rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName); const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth = const rightFixedWidth =
rightFixedIndex >= 0 rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) ? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0; : 0;
return ( return (
<TableHead <TableHead
key={column.columnName} key={column.columnName}
className={cn( className={cn(
column.columnName === "__checkbox__" 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" ? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "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-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}`, `text-${column.align}`,
column.sortable && "hover:bg-primary/10", column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일 // 고정 컬럼 스타일
column.fixed === "left" && column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
"sticky z-40 border-r border-border bg-background shadow-sm", column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
column.fixed === "right" && // 숨김 컬럼 스타일 (디자인 모드에서만)
"sticky z-40 border-l border-border bg-background shadow-sm", isDesignMode && column.hidden && "bg-muted/50 opacity-40",
// 숨김 컬럼 스타일 (디자인 모드에서만)
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>
)}
</>
)} )}
</div> style={{
</TableHead> width: getColumnWidth(column),
); minWidth: "100px", // 최소 너비 보장
})} maxWidth: "300px", // 최대 너비 제한
</TableRow> boxSizing: "border-box",
</TableHeader> overflow: "hidden",
textOverflow: "ellipsis",
<TableBody> whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
{data.length === 0 ? ( backgroundColor: "hsl(var(--background))",
<TableRow> // sticky 위치 설정
<TableCell colSpan={visibleColumns.length} className="py-12 text-center"> ...(column.fixed === "left" && { left: leftFixedWidth }),
<div className="flex flex-col items-center justify-center space-y-3"> ...(column.fixed === "right" && { right: rightFixedWidth }),
<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"> onClick={() => column.sortable && sortHandler(column.columnName)}
<path >
strokeLinecap="round" <div className="flex items-center gap-2">
strokeLinejoin="round" {column.columnName === "__checkbox__" ? (
strokeWidth={2} checkboxConfig.selectAll && (
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" <Checkbox
/> checked={isAllSelected}
</svg> onCheckedChange={handleSelectAll}
</div> aria-label="전체 선택"
<span className="text-sm font-medium text-muted-foreground"> </span> style={{ zIndex: 1 }}
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground"> />
)
</span> ) : (
</div> <>
</TableCell> <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> </TableRow>
) : ( </TableHeader>
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);
// 오른쪽 고정 컬럼들의 누적 너비 계산 <TableBody>
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right"); {actualData.length === 0 ? (
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName); <TableRow>
const rightFixedWidth = <TableCell colSpan={actualColumns.length || 1} className="py-12 text-center">
rightFixedIndex >= 0 <div className="flex flex-col items-center justify-center space-y-3">
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) <div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
: 0; <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 isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted = column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult = isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링 // 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const renderCellContent = () => { const cellKey = `${index}-${colIndex}`;
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리 // 인덱스 기반 하이라이트 + 실제 값 검증
const lowerValue = String(cellValue).toLowerCase(); const isHighlighted =
const lowerTerm = searchTerm.toLowerCase(); column.columnName !== "__checkbox__" &&
const startIndex = lowerValue.indexOf(lowerTerm); hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex); // 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length); const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const after = String(cellValue).slice(startIndex + searchTerm.length); const isCurrentSearchResult =
isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
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 ( return (
<> <TableCell
{before} key={`cell-${column.columnName}`}
<mark className={cn( id={isCurrentSearchResult ? "current-search-result" : undefined}
"rounded px-0.5", className={cn(
isCurrentSearchResult "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",
? "bg-orange-400 text-white font-semibold" `text-${column.align}`,
: "bg-yellow-200 text-yellow-900" // 고정 컬럼 스타일
)}> column.fixed === "left" &&
{match} "border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
</mark> column.fixed === "right" &&
{after} "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>
); );
}; })}
</TableRow>
return ( ))
<TableCell )}
key={`cell-${column.columnName}`} </TableBody>
id={isCurrentSearchResult ? "current-search-result" : undefined} </Table>
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>
</div> </div>
</div> </div>
); );

View File

@ -5193,13 +5193,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (tableConfig.displayMode === "card" && !isDesignMode) { if (tableConfig.displayMode === "card" && !isDesignMode) {
return ( return (
<div {...domProps}> <div {...domProps}>
<CardModeRenderer {loading ? (
data={data} <div className="flex h-40 items-center justify-center">
loading={loading} <span className="text-muted-foreground text-sm"> ...</span>
error={error} </div>
cardConfig={tableConfig.cardConfig} ) : error ? (
onRowClick={handleRowClick} <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} {paginationJSX}
</div> </div>
); );

View File

@ -1,6 +1,6 @@
/** /**
* Unified * Unified
* *
* 10 * 10
* - UnifiedInput * - UnifiedInput
* - UnifiedSelect * - UnifiedSelect
@ -251,6 +251,16 @@ export interface ListColumn {
format?: string; format?: string;
} }
export interface UnifiedListCardConfig {
titleColumn?: string;
subtitleColumn?: string;
descriptionColumn?: string;
imageColumn?: string;
cardsPerRow?: number;
cardSpacing?: number;
showActions?: boolean;
}
export interface UnifiedListConfig { export interface UnifiedListConfig {
viewMode: UnifiedListViewMode; viewMode: UnifiedListViewMode;
editable?: boolean; editable?: boolean;
@ -259,6 +269,7 @@ export interface UnifiedListConfig {
pageSize?: number; pageSize?: number;
columns?: ListColumn[]; columns?: ListColumn[];
modal?: boolean; modal?: boolean;
cardConfig?: UnifiedListCardConfig;
// 데이터 소스 // 데이터 소스
dataSource?: { dataSource?: {
table?: string; table?: string;
@ -456,47 +467,46 @@ export const LEGACY_TO_UNIFIED_MAP: Record<string, UnifiedComponentType> = {
"text-input": "UnifiedInput", "text-input": "UnifiedInput",
"number-input": "UnifiedInput", "number-input": "UnifiedInput",
"password-input": "UnifiedInput", "password-input": "UnifiedInput",
// Select 계열 // Select 계열
"select-basic": "UnifiedSelect", "select-basic": "UnifiedSelect",
"radio-basic": "UnifiedSelect", "radio-basic": "UnifiedSelect",
"checkbox-basic": "UnifiedSelect", "checkbox-basic": "UnifiedSelect",
"entity-search-input": "UnifiedSelect", "entity-search-input": "UnifiedSelect",
"autocomplete-search-input": "UnifiedSelect", "autocomplete-search-input": "UnifiedSelect",
// Date 계열 // Date 계열
"date-input": "UnifiedDate", "date-input": "UnifiedDate",
// Text 계열 // Text 계열
"textarea-basic": "UnifiedText", "textarea-basic": "UnifiedText",
// Media 계열 // Media 계열
"file-upload": "UnifiedMedia", "file-upload": "UnifiedMedia",
"image-widget": "UnifiedMedia", "image-widget": "UnifiedMedia",
// List 계열 // List 계열
"table-list": "UnifiedList", "table-list": "UnifiedList",
"table-search-widget": "UnifiedList", "table-search-widget": "UnifiedList",
"modal-repeater-table": "UnifiedList", "modal-repeater-table": "UnifiedList",
"repeater-field-group": "UnifiedList", "repeater-field-group": "UnifiedList",
"card-display": "UnifiedList", "card-display": "UnifiedList",
// Layout 계열 // Layout 계열
"split-panel-layout": "UnifiedLayout", "split-panel-layout": "UnifiedLayout",
"screen-split-panel": "UnifiedLayout", "screen-split-panel": "UnifiedLayout",
// Group 계열 // Group 계열
"tabs-widget": "UnifiedGroup", "tabs-widget": "UnifiedGroup",
"section-paper": "UnifiedGroup", "section-paper": "UnifiedGroup",
"section-card": "UnifiedGroup", "section-card": "UnifiedGroup",
"universal-form-modal": "UnifiedGroup", "universal-form-modal": "UnifiedGroup",
// Biz 계열 // Biz 계열
"category-manager": "UnifiedBiz", "category-manager": "UnifiedBiz",
"numbering-rule": "UnifiedBiz", "numbering-rule": "UnifiedBiz",
"flow-widget": "UnifiedBiz", "flow-widget": "UnifiedBiz",
// Button (Input의 버튼 모드) // Button (Input의 버튼 모드)
"button-primary": "UnifiedInput", "button-primary": "UnifiedInput",
}; };