컬럼 고정기능 구현

This commit is contained in:
kjs 2025-09-18 15:14:14 +09:00
parent f5caa7127c
commit 964b6415f8
4 changed files with 1030 additions and 613 deletions

View File

@ -58,25 +58,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
screenId,
size,
position,
componentConfig,
selectedScreen,
onZoneComponentDrop,
onZoneClick,
tableName,
onRefresh,
onClose,
}) => {
// 컴포넌트 설정
const tableConfig = {
@ -86,7 +75,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} as TableListConfig;
// 상태 관리
const [data, setData] = useState<any[]>([]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
@ -150,7 +139,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
const response = await tableTypeApi.getColumns(tableConfig.selectedTable);
// API 응답 구조 확인 및 컬럼 배열 추출
const columns = Array.isArray(response) ? response : response.columns || [];
const columns = Array.isArray(response) ? response : (response as any).columns || [];
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
@ -463,6 +452,72 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
}, [displayColumns, tableConfig.columns]);
// 컬럼을 고정 위치별로 분류
const columnsByPosition = useMemo(() => {
const leftFixed: ColumnConfig[] = [];
const rightFixed: ColumnConfig[] = [];
const normal: ColumnConfig[] = [];
visibleColumns.forEach((col) => {
if (col.fixed === "left") {
leftFixed.push(col);
} else if (col.fixed === "right") {
rightFixed.push(col);
} else {
normal.push(col);
}
});
// 고정 컬럼들은 fixedOrder로 정렬
leftFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
rightFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
return { leftFixed, rightFixed, normal };
}, [visibleColumns]);
// 가로 스크롤이 필요한지 계산
const needsHorizontalScroll = useMemo(() => {
if (!tableConfig.horizontalScroll?.enabled) {
console.log("🚫 가로 스크롤 비활성화됨");
return false;
}
const maxVisible = tableConfig.horizontalScroll.maxVisibleColumns || 8;
const totalColumns = visibleColumns.length;
const result = totalColumns > maxVisible;
console.log(
`🔍 가로 스크롤 계산: ${totalColumns}개 컬럼 > ${maxVisible}개 최대 = ${result ? "스크롤 필요" : "스크롤 불필요"}`,
);
console.log("📊 가로 스크롤 설정:", tableConfig.horizontalScroll);
console.log(
"📋 현재 컬럼들:",
visibleColumns.map((c) => c.columnName),
);
return result;
}, [visibleColumns.length, tableConfig.horizontalScroll]);
// 컬럼 너비 계산 - 내용 길이에 맞게 자동 조정
const getColumnWidth = (column: ColumnConfig) => {
if (column.width) return column.width;
// 컬럼 헤더 텍스트 길이 기반으로 계산
const headerText = columnLabels[column.columnName] || column.displayName || column.columnName;
const headerLength = headerText.length;
// 데이터 셀의 최대 길이 추정 (실제 데이터가 있다면 더 정확하게 계산 가능)
const estimatedContentLength = Math.max(headerLength, 10); // 최소 10자
// 문자당 약 8px 정도로 계산하고, 패딩 및 여백 고려
const calculatedWidth = estimatedContentLength * 8 + 40; // 40px는 패딩과 여백
// 최소 너비만 보장하고, 최대 너비 제한은 제거
const minWidth = 80;
return Math.max(minWidth, calculatedWidth);
};
// 🎯 값 포맷팅 (전역 코드 캐시 사용)
const formatCellValue = useMemo(() => {
return (value: any, format?: string, columnName?: string) => {
@ -596,7 +651,226 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div className="mt-1 text-xs text-gray-400">{error}</div>
</div>
</div>
) : needsHorizontalScroll ? (
// 가로 스크롤이 필요한 경우 - 고정 컬럼 지원 테이블
<div className="relative flex h-full">
{/* 왼쪽 고정 컬럼 */}
{columnsByPosition.leftFixed.length > 0 && (
<div className="flex-shrink-0 border-r bg-gray-50/50">
<table className="table-auto">
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.leftFixed.map((column) => (
<th
key={`fixed-left-${column.columnName}`}
style={{ minWidth: `${getColumnWidth(column)}px` }}
className={cn(
"cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.leftFixed.length} className="py-8 text-center text-gray-500">
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`fixed-left-row-${index}`}
className={cn(
"cursor-pointer border-b",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.leftFixed.map((column) => (
<td
key={`fixed-left-cell-${column.columnName}`}
className={cn("px-4 py-3 text-sm whitespace-nowrap", `text-${column.align}`)}
>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* 스크롤 가능한 중앙 컬럼들 */}
<div className="flex-1 overflow-x-auto">
<table className="w-full table-auto">
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
<tr>
{columnsByPosition.normal.map((column) => (
<th
key={`normal-${column.columnName}`}
style={{ minWidth: `${getColumnWidth(column)}px` }}
className={cn(
"cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.normal.length} className="py-8 text-center text-gray-500">
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.rightFixed.length === 0
? "데이터가 없습니다"
: ""}
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`normal-row-${index}`}
className={cn(
"cursor-pointer border-b",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.normal.map((column) => (
<td
key={`normal-cell-${column.columnName}`}
className={cn("px-4 py-3 text-sm whitespace-nowrap", `text-${column.align}`)}
>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* 오른쪽 고정 컬럼 */}
{columnsByPosition.rightFixed.length > 0 && (
<div className="flex-shrink-0 border-l bg-gray-50/50">
<table className="table-auto">
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.rightFixed.map((column) => (
<th
key={`fixed-right-${column.columnName}`}
style={{ minWidth: `${getColumnWidth(column)}px` }}
className={cn(
"cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.rightFixed.length} className="py-8 text-center text-gray-500">
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.normal.length === 0
? "데이터가 없습니다"
: ""}
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`fixed-right-row-${index}`}
className={cn(
"cursor-pointer border-b",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.rightFixed.map((column) => (
<td
key={`fixed-right-cell-${column.columnName}`}
className={cn("px-4 py-3 text-sm whitespace-nowrap", `text-${column.align}`)}
>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
) : (
// 기존 테이블 (가로 스크롤이 필요 없는 경우)
<Table>
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
<TableRow>
@ -605,7 +879,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
key={column.columnName}
style={{ width: column.width ? `${column.width}px` : undefined }}
className={cn(
"cursor-pointer select-none",
"cursor-pointer whitespace-nowrap select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
@ -650,7 +924,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column) => (
<TableCell key={column.columnName} className={`text-${column.align}`}>
<TableCell key={column.columnName} className={cn("whitespace-nowrap", `text-${column.align}`)}>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
</TableCell>
))}

View File

@ -10,22 +10,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin";
import {
Plus,
Trash2,
ArrowUp,
ArrowDown,
Eye,
EyeOff,
Settings,
Columns,
Filter,
Palette,
MousePointer,
} from "lucide-react";
import { Plus, Trash2, ArrowUp, ArrowDown, Settings, Columns, Filter, Palette, MousePointer } from "lucide-react";
export interface TableListConfigPanelProps {
config: TableListConfig;
@ -321,6 +308,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
{/* 기본 설정 탭 */}
<TabsContent value="basic" className="space-y-4">
<ScrollArea className="h-[600px] pr-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
@ -482,10 +470,90 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="horizontalScrollEnabled"
checked={config.horizontalScroll?.enabled}
onCheckedChange={(checked) => handleNestedChange("horizontalScroll", "enabled", checked)}
/>
<Label htmlFor="horizontalScrollEnabled"> </Label>
</div>
{config.horizontalScroll?.enabled && (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="maxVisibleColumns" className="text-sm">
</Label>
<Input
id="maxVisibleColumns"
type="number"
value={config.horizontalScroll?.maxVisibleColumns || 8}
onChange={(e) =>
handleNestedChange("horizontalScroll", "maxVisibleColumns", parseInt(e.target.value) || 8)
}
min={3}
max={20}
placeholder="8"
className="h-8"
/>
<div className="text-xs text-gray-500"> </div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="minColumnWidth" className="text-sm">
(px)
</Label>
<Input
id="minColumnWidth"
type="number"
value={config.horizontalScroll?.minColumnWidth || 100}
onChange={(e) =>
handleNestedChange("horizontalScroll", "minColumnWidth", parseInt(e.target.value) || 100)
}
min={50}
max={500}
placeholder="100"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label htmlFor="maxColumnWidth" className="text-sm">
(px)
</Label>
<Input
id="maxColumnWidth"
type="number"
value={config.horizontalScroll?.maxColumnWidth || 300}
onChange={(e) =>
handleNestedChange("horizontalScroll", "maxColumnWidth", parseInt(e.target.value) || 300)
}
min={100}
max={800}
placeholder="300"
className="h-8"
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</ScrollArea>
</TabsContent>
{/* 컬럼 설정 탭 */}
<TabsContent value="columns" className="space-y-4">
<ScrollArea className="h-[600px] pr-4">
{!screenTableName ? (
<Card>
<CardContent className="pt-6">
@ -544,7 +612,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
<ScrollArea className="h-96">
<div className="space-y-3">
{config.columns?.map((column, index) => (
<div key={column.columnName} className="space-y-3 rounded-lg border p-3">
@ -661,6 +729,47 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={column.fixed === false ? "none" : column.fixed || "none"}
onValueChange={(value: string) => {
const fixedValue = value === "none" ? false : (value as "left" | "right");
updateColumn(column.columnName, {
fixed: fixedValue,
fixedOrder: fixedValue ? column.fixedOrder || 0 : undefined,
});
}}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="left"> </SelectItem>
<SelectItem value="right"> </SelectItem>
</SelectContent>
</Select>
</div>
{(column.fixed === "left" || column.fixed === "right") && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="number"
value={column.fixedOrder || 0}
onChange={(e) =>
updateColumn(column.columnName, {
fixedOrder: parseInt(e.target.value) || 0,
})
}
placeholder="0"
className="h-8"
min="0"
/>
</div>
)}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1">
<Checkbox
@ -690,10 +799,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</CardContent>
</Card>
)}
</ScrollArea>
</TabsContent>
{/* Entity 조인 컬럼 추가 탭 */}
<TabsContent value="join-columns" className="space-y-4">
<ScrollArea className="h-[600px] pr-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Entity </CardTitle>
@ -738,7 +849,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
);
return (
<div key={colIndex} className="flex items-center justify-between rounded border p-2">
<div
key={colIndex}
className="flex items-center justify-between rounded border p-2"
>
<div className="flex-1">
<div className="text-sm font-medium">{column.columnLabel}</div>
<div className="text-muted-foreground text-xs">
@ -788,7 +902,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
<div className="flex flex-wrap gap-1">
{entityJoinColumns.availableColumns.map((column, index) => {
const isAlreadyAdded = config.columns?.some((col) => col.columnName === column.joinAlias);
const isAlreadyAdded = config.columns?.some(
(col) => col.columnName === column.joinAlias,
);
return (
<Badge
@ -811,10 +927,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</ScrollArea>
</CardContent>
</Card>
</ScrollArea>
</TabsContent>
{/* 필터 설정 탭 */}
<TabsContent value="filter" className="space-y-4">
<ScrollArea className="h-[600px] pr-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
@ -863,10 +981,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
)}
</CardContent>
</Card>
</ScrollArea>
</TabsContent>
{/* 액션 설정 탭 */}
<TabsContent value="actions" className="space-y-4">
<ScrollArea className="h-[600px] pr-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
@ -891,10 +1011,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
</CardContent>
</Card>
</ScrollArea>
</TabsContent>
{/* 스타일 설정 탭 */}
<TabsContent value="style" className="space-y-4">
<ScrollArea className="h-[600px] pr-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
@ -967,6 +1089,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
</CardContent>
</Card>
</ScrollArea>
</TabsContent>
</Tabs>
</div>

View File

@ -31,6 +31,14 @@ export const TableListDefinition = createComponentDefinition({
autoWidth: true,
stickyHeader: false,
// 가로 스크롤 및 컬럼 고정 설정
horizontalScroll: {
enabled: true,
maxVisibleColumns: 8, // 8개 컬럼까지는 스크롤 없이 표시
minColumnWidth: 100,
maxColumnWidth: 300,
},
// 페이지네이션
pagination: {
enabled: true,

View File

@ -27,6 +27,10 @@ export interface ColumnConfig {
dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용)
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
// 컬럼 고정 관련 속성
fixed?: "left" | "right" | false; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함)
fixedOrder?: number; // 고정된 컬럼들 내에서의 순서
}
/**
@ -105,6 +109,14 @@ export interface TableListConfig extends ComponentConfig {
autoWidth: boolean;
stickyHeader: boolean;
// 가로 스크롤 및 컬럼 고정 설정
horizontalScroll: {
enabled: boolean; // 가로 스크롤 활성화 여부
maxVisibleColumns?: number; // 스크롤 없이 표시할 최대 컬럼 수 (이 수를 넘으면 가로 스크롤)
minColumnWidth?: number; // 컬럼 최소 너비 (px)
maxColumnWidth?: number; // 컬럼 최대 너비 (px)
};
// 페이지네이션
pagination: PaginationConfig;