카드 디스플레이 옵션 설정
This commit is contained in:
parent
01e47a1830
commit
9c26738604
|
|
@ -354,8 +354,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
|
||||
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
|
||||
|
||||
// unified-select의 경우 inputType 전달
|
||||
const extraProps = componentId === "unified-select" ? { inputType } : {};
|
||||
// 현재 화면의 테이블명 가져오기
|
||||
const currentTableName = tables?.[0]?.tableName;
|
||||
|
||||
// 컴포넌트별 추가 props
|
||||
const extraProps: Record<string, any> = {};
|
||||
if (componentId === "unified-select") {
|
||||
extraProps.inputType = inputType;
|
||||
}
|
||||
if (componentId === "unified-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -2,554 +2,180 @@
|
|||
|
||||
/**
|
||||
* UnifiedList
|
||||
*
|
||||
*
|
||||
* 통합 리스트 컴포넌트
|
||||
* - table: 테이블 뷰
|
||||
* - card: 카드 뷰
|
||||
* - kanban: 칸반 뷰
|
||||
* - list: 단순 리스트 뷰
|
||||
* 기존 TableListComponent를 래핑하여 동일한 기능 제공
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedListProps, ListColumn } from "@/types/unified-components";
|
||||
import { Search, ChevronUp, ChevronDown, MoreHorizontal, GripVertical } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 테이블 뷰 컴포넌트
|
||||
*/
|
||||
const TableView = forwardRef<HTMLDivElement, {
|
||||
columns: ListColumn[];
|
||||
data: Record<string, unknown>[];
|
||||
selectedRows: Record<string, unknown>[];
|
||||
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
||||
onRowClick?: (row: Record<string, unknown>) => void;
|
||||
editable?: boolean;
|
||||
sortColumn?: string;
|
||||
sortDirection?: "asc" | "desc";
|
||||
onSort?: (column: string) => void;
|
||||
className?: string;
|
||||
}>(({
|
||||
columns,
|
||||
data,
|
||||
selectedRows = [],
|
||||
onRowSelect,
|
||||
onRowClick,
|
||||
editable,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
className
|
||||
}, ref) => {
|
||||
// 행 선택 처리
|
||||
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
||||
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
||||
}, [selectedRows]);
|
||||
|
||||
const handleSelectAll = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
onRowSelect?.(data);
|
||||
} else {
|
||||
onRowSelect?.([]);
|
||||
}
|
||||
}, [data, onRowSelect]);
|
||||
|
||||
const handleSelectRow = useCallback((row: Record<string, unknown>, checked: boolean) => {
|
||||
if (checked) {
|
||||
onRowSelect?.([...selectedRows, row]);
|
||||
} else {
|
||||
onRowSelect?.(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row)));
|
||||
}
|
||||
}, [selectedRows, onRowSelect]);
|
||||
|
||||
const allSelected = data.length > 0 && selectedRows.length === data.length;
|
||||
const someSelected = selectedRows.length > 0 && selectedRows.length < data.length;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("border rounded-md overflow-hidden", className)}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
{onRowSelect && (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
// indeterminate 상태는 data-state로 처리
|
||||
data-state={someSelected ? "indeterminate" : allSelected ? "checked" : "unchecked"}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.field}
|
||||
className={cn(
|
||||
column.sortable && "cursor-pointer select-none hover:bg-muted",
|
||||
)}
|
||||
style={{ width: column.width ? `${column.width}px` : "auto" }}
|
||||
onClick={() => column.sortable && onSort?.(column.field)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{column.header}
|
||||
{column.sortable && sortColumn === column.field && (
|
||||
sortDirection === "asc"
|
||||
? <ChevronUp className="h-4 w-4" />
|
||||
: <ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
{editable && <TableHead className="w-10" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + (onRowSelect ? 1 : 0) + (editable ? 1 : 0)}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
데이터가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50",
|
||||
isRowSelected(row) && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{onRowSelect && (
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isRowSelected(row)}
|
||||
onCheckedChange={(checked) => handleSelectRow(row, checked as boolean)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.field}>
|
||||
{formatCellValue(row[column.field], column.format)}
|
||||
</TableCell>
|
||||
))}
|
||||
{editable && (
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TableView.displayName = "TableView";
|
||||
|
||||
/**
|
||||
* 카드 뷰 컴포넌트
|
||||
*/
|
||||
const CardView = forwardRef<HTMLDivElement, {
|
||||
columns: ListColumn[];
|
||||
data: Record<string, unknown>[];
|
||||
selectedRows: Record<string, unknown>[];
|
||||
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
||||
onRowClick?: (row: Record<string, unknown>) => void;
|
||||
className?: string;
|
||||
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
|
||||
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
||||
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
||||
}, [selectedRows]);
|
||||
|
||||
const handleCardClick = useCallback((row: Record<string, unknown>) => {
|
||||
if (onRowSelect) {
|
||||
const isSelected = isRowSelected(row);
|
||||
if (isSelected) {
|
||||
onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row)));
|
||||
} else {
|
||||
onRowSelect([...selectedRows, row]);
|
||||
}
|
||||
}
|
||||
onRowClick?.(row);
|
||||
}, [selectedRows, isRowSelected, onRowSelect, onRowClick]);
|
||||
|
||||
// 주요 컬럼 (첫 번째)과 나머지 구분
|
||||
const [primaryColumn, ...otherColumns] = columns;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", className)}>
|
||||
{data.length === 0 ? (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||
데이터가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors hover:border-primary",
|
||||
isRowSelected(row) && "border-primary bg-primary/5"
|
||||
)}
|
||||
onClick={() => handleCardClick(row)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">
|
||||
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<dl className="space-y-1 text-sm">
|
||||
{otherColumns.slice(0, 4).map((column) => (
|
||||
<div key={column.field} className="flex justify-between">
|
||||
<dt className="text-muted-foreground">{column.header}</dt>
|
||||
<dd className="font-medium">
|
||||
{formatCellValue(row[column.field], column.format)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CardView.displayName = "CardView";
|
||||
|
||||
/**
|
||||
* 리스트 뷰 컴포넌트
|
||||
*/
|
||||
const ListView = forwardRef<HTMLDivElement, {
|
||||
columns: ListColumn[];
|
||||
data: Record<string, unknown>[];
|
||||
selectedRows: Record<string, unknown>[];
|
||||
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
||||
onRowClick?: (row: Record<string, unknown>) => void;
|
||||
className?: string;
|
||||
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
|
||||
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
||||
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
||||
}, [selectedRows]);
|
||||
|
||||
const handleItemClick = useCallback((row: Record<string, unknown>) => {
|
||||
if (onRowSelect) {
|
||||
const isSelected = isRowSelected(row);
|
||||
if (isSelected) {
|
||||
onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row)));
|
||||
} else {
|
||||
onRowSelect([...selectedRows, row]);
|
||||
}
|
||||
}
|
||||
onRowClick?.(row);
|
||||
}, [selectedRows, isRowSelected, onRowSelect, onRowClick]);
|
||||
|
||||
const [primaryColumn, secondaryColumn] = columns;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("divide-y border rounded-md", className)}>
|
||||
{data.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
데이터가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50",
|
||||
isRowSelected(row) && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => handleItemClick(row)}
|
||||
>
|
||||
{onRowSelect && (
|
||||
<Checkbox
|
||||
checked={isRowSelected(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onRowSelect([...selectedRows, row]);
|
||||
} else {
|
||||
onRowSelect(selectedRows.filter((r) => r !== row));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
|
||||
</div>
|
||||
{secondaryColumn && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{formatCellValue(row[secondaryColumn.field], secondaryColumn.format)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ListView.displayName = "ListView";
|
||||
|
||||
/**
|
||||
* 셀 값 포맷팅
|
||||
*/
|
||||
function formatCellValue(value: unknown, format?: string): React.ReactNode {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
if (format) {
|
||||
switch (format) {
|
||||
case "date":
|
||||
return new Date(String(value)).toLocaleDateString("ko-KR");
|
||||
case "datetime":
|
||||
return new Date(String(value)).toLocaleString("ko-KR");
|
||||
case "currency":
|
||||
return Number(value).toLocaleString("ko-KR") + "원";
|
||||
case "number":
|
||||
return Number(value).toLocaleString("ko-KR");
|
||||
case "percent":
|
||||
return Number(value).toFixed(1) + "%";
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
import React, { forwardRef, useMemo } from "react";
|
||||
import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent";
|
||||
import { UnifiedListProps } from "@/types/unified-components";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedList 컴포넌트
|
||||
* 기존 TableListComponent의 모든 기능을 그대로 사용
|
||||
*/
|
||||
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>((props, ref) => {
|
||||
const { id, style, size, config: configProp, onRowSelect } = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || {
|
||||
viewMode: "table" as const,
|
||||
source: "static" as const,
|
||||
columns: [],
|
||||
};
|
||||
|
||||
// 테이블명 추출
|
||||
const tableName = config.dataSource?.table || (props as any).tableName;
|
||||
|
||||
// columns 형식 변환 (UnifiedListConfigPanel 형식 -> TableListComponent 형식)
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
(config.columns || []).map((col: any, index: number) => ({
|
||||
columnName: col.key || col.field || "",
|
||||
displayName: col.title || col.header || col.key || col.field || "",
|
||||
width: col.width ? parseInt(col.width, 10) : undefined,
|
||||
visible: true,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
align: "left" as const,
|
||||
order: index,
|
||||
isEntityJoin: col.isJoinColumn || false,
|
||||
})),
|
||||
[config.columns],
|
||||
);
|
||||
|
||||
// 디버깅: config.cardConfig 확인
|
||||
console.log("📋 UnifiedList config.cardConfig:", config.cardConfig);
|
||||
|
||||
// TableListComponent에 전달할 component 객체 생성
|
||||
const componentObj = useMemo(
|
||||
() => ({
|
||||
id: id || "unified-list",
|
||||
type: "table-list",
|
||||
config: {
|
||||
selectedTable: tableName,
|
||||
tableName: tableName,
|
||||
columns: tableColumns,
|
||||
displayMode: config.viewMode === "card" ? "card" : "table",
|
||||
cardConfig: {
|
||||
idColumn: tableColumns[0]?.columnName || "id",
|
||||
titleColumn: tableColumns[0]?.columnName || "",
|
||||
subtitleColumn: undefined,
|
||||
descriptionColumn: undefined,
|
||||
imageColumn: undefined,
|
||||
cardsPerRow: 3,
|
||||
cardSpacing: 16,
|
||||
showActions: false,
|
||||
...config.cardConfig,
|
||||
},
|
||||
showHeader: true,
|
||||
showFooter: false,
|
||||
checkbox: {
|
||||
enabled: !!onRowSelect,
|
||||
position: "left" as const,
|
||||
showHeader: true,
|
||||
},
|
||||
height: "fixed" as const,
|
||||
autoWidth: true,
|
||||
stickyHeader: true,
|
||||
autoLoad: true,
|
||||
horizontalScroll: {
|
||||
enabled: true,
|
||||
minColumnWidth: 100,
|
||||
maxColumnWidth: 300,
|
||||
},
|
||||
pagination: {
|
||||
enabled: config.pageable !== false,
|
||||
pageSize: config.pageSize || 20,
|
||||
position: "bottom" as const,
|
||||
showPageSize: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
},
|
||||
filter: {
|
||||
enabled: config.searchable !== false,
|
||||
position: "top" as const,
|
||||
searchPlaceholder: "검색...",
|
||||
},
|
||||
actions: {
|
||||
enabled: false,
|
||||
items: [],
|
||||
},
|
||||
tableStyle: {
|
||||
striped: false,
|
||||
bordered: true,
|
||||
hover: true,
|
||||
compact: false,
|
||||
},
|
||||
toolbar: {
|
||||
showRefresh: true,
|
||||
showExport: false,
|
||||
showColumnToggle: false,
|
||||
},
|
||||
},
|
||||
style: {},
|
||||
gridColumns: 1,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
label,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
data = [],
|
||||
selectedRows = [],
|
||||
tableName,
|
||||
tableColumns,
|
||||
config.viewMode,
|
||||
config.pageable,
|
||||
config.searchable,
|
||||
config.pageSize,
|
||||
config.cardConfig,
|
||||
onRowSelect,
|
||||
onRowClick,
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { viewMode: "table" as const, source: "static" as const, columns: [] };
|
||||
|
||||
// 내부 상태
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const pageSize = config.pageSize || 10;
|
||||
const columns = config.columns || [];
|
||||
|
||||
// 검색 필터링
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm || !config.searchable) return data;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return data.filter((row) =>
|
||||
columns.some((col) => {
|
||||
const value = row[col.field];
|
||||
return value && String(value).toLowerCase().includes(term);
|
||||
})
|
||||
);
|
||||
}, [data, searchTerm, config.searchable, columns]);
|
||||
|
||||
// 정렬
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortColumn) return filteredData;
|
||||
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aVal = a[sortColumn];
|
||||
const bVal = b[sortColumn];
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
const comparison = String(aVal).localeCompare(String(bVal), "ko-KR", { numeric: true });
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [filteredData, sortColumn, sortDirection]);
|
||||
|
||||
// 페이지네이션
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!config.pageable) return sortedData;
|
||||
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return sortedData.slice(start, start + pageSize);
|
||||
}, [sortedData, currentPage, pageSize, config.pageable]);
|
||||
|
||||
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||||
|
||||
// 정렬 핸들러
|
||||
const handleSort = useCallback((column: string) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
}, [sortColumn]);
|
||||
|
||||
// 뷰모드별 렌더링
|
||||
const renderView = () => {
|
||||
const viewProps = {
|
||||
columns,
|
||||
data: paginatedData,
|
||||
selectedRows,
|
||||
onRowSelect,
|
||||
onRowClick,
|
||||
editable: config.editable,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort: handleSort,
|
||||
};
|
||||
|
||||
switch (config.viewMode) {
|
||||
case "table":
|
||||
return <TableView {...viewProps} />;
|
||||
case "card":
|
||||
return <CardView {...viewProps} />;
|
||||
case "list":
|
||||
return <ListView {...viewProps} />;
|
||||
case "kanban":
|
||||
// TODO: 칸반 뷰 구현
|
||||
return (
|
||||
<div className="p-4 border rounded text-center text-muted-foreground">
|
||||
칸반 뷰 (미구현)
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <TableView {...viewProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
const showSearch = config.searchable;
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
],
|
||||
);
|
||||
|
||||
// 테이블이 없으면 안내 메시지
|
||||
if (!tableName) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col gap-4"
|
||||
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
width: size?.width || style?.width || "100%",
|
||||
height: size?.height || style?.height || 400,
|
||||
}}
|
||||
>
|
||||
{/* 헤더 영역 */}
|
||||
{(showLabel || showSearch) && (
|
||||
<div className="flex items-center justify-between gap-4 flex-shrink-0">
|
||||
{showLabel && (
|
||||
<Label className="text-lg font-semibold">{label}</Label>
|
||||
)}
|
||||
|
||||
{/* 검색 */}
|
||||
{showSearch && (
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="pl-10 h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 뷰 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{config.pageable && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 {sortedData.length}건 중 {(currentPage - 1) * pageSize + 1}-
|
||||
{Math.min(currentPage * pageSize, sortedData.length)}건
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const page = i + 1;
|
||||
return (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
onClick={() => setCurrentPage(page)}
|
||||
isActive={currentPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">테이블이 설정되지 않았습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col overflow-hidden"
|
||||
style={{
|
||||
width: size?.width || style?.width || "100%",
|
||||
height: size?.height || style?.height || 400,
|
||||
}}
|
||||
>
|
||||
<TableListComponent
|
||||
component={componentObj}
|
||||
tableName={tableName}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onSelectedRowsChange={
|
||||
onRowSelect
|
||||
? (_, selectedData) => {
|
||||
onRowSelect(selectedData);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UnifiedList.displayName = "UnifiedList";
|
||||
|
||||
export default UnifiedList;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,121 +3,224 @@
|
|||
/**
|
||||
* UnifiedList 설정 패널
|
||||
* 통합 목록 컴포넌트의 세부 설정을 관리합니다.
|
||||
* - 현재 화면의 테이블 데이터를 사용
|
||||
* - 테이블 컬럼 + 엔티티 조인 컬럼 선택 지원
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Database, Link2, GripVertical, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UnifiedListConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
/** 현재 화면의 테이블명 */
|
||||
currentTableName?: string;
|
||||
}
|
||||
|
||||
interface ColumnOption {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
isJoinColumn?: boolean;
|
||||
sourceTable?: string;
|
||||
inputType?: string;
|
||||
}
|
||||
|
||||
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
currentTableName,
|
||||
}) => {
|
||||
// 테이블 목록
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
|
||||
// 컬럼 목록
|
||||
// 컬럼 목록 (테이블 컬럼 + 엔티티 조인 컬럼)
|
||||
const [columns, setColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [expandedJoinSections, setExpandedJoinSections] = useState<Set<string>>(new Set());
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
const newConfig = { ...config, [field]: value };
|
||||
console.log("⚙️ UnifiedListConfigPanel updateConfig:", { field, value, newConfig });
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getTables();
|
||||
setTables(data.map(t => ({
|
||||
tableName: t.tableName,
|
||||
displayName: t.displayName || t.tableName
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
// 테이블명 (현재 화면의 테이블 사용)
|
||||
const tableName = currentTableName || config.tableName;
|
||||
|
||||
// 테이블 선택 시 컬럼 목록 로드
|
||||
// 테이블 컬럼 및 엔티티 조인 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.tableName) {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getColumns(config.tableName);
|
||||
setColumns(data.map((c: any) => ({
|
||||
// 1. 테이블 컬럼 로드
|
||||
const columnData = await tableTypeApi.getColumns(tableName);
|
||||
const baseColumns: ColumnOption[] = columnData.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName: c.displayName || c.columnName || c.column_name
|
||||
})));
|
||||
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
|
||||
isJoinColumn: false,
|
||||
inputType: c.inputType || c.input_type || c.webType || c.web_type,
|
||||
}));
|
||||
|
||||
// 2. 엔티티 타입 컬럼 찾기 및 조인 컬럼 정보 로드
|
||||
const entityColumns = columnData.filter((c: any) => (c.inputType || c.input_type) === "entity");
|
||||
|
||||
const joinColumnOptions: ColumnOption[] = [];
|
||||
|
||||
for (const entityCol of entityColumns) {
|
||||
const colName = entityCol.columnName || entityCol.column_name;
|
||||
|
||||
// referenceTable 우선순위:
|
||||
// 1. 컬럼의 reference_table 필드
|
||||
// 2. detailSettings.referenceTable
|
||||
let referenceTable = entityCol.referenceTable || entityCol.reference_table;
|
||||
|
||||
if (!referenceTable) {
|
||||
let detailSettings = entityCol.detailSettings || entityCol.detail_settings;
|
||||
if (typeof detailSettings === "string") {
|
||||
try {
|
||||
detailSettings = JSON.parse(detailSettings);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
referenceTable = detailSettings?.referenceTable;
|
||||
}
|
||||
|
||||
if (referenceTable) {
|
||||
try {
|
||||
const refColumnData = await tableTypeApi.getColumns(referenceTable);
|
||||
|
||||
refColumnData.forEach((refCol: any) => {
|
||||
const refColName = refCol.columnName || refCol.column_name;
|
||||
const refDisplayName = refCol.displayName || refCol.columnLabel || refColName;
|
||||
|
||||
joinColumnOptions.push({
|
||||
columnName: `${colName}.${refColName}`,
|
||||
displayName: refDisplayName,
|
||||
isJoinColumn: true,
|
||||
sourceTable: referenceTable,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`참조 테이블 ${referenceTable} 컬럼 로드 실패:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setColumns([...baseColumns, ...joinColumnOptions]);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [config.tableName]);
|
||||
}, [tableName]);
|
||||
|
||||
// 컬럼 관리
|
||||
const configColumns = config.columns || [];
|
||||
// 컬럼 설정
|
||||
const configColumns: Array<{ key: string; title: string; width?: string; isJoinColumn?: boolean }> =
|
||||
config.columns || [];
|
||||
|
||||
const addColumn = () => {
|
||||
const newColumns = [...configColumns, { key: "", title: "", width: "" }];
|
||||
// 컬럼이 추가되었는지 확인
|
||||
const isColumnAdded = (columnName: string) => {
|
||||
return configColumns.some((col) => col.key === columnName);
|
||||
};
|
||||
|
||||
// 컬럼 토글 (추가/제거)
|
||||
const toggleColumn = (column: ColumnOption) => {
|
||||
if (isColumnAdded(column.columnName)) {
|
||||
// 제거
|
||||
const newColumns = configColumns.filter((col) => col.key !== column.columnName);
|
||||
updateConfig("columns", newColumns);
|
||||
} else {
|
||||
// 추가
|
||||
const newColumn = {
|
||||
key: column.columnName,
|
||||
title: column.displayName,
|
||||
width: "",
|
||||
isJoinColumn: column.isJoinColumn || false,
|
||||
};
|
||||
updateConfig("columns", [...configColumns, newColumn]);
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 제목 수정
|
||||
const updateColumnTitle = (columnKey: string, title: string) => {
|
||||
const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, title } : col));
|
||||
updateConfig("columns", newColumns);
|
||||
};
|
||||
|
||||
const updateColumn = (index: number, field: string, value: string) => {
|
||||
const newColumns = [...configColumns];
|
||||
newColumns[index] = { ...newColumns[index], [field]: value };
|
||||
// 컬럼 너비 수정
|
||||
const updateColumnWidth = (columnKey: string, width: string) => {
|
||||
const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, width } : col));
|
||||
updateConfig("columns", newColumns);
|
||||
};
|
||||
|
||||
const removeColumn = (index: number) => {
|
||||
const newColumns = configColumns.filter((_: any, i: number) => i !== index);
|
||||
updateConfig("columns", newColumns);
|
||||
// 그룹별 컬럼 분리
|
||||
const baseColumns = useMemo(() => columns.filter((col) => !col.isJoinColumn), [columns]);
|
||||
|
||||
// 조인 컬럼을 소스 테이블별로 그룹화
|
||||
const joinColumnsByTable = useMemo(() => {
|
||||
const grouped: Record<string, ColumnOption[]> = {};
|
||||
columns
|
||||
.filter((col) => col.isJoinColumn)
|
||||
.forEach((col) => {
|
||||
const table = col.sourceTable || "unknown";
|
||||
if (!grouped[table]) {
|
||||
grouped[table] = [];
|
||||
}
|
||||
grouped[table].push(col);
|
||||
});
|
||||
return grouped;
|
||||
}, [columns]);
|
||||
|
||||
// 조인 섹션 토글
|
||||
const toggleJoinSection = (tableName: string) => {
|
||||
setExpandedJoinSections((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(tableName)) {
|
||||
newSet.delete(tableName);
|
||||
} else {
|
||||
newSet.add(tableName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 정보 (읽기 전용) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||
{tableName ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="text-muted-foreground h-4 w-4" />
|
||||
<span className="text-sm font-medium">{tableName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600">화면에 테이블이 설정되지 않았습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 뷰 모드 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 방식</Label>
|
||||
<Select
|
||||
value={config.viewMode || "table"}
|
||||
onValueChange={(value) => updateConfig("viewMode", value)}
|
||||
>
|
||||
<Select value={config.viewMode || "table"} onValueChange={(value) => updateConfig("viewMode", value)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="방식 선택" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -130,153 +233,226 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
{/* 카드 모드 설정 */}
|
||||
{config.viewMode === "card" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">카드 설정</Label>
|
||||
|
||||
{/* 데이터 소스 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||
<Select
|
||||
value={config.source || "static"}
|
||||
onValueChange={(value) => updateConfig("source", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">정적 데이터</SelectItem>
|
||||
<SelectItem value="db">데이터베이스</SelectItem>
|
||||
<SelectItem value="api">API</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* DB 설정 */}
|
||||
{config.source === "db" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={config.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("tableName", value);
|
||||
updateConfig("columns", []); // 테이블 변경 시 컬럼 초기화
|
||||
}}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 설정 */}
|
||||
{config.source === "api" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">API 엔드포인트</Label>
|
||||
<Input
|
||||
value={config.apiEndpoint || ""}
|
||||
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||
placeholder="/api/list"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">컬럼 설정</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addColumn}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{configColumns.map((column: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{/* 컬럼 키 - 드롭다운 */}
|
||||
{/* 제목 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-[10px]">제목 컬럼</Label>
|
||||
<Select
|
||||
value={column.key || ""}
|
||||
onValueChange={(value) => {
|
||||
const selectedCol = columns.find(c => c.columnName === value);
|
||||
updateColumn(index, "key", value);
|
||||
// 제목을 자동으로 설정
|
||||
if (selectedCol && !column.title) {
|
||||
updateColumn(index, "title", selectedCol.displayName);
|
||||
}
|
||||
}}
|
||||
disabled={loadingColumns || !config.tableName}
|
||||
value={config.cardConfig?.titleColumn || ""}
|
||||
onValueChange={(value) => updateConfig("cardConfig", { ...config.cardConfig, titleColumn: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
{configColumns.map((col: any) => (
|
||||
<SelectItem key={col.key} value={col.key}>
|
||||
{col.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={column.title || ""}
|
||||
onChange={(e) => updateColumn(index, "title", e.target.value)}
|
||||
placeholder="제목"
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={column.width || ""}
|
||||
onChange={(e) => updateColumn(index, "width", e.target.value)}
|
||||
placeholder="너비"
|
||||
className="h-7 text-xs w-16"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeColumn(index)}
|
||||
className="h-7 w-7 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{configColumns.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">
|
||||
컬럼을 추가해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부제목 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-[10px]">부제목 컬럼</Label>
|
||||
<Select
|
||||
value={config.cardConfig?.subtitleColumn || "_none_"}
|
||||
onValueChange={(value) =>
|
||||
updateConfig("cardConfig", { ...config.cardConfig, subtitleColumn: value === "_none_" ? "" : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">없음</SelectItem>
|
||||
{configColumns.map((col: any) => (
|
||||
<SelectItem key={col.key} value={col.key}>
|
||||
{col.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 행당 카드 수 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-[10px]">행당 카드 수</Label>
|
||||
<Select
|
||||
value={String(config.cardConfig?.cardsPerRow || 3)}
|
||||
onValueChange={(value) =>
|
||||
updateConfig("cardConfig", { ...config.cardConfig, cardsPerRow: parseInt(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1개</SelectItem>
|
||||
<SelectItem value="2">2개</SelectItem>
|
||||
<SelectItem value="3">3개</SelectItem>
|
||||
<SelectItem value="4">4개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시할 컬럼 선택</Label>
|
||||
|
||||
{loadingColumns ? (
|
||||
<p className="text-muted-foreground py-2 text-xs">컬럼 로딩 중...</p>
|
||||
) : !tableName ? (
|
||||
<p className="text-muted-foreground py-2 text-xs">테이블을 선택해주세요</p>
|
||||
) : (
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto rounded-md border p-2">
|
||||
{/* 테이블 컬럼 */}
|
||||
<div className="space-y-0.5">
|
||||
{baseColumns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
||||
isColumnAdded(column.columnName) && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => toggleColumn(column)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isColumnAdded(column.columnName)}
|
||||
onCheckedChange={() => toggleColumn(column)}
|
||||
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
|
||||
/>
|
||||
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate text-xs">{column.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 조인 컬럼 (테이블별 그룹) */}
|
||||
{Object.keys(joinColumnsByTable).length > 0 && (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<div className="text-muted-foreground mb-1 flex items-center gap-1 text-[10px] font-medium">
|
||||
<Link2 className="h-3 w-3 text-blue-500" />
|
||||
엔티티 조인 컬럼
|
||||
</div>
|
||||
{Object.entries(joinColumnsByTable).map(([refTable, refColumns]) => (
|
||||
<div key={refTable} className="mb-1">
|
||||
<div
|
||||
className="hover:bg-muted/30 flex cursor-pointer items-center gap-1 rounded px-1 py-0.5"
|
||||
onClick={() => toggleJoinSection(refTable)}
|
||||
>
|
||||
{expandedJoinSections.has(refTable) ? (
|
||||
<ChevronDown className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-[10px] font-medium text-blue-600">{refTable}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({refColumns.length})</span>
|
||||
</div>
|
||||
|
||||
{expandedJoinSections.has(refTable) && (
|
||||
<div className="ml-3 space-y-0.5">
|
||||
{refColumns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
||||
isColumnAdded(column.columnName) && "bg-blue-50",
|
||||
)}
|
||||
onClick={() => toggleColumn(column)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isColumnAdded(column.columnName)}
|
||||
onCheckedChange={() => toggleColumn(column)}
|
||||
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{column.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 컬럼 상세 설정 */}
|
||||
{configColumns.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">선택된 컬럼 ({configColumns.length}개)</Label>
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto">
|
||||
{configColumns.map((column, index) => {
|
||||
const colInfo = columns.find((c) => c.columnName === column.key);
|
||||
return (
|
||||
<div key={column.key} className="bg-muted/30 flex items-center gap-2 rounded-md border p-2">
|
||||
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab" />
|
||||
{column.isJoinColumn ? (
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
<Input
|
||||
value={column.title}
|
||||
onChange={(e) => updateColumnTitle(column.key, e.target.value)}
|
||||
placeholder="제목"
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={column.width || ""}
|
||||
onChange={(e) => updateColumnWidth(column.key, e.target.value)}
|
||||
placeholder="너비"
|
||||
className="h-6 w-14 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleColumn(colInfo || { columnName: column.key, displayName: column.title })}
|
||||
className="text-destructive h-6 w-6 p-0"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 기능 옵션 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">기능 옵션</Label>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="sortable"
|
||||
checked={config.sortable !== false}
|
||||
onCheckedChange={(checked) => updateConfig("sortable", checked)}
|
||||
/>
|
||||
<label htmlFor="sortable" className="text-xs">정렬 기능</label>
|
||||
<label htmlFor="sortable" className="text-xs">
|
||||
정렬 기능
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -285,7 +461,9 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
|
|||
checked={config.pagination !== false}
|
||||
onCheckedChange={(checked) => updateConfig("pagination", checked)}
|
||||
/>
|
||||
<label htmlFor="pagination" className="text-xs">페이지네이션</label>
|
||||
<label htmlFor="pagination" className="text-xs">
|
||||
페이지네이션
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -294,7 +472,9 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
|
|||
checked={config.searchable || false}
|
||||
onCheckedChange={(checked) => updateConfig("searchable", checked)}
|
||||
/>
|
||||
<label htmlFor="searchable" className="text-xs">검색 기능</label>
|
||||
<label htmlFor="searchable" className="text-xs">
|
||||
검색 기능
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -303,30 +483,35 @@ export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
|
|||
checked={config.editable || false}
|
||||
onCheckedChange={(checked) => updateConfig("editable", checked)}
|
||||
/>
|
||||
<label htmlFor="editable" className="text-xs">인라인 편집</label>
|
||||
<label htmlFor="editable" className="text-xs">
|
||||
인라인 편집
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
{config.pagination !== false && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">페이지당 행 수</Label>
|
||||
<Select
|
||||
value={String(config.pageSize || 10)}
|
||||
onValueChange={(value) => updateConfig("pageSize", Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">페이지당 행 수</Label>
|
||||
<Select
|
||||
value={String(config.pageSize || 10)}
|
||||
onValueChange={(value) => updateConfig("pageSize", Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,14 @@ export interface ComponentRenderer {
|
|||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
onSelectedRowsChange?: (
|
||||
selectedRows: any[],
|
||||
selectedRowsData: any[],
|
||||
sortBy?: string,
|
||||
sortOrder?: "asc" | "desc",
|
||||
columnOrder?: string[],
|
||||
tableDisplayData?: any[],
|
||||
) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
|
@ -126,7 +133,14 @@ export interface DynamicComponentRendererProps {
|
|||
// 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트)
|
||||
disabledFields?: string[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
onSelectedRowsChange?: (
|
||||
selectedRows: any[],
|
||||
selectedRowsData: any[],
|
||||
sortBy?: string,
|
||||
sortOrder?: "asc" | "desc",
|
||||
columnOrder?: string[],
|
||||
tableDisplayData?: any[],
|
||||
) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
|
@ -146,7 +160,7 @@ export interface DynamicComponentRendererProps {
|
|||
// 모달 내에서 렌더링 여부
|
||||
isInModal?: boolean;
|
||||
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||
// 🆕 조건부 비활성화 상태
|
||||
conditionalDisabled?: boolean;
|
||||
|
|
@ -172,7 +186,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const config = (component as any).componentConfig || {};
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
const currentValue = props.formData?.[fieldName];
|
||||
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
if (props.onFormDataChange) {
|
||||
props.onFormDataChange(fieldName, value);
|
||||
|
|
@ -259,6 +273,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
);
|
||||
|
||||
case "unified-list":
|
||||
// 데이터 소스: config.data > props.tableDisplayData > []
|
||||
const listData = config.data?.length > 0 ? config.data : props.tableDisplayData || [];
|
||||
|
||||
return (
|
||||
<UnifiedList
|
||||
unifiedType="UnifiedList"
|
||||
|
|
@ -271,8 +288,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
pagination: config.pagination,
|
||||
searchable: config.searchable,
|
||||
editable: config.editable,
|
||||
pageable: config.pageable,
|
||||
pageSize: config.pageSize,
|
||||
cardConfig: config.cardConfig,
|
||||
dataSource: {
|
||||
table: config.dataSource?.table || props.tableName,
|
||||
},
|
||||
}}
|
||||
data={config.data || []}
|
||||
data={listData}
|
||||
selectedRows={props.selectedRowsData || []}
|
||||
onRowSelect={
|
||||
props.onSelectedRowsChange
|
||||
? (rows) =>
|
||||
props.onSelectedRowsChange?.(
|
||||
rows.map((r: any) => r.id || r.objid),
|
||||
rows,
|
||||
props.sortBy,
|
||||
props.sortOrder,
|
||||
undefined,
|
||||
props.tableDisplayData,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -372,17 +409,22 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const webType = (component as any).componentConfig?.webType;
|
||||
const tableName = (component as any).tableName;
|
||||
const columnName = (component as any).columnName;
|
||||
|
||||
|
||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
|
||||
if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") {
|
||||
if (
|
||||
(inputType === "category" || webType === "category") &&
|
||||
tableName &&
|
||||
columnName &&
|
||||
componentType === "select-basic"
|
||||
) {
|
||||
// select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
|
||||
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||
try {
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const fieldName = columnName || component.id;
|
||||
const currentValue = props.formData?.[fieldName] || "";
|
||||
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
if (props.onFormDataChange) {
|
||||
props.onFormDataChange(fieldName, value);
|
||||
|
|
@ -496,7 +538,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
|
||||
// 컴포넌트의 columnName에 해당하는 formData 값 추출
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
||||
|
||||
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
|
||||
let currentValue;
|
||||
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
|
||||
|
|
@ -505,7 +547,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
||||
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
const handleChange = (value: any) => {
|
||||
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
||||
|
|
@ -551,10 +593,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
};
|
||||
|
||||
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
|
||||
const useConfigTableName = componentType === "entity-search-input" ||
|
||||
componentType === "autocomplete-search-input" ||
|
||||
componentType === "modal-repeater-table";
|
||||
|
||||
const useConfigTableName =
|
||||
componentType === "entity-search-input" ||
|
||||
componentType === "autocomplete-search-input" ||
|
||||
componentType === "modal-repeater-table";
|
||||
|
||||
const rendererProps = {
|
||||
component,
|
||||
isSelected,
|
||||
|
|
@ -578,7 +621,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onFormDataChange,
|
||||
onChange: handleChange, // 개선된 onChange 핸들러 전달
|
||||
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
|
||||
tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName,
|
||||
tableName: useConfigTableName ? component.componentConfig?.tableName || tableName : tableName,
|
||||
menuId, // 🆕 메뉴 ID
|
||||
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
|
||||
selectedScreen, // 🆕 화면 정보
|
||||
|
|
@ -677,10 +720,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
|
||||
// 폴백 렌더링 - 기본 플레이스홀더
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-border bg-muted p-4">
|
||||
<div className="border-border bg-muted flex h-full w-full items-center justify-center rounded border-2 border-dashed p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-sm font-medium text-muted-foreground">{component.label || component.id}</div>
|
||||
<div className="text-xs text-muted-foreground/70">미구현 컴포넌트: {componentType}</div>
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{component.label || component.id}</div>
|
||||
<div className="text-muted-foreground/70 text-xs">미구현 컴포넌트: {componentType}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ interface CardModeRendererProps {
|
|||
data: Record<string, any>[];
|
||||
cardConfig: CardDisplayConfig;
|
||||
visibleColumns: ColumnConfig[];
|
||||
onRowClick?: (row: Record<string, any>) => void;
|
||||
onRowClick?: (row: Record<string, any>, index: number, e: React.MouseEvent) => void;
|
||||
onRowSelect?: (row: Record<string, any>, selected: boolean) => void;
|
||||
selectedRows?: string[];
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -26,19 +25,24 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
cardConfig,
|
||||
visibleColumns,
|
||||
onRowClick,
|
||||
onRowSelect,
|
||||
selectedRows = [],
|
||||
showActions = true,
|
||||
}) => {
|
||||
// 기본값 설정
|
||||
// 기본값과 병합
|
||||
const config = {
|
||||
cardsPerRow: 3,
|
||||
cardSpacing: 16,
|
||||
showActions: true,
|
||||
cardHeight: "auto",
|
||||
...cardConfig,
|
||||
idColumn: cardConfig?.idColumn || "",
|
||||
titleColumn: cardConfig?.titleColumn || "",
|
||||
subtitleColumn: cardConfig?.subtitleColumn,
|
||||
descriptionColumn: cardConfig?.descriptionColumn,
|
||||
imageColumn: cardConfig?.imageColumn,
|
||||
cardsPerRow: cardConfig?.cardsPerRow ?? 3,
|
||||
cardSpacing: cardConfig?.cardSpacing ?? 16,
|
||||
showActions: cardConfig?.showActions ?? true,
|
||||
cardHeight: cardConfig?.cardHeight as number | "auto" | undefined,
|
||||
};
|
||||
|
||||
// 디버깅: cardConfig 확인
|
||||
console.log("🃏 CardModeRenderer config:", { cardConfig, mergedConfig: config });
|
||||
|
||||
// 카드 그리드 스타일 계산
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
|
|
@ -60,11 +64,11 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
};
|
||||
|
||||
// 액션 버튼 렌더링
|
||||
const renderActions = (row: Record<string, any>) => {
|
||||
if (!showActions || !config.showActions) return null;
|
||||
const renderActions = (_row: Record<string, any>) => {
|
||||
if (!config.showActions) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end space-x-1 mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="mt-3 flex items-center justify-end space-x-1 border-t border-gray-100 pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -113,11 +117,11 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mb-4">
|
||||
<div className="w-8 h-8 bg-muted-foreground/20 rounded-lg"></div>
|
||||
<div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
|
||||
<div className="bg-muted-foreground/20 h-8 w-8 rounded-lg"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">표시할 데이터가 없습니다</div>
|
||||
<div className="text-xs text-muted-foreground/60">조건을 변경하거나 새로운 데이터를 추가해보세요</div>
|
||||
<div className="text-muted-foreground mb-1 text-sm font-medium">표시할 데이터가 없습니다</div>
|
||||
<div className="text-muted-foreground/60 text-xs">조건을 변경하거나 새로운 데이터를 추가해보세요</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -130,7 +134,7 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
const subtitleValue = getColumnValue(row, config.subtitleColumn);
|
||||
const descriptionValue = getColumnValue(row, config.descriptionColumn);
|
||||
const imageValue = getColumnValue(row, config.imageColumn);
|
||||
|
||||
|
||||
const isSelected = selectedRows.includes(idValue);
|
||||
|
||||
return (
|
||||
|
|
@ -138,23 +142,17 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
key={`card-${index}-${idValue}`}
|
||||
style={cardStyle}
|
||||
className={`transition-all duration-200 hover:shadow-md ${
|
||||
isSelected ? "ring-2 ring-blue-500 bg-blue-50/30" : ""
|
||||
isSelected ? "bg-blue-50/30 ring-2 ring-blue-500" : ""
|
||||
}`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
onClick={(e) => onRowClick?.(row, index, e)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm font-medium truncate">
|
||||
{titleValue || "제목 없음"}
|
||||
</CardTitle>
|
||||
{subtitleValue && (
|
||||
<div className="text-xs text-gray-500 mt-1 truncate">
|
||||
{subtitleValue}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-sm font-medium">{titleValue || "제목 없음"}</CardTitle>
|
||||
{subtitleValue && <div className="mt-1 truncate text-xs text-gray-500">{subtitleValue}</div>}
|
||||
</div>
|
||||
|
||||
|
||||
{/* ID 뱃지 */}
|
||||
{idValue && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
|
|
@ -171,7 +169,7 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
<img
|
||||
src={imageValue}
|
||||
alt={titleValue}
|
||||
className="w-full h-24 object-cover rounded-md bg-gray-100"
|
||||
className="h-24 w-full rounded-md bg-gray-100 object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
|
|
@ -181,33 +179,30 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
)}
|
||||
|
||||
{/* 설명 표시 */}
|
||||
{descriptionValue && (
|
||||
<div className="text-xs text-gray-600 line-clamp-2 mb-3">
|
||||
{descriptionValue}
|
||||
</div>
|
||||
)}
|
||||
{descriptionValue && <div className="mb-3 line-clamp-2 text-xs text-gray-600">{descriptionValue}</div>}
|
||||
|
||||
{/* 추가 필드들 표시 (선택적) */}
|
||||
<div className="space-y-1">
|
||||
{visibleColumns
|
||||
.filter(col =>
|
||||
col.columnName !== config.idColumn &&
|
||||
col.columnName !== config.titleColumn &&
|
||||
col.columnName !== config.subtitleColumn &&
|
||||
col.columnName !== config.descriptionColumn &&
|
||||
col.columnName !== config.imageColumn &&
|
||||
col.columnName !== "__checkbox__" &&
|
||||
col.visible
|
||||
{(visibleColumns || [])
|
||||
.filter(
|
||||
(col) =>
|
||||
col.columnName !== config.idColumn &&
|
||||
col.columnName !== config.titleColumn &&
|
||||
col.columnName !== config.subtitleColumn &&
|
||||
col.columnName !== config.descriptionColumn &&
|
||||
col.columnName !== config.imageColumn &&
|
||||
col.columnName !== "__checkbox__" &&
|
||||
col.visible,
|
||||
)
|
||||
.slice(0, 3) // 최대 3개 추가 필드만 표시
|
||||
.map((col) => {
|
||||
const value = getColumnValue(row, col.columnName);
|
||||
if (!value) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div key={col.columnName} className="flex justify-between items-center text-xs">
|
||||
<span className="text-gray-500 truncate">{col.displayName}:</span>
|
||||
<span className="font-medium truncate ml-2">{value}</span>
|
||||
<div key={col.columnName} className="flex items-center justify-between text-xs">
|
||||
<span className="truncate text-gray-500">{col.displayName}:</span>
|
||||
<span className="ml-2 truncate font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ interface SingleTableWithStickyProps {
|
|||
handleSort?: (columnName: string) => void;
|
||||
onSort?: (columnName: string) => void;
|
||||
handleSelectAll?: (checked: boolean) => void;
|
||||
handleRowClick?: (row: any) => void;
|
||||
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
|
||||
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
|
||||
renderCheckboxHeader?: () => React.ReactNode;
|
||||
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
|
||||
|
|
@ -77,278 +77,290 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
const checkboxConfig = tableConfig?.checkbox || {};
|
||||
const actualColumns = visibleColumns || columns || [];
|
||||
const sortHandler = onSort || handleSort || (() => {});
|
||||
const actualData = data || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col bg-background shadow-sm"
|
||||
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div className="relative overflow-x-auto">
|
||||
<Table
|
||||
className="w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
tableLayout: "auto", // 테이블 크기 자동 조정
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<TableHeader
|
||||
className={cn(
|
||||
"border-b bg-background",
|
||||
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
|
||||
)}
|
||||
<div className="relative flex-1 overflow-auto">
|
||||
<Table
|
||||
className="w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
tableLayout: "auto", // 테이블 크기 자동 조정
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<TableRow className="border-b">
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
<TableHeader
|
||||
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
|
||||
>
|
||||
<TableRow className="border-b">
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background"
|
||||
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-primary/10",
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" &&
|
||||
"sticky z-40 border-r border-border bg-background shadow-sm",
|
||||
column.fixed === "right" &&
|
||||
"sticky z-40 border-l border-border bg-background shadow-sm",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
checkboxConfig.selectAll && (
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate">
|
||||
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||
</span>
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
<span className="ml-1 flex h-4 w-4 items-center justify-center rounded-md bg-background/50 shadow-sm sm:ml-2 sm:h-5 sm:w-5">
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
return (
|
||||
<TableHead
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
|
||||
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-primary/10",
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
|
||||
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<svg className="h-6 w-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">데이터가 없습니다</span>
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
|
||||
조건을 변경하여 다시 검색해보세요
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
checkboxConfig.selectAll && (
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate">
|
||||
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||
</span>
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<TableRow
|
||||
key={`row-${index}`}
|
||||
className={cn(
|
||||
"h-14 cursor-pointer border-b transition-colors bg-background sm:h-16",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = visibleColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
</TableHeader>
|
||||
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
<TableBody>
|
||||
{actualData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={actualColumns.length || 1} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<svg
|
||||
className="text-muted-foreground h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">데이터가 없습니다</span>
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
|
||||
조건을 변경하여 다시 검색해보세요
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
actualData.map((row, index) => (
|
||||
<TableRow
|
||||
key={`row-${index}`}
|
||||
className={cn(
|
||||
"bg-background h-10 cursor-pointer border-b transition-colors",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
||||
)}
|
||||
onClick={(e) => handleRowClick?.(row, index, e)}
|
||||
>
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
|
||||
// 현재 셀이 편집 중인지 확인
|
||||
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns
|
||||
.slice(rightFixedIndex + 1)
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
|
||||
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
|
||||
const cellKey = `${index}-${colIndex}`;
|
||||
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
|
||||
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
|
||||
|
||||
// 인덱스 기반 하이라이트 + 실제 값 검증
|
||||
const 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 isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
|
||||
|
||||
// 셀 값에서 검색어 하이라이트 렌더링
|
||||
const renderCellContent = () => {
|
||||
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
||||
|
||||
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||
return cellValue;
|
||||
}
|
||||
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
|
||||
const cellKey = `${index}-${colIndex}`;
|
||||
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
|
||||
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
|
||||
|
||||
// 검색어 하이라이트 처리
|
||||
const lowerValue = String(cellValue).toLowerCase();
|
||||
const lowerTerm = searchTerm.toLowerCase();
|
||||
const startIndex = lowerValue.indexOf(lowerTerm);
|
||||
|
||||
if (startIndex === -1) return cellValue;
|
||||
// 인덱스 기반 하이라이트 + 실제 값 검증
|
||||
const isHighlighted =
|
||||
column.columnName !== "__checkbox__" &&
|
||||
hasSearchTerm &&
|
||||
(searchHighlights?.has(cellKey) ?? false);
|
||||
|
||||
const before = String(cellValue).slice(0, startIndex);
|
||||
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
|
||||
const after = String(cellValue).slice(startIndex + searchTerm.length);
|
||||
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
|
||||
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
|
||||
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 (
|
||||
<>
|
||||
{before}
|
||||
<mark className={cn(
|
||||
"rounded px-0.5",
|
||||
isCurrentSearchResult
|
||||
? "bg-orange-400 text-white font-semibold"
|
||||
: "bg-yellow-200 text-yellow-900"
|
||||
)}>
|
||||
{match}
|
||||
</mark>
|
||||
{after}
|
||||
</>
|
||||
<TableCell
|
||||
key={`cell-${column.columnName}`}
|
||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||
className={cn(
|
||||
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
|
||||
`text-${column.align}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" &&
|
||||
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
|
||||
column.fixed === "right" &&
|
||||
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
|
||||
// 편집 가능 셀 스타일
|
||||
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
|
||||
e.stopPropagation();
|
||||
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxCell?.(row, index)
|
||||
) : isEditing ? (
|
||||
// 인라인 편집 입력 필드
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editingValue ?? ""}
|
||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||
onKeyDown={onEditKeyDown}
|
||||
onBlur={() => {
|
||||
// blur 시 저장 (Enter와 동일)
|
||||
if (onEditKeyDown) {
|
||||
const fakeEvent = {
|
||||
key: "Enter",
|
||||
preventDefault: () => {},
|
||||
} as React.KeyboardEvent<HTMLInputElement>;
|
||||
onEditKeyDown(fakeEvent);
|
||||
}
|
||||
}}
|
||||
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
renderCellContent()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={`cell-${column.columnName}`}
|
||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||
className={cn(
|
||||
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
|
||||
`text-${column.align}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" &&
|
||||
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
|
||||
column.fixed === "right" &&
|
||||
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
|
||||
// 편집 가능 셀 스타일
|
||||
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
|
||||
e.stopPropagation();
|
||||
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxCell(row, index)
|
||||
) : isEditing ? (
|
||||
// 인라인 편집 입력 필드
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editingValue ?? ""}
|
||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||
onKeyDown={onEditKeyDown}
|
||||
onBlur={() => {
|
||||
// blur 시 저장 (Enter와 동일)
|
||||
if (onEditKeyDown) {
|
||||
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
|
||||
onEditKeyDown(fakeEvent);
|
||||
}
|
||||
}}
|
||||
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
renderCellContent()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5193,13 +5193,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (tableConfig.displayMode === "card" && !isDesignMode) {
|
||||
return (
|
||||
<div {...domProps}>
|
||||
<CardModeRenderer
|
||||
data={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
cardConfig={tableConfig.cardConfig}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<span className="text-destructive text-sm">{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<CardModeRenderer
|
||||
data={data}
|
||||
cardConfig={
|
||||
tableConfig.cardConfig || {
|
||||
idColumn: visibleColumns[0]?.columnName || "id",
|
||||
titleColumn: visibleColumns[0]?.columnName || "",
|
||||
cardsPerRow: 3,
|
||||
cardSpacing: 16,
|
||||
showActions: false,
|
||||
}
|
||||
}
|
||||
visibleColumns={visibleColumns}
|
||||
onRowClick={handleRowClick}
|
||||
selectedRows={Array.from(selectedRows)}
|
||||
/>
|
||||
)}
|
||||
{paginationJSX}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Unified 컴포넌트 타입 정의
|
||||
*
|
||||
*
|
||||
* 10개의 통합 컴포넌트 시스템을 위한 타입 정의
|
||||
* - UnifiedInput
|
||||
* - UnifiedSelect
|
||||
|
|
@ -251,6 +251,16 @@ export interface ListColumn {
|
|||
format?: string;
|
||||
}
|
||||
|
||||
export interface UnifiedListCardConfig {
|
||||
titleColumn?: string;
|
||||
subtitleColumn?: string;
|
||||
descriptionColumn?: string;
|
||||
imageColumn?: string;
|
||||
cardsPerRow?: number;
|
||||
cardSpacing?: number;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedListConfig {
|
||||
viewMode: UnifiedListViewMode;
|
||||
editable?: boolean;
|
||||
|
|
@ -259,6 +269,7 @@ export interface UnifiedListConfig {
|
|||
pageSize?: number;
|
||||
columns?: ListColumn[];
|
||||
modal?: boolean;
|
||||
cardConfig?: UnifiedListCardConfig;
|
||||
// 데이터 소스
|
||||
dataSource?: {
|
||||
table?: string;
|
||||
|
|
@ -456,47 +467,46 @@ export const LEGACY_TO_UNIFIED_MAP: Record<string, UnifiedComponentType> = {
|
|||
"text-input": "UnifiedInput",
|
||||
"number-input": "UnifiedInput",
|
||||
"password-input": "UnifiedInput",
|
||||
|
||||
|
||||
// Select 계열
|
||||
"select-basic": "UnifiedSelect",
|
||||
"radio-basic": "UnifiedSelect",
|
||||
"checkbox-basic": "UnifiedSelect",
|
||||
"entity-search-input": "UnifiedSelect",
|
||||
"autocomplete-search-input": "UnifiedSelect",
|
||||
|
||||
|
||||
// Date 계열
|
||||
"date-input": "UnifiedDate",
|
||||
|
||||
|
||||
// Text 계열
|
||||
"textarea-basic": "UnifiedText",
|
||||
|
||||
|
||||
// Media 계열
|
||||
"file-upload": "UnifiedMedia",
|
||||
"image-widget": "UnifiedMedia",
|
||||
|
||||
|
||||
// List 계열
|
||||
"table-list": "UnifiedList",
|
||||
"table-search-widget": "UnifiedList",
|
||||
"modal-repeater-table": "UnifiedList",
|
||||
"repeater-field-group": "UnifiedList",
|
||||
"card-display": "UnifiedList",
|
||||
|
||||
|
||||
// Layout 계열
|
||||
"split-panel-layout": "UnifiedLayout",
|
||||
"screen-split-panel": "UnifiedLayout",
|
||||
|
||||
|
||||
// Group 계열
|
||||
"tabs-widget": "UnifiedGroup",
|
||||
"section-paper": "UnifiedGroup",
|
||||
"section-card": "UnifiedGroup",
|
||||
"universal-form-modal": "UnifiedGroup",
|
||||
|
||||
|
||||
// Biz 계열
|
||||
"category-manager": "UnifiedBiz",
|
||||
"numbering-rule": "UnifiedBiz",
|
||||
"flow-widget": "UnifiedBiz",
|
||||
|
||||
|
||||
// Button (Input의 버튼 모드)
|
||||
"button-primary": "UnifiedInput",
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue