카드 디스플레이 옵션 설정
This commit is contained in:
parent
01e47a1830
commit
9c26738604
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue