fix: 테이블 컬럼 설정 개선

- 체크박스 컬럼 위치 보존 (드래그 순서 변경 시 맨 오른쪽으로 이동하는 문제 해결)
- 사용자별 컬럼 설정 localStorage 저장 및 불러오기 기능 추가
- useAuth 훅으로 userId 가져오기
- 초기 로드 시 저장된 설정 자동 복원
This commit is contained in:
kjs 2025-11-12 11:15:44 +09:00
parent 73049c4162
commit 33ba13b070
4 changed files with 267 additions and 126 deletions

View File

@ -17,20 +17,19 @@ import { GripVertical, Eye, EyeOff } from "lucide-react";
import { ColumnVisibility } from "@/types/table-options";
interface Props {
tableId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
isOpen: boolean;
onClose: () => void;
}
export const ColumnVisibilityPanel: React.FC<Props> = ({
tableId,
open,
onOpenChange,
isOpen,
onClose,
}) => {
const { getTable } = useTableOptions();
const table = getTable(tableId);
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
// 테이블 정보 로드
useEffect(() => {
@ -62,9 +61,31 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
);
};
const moveColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...localColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setLocalColumns(newColumns);
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
moveColumn(draggedIndex, index);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const handleApply = () => {
table?.onColumnVisibilityChange(localColumns);
onOpenChange(false);
onClose();
};
const handleReset = () => {
@ -83,7 +104,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
const visibleCount = localColumns.filter((col) => col.visible).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
@ -114,14 +135,18 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
{/* 컬럼 리스트 */}
<ScrollArea className="h-[300px] sm:h-[400px]">
<div className="space-y-2 pr-4">
{localColumns.map((col) => {
{localColumns.map((col, index) => {
const columnMeta = table?.columns.find(
(c) => c.columnName === col.columnName
);
return (
<div
key={col.columnName}
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50 cursor-move"
>
{/* 드래그 핸들 */}
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />

View File

@ -22,18 +22,16 @@ import { Plus, X } from "lucide-react";
import { TableFilter } from "@/types/table-options";
interface Props {
tableId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
isOpen: boolean;
onClose: () => void;
}
export const FilterPanel: React.FC<Props> = ({
tableId,
open,
onOpenChange,
isOpen,
onClose,
}) => {
const { getTable } = useTableOptions();
const table = getTable(tableId);
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
@ -87,7 +85,7 @@ export const FilterPanel: React.FC<Props> = ({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">

View File

@ -11,23 +11,22 @@ import {
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ArrowRight } from "lucide-react";
import { ArrowRight, GripVertical, X } from "lucide-react";
interface Props {
tableId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
isOpen: boolean;
onClose: () => void;
}
export const GroupingPanel: React.FC<Props> = ({
tableId,
open,
onOpenChange,
isOpen,
onClose,
}) => {
const { getTable } = useTableOptions();
const table = getTable(tableId);
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const toggleColumn = (columnName: string) => {
if (selectedColumns.includes(columnName)) {
@ -37,9 +36,35 @@ export const GroupingPanel: React.FC<Props> = ({
}
};
const removeColumn = (columnName: string) => {
setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
};
const moveColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...selectedColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setSelectedColumns(newColumns);
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
moveColumn(draggedIndex, index);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const applyGrouping = () => {
table?.onGroupChange(selectedColumns);
onOpenChange(false);
onClose();
};
const clearGrouping = () => {
@ -48,7 +73,7 @@ export const GroupingPanel: React.FC<Props> = ({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
@ -58,89 +83,126 @@ export const GroupingPanel: React.FC<Props> = ({
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 상태 표시 */}
<div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
<div className="text-xs text-muted-foreground sm:text-sm">
{selectedColumns.length}
</div>
<Button
variant="ghost"
size="sm"
onClick={clearGrouping}
className="h-7 text-xs"
>
</Button>
</div>
{/* 컬럼 리스트 */}
<ScrollArea className="h-[250px] sm:h-[300px]">
<div className="space-y-2 pr-4">
{table?.columns.map((col) => {
const isSelected = selectedColumns.includes(col.columnName);
const order = selectedColumns.indexOf(col.columnName) + 1;
return (
<div
key={col.columnName}
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleColumn(col.columnName)}
/>
<div className="flex-1">
<div className="text-xs font-medium sm:text-sm">
{col.columnLabel}
</div>
<div className="text-[10px] text-muted-foreground sm:text-xs">
{col.columnName}
</div>
</div>
{isSelected && (
<div className="flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{order}
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* 그룹 순서 미리보기 */}
{/* 선택된 컬럼 (드래그 가능) */}
{selectedColumns.length > 0 && (
<div className="rounded-lg border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium sm:text-sm">
<div>
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium sm:text-sm">
({selectedColumns.length})
</div>
<Button
variant="ghost"
size="sm"
onClick={clearGrouping}
className="h-7 text-xs"
>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<div className="space-y-2">
{selectedColumns.map((colName, index) => {
const col = table?.columns.find(
(c) => c.columnName === colName
);
if (!col) return null;
return (
<React.Fragment key={colName}>
<div className="rounded bg-primary/10 px-2 py-1 font-medium">
{col?.columnLabel}
<div
key={colName}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className="flex items-center gap-2 rounded-lg border bg-primary/5 p-2 sm:p-3 transition-colors hover:bg-primary/10 cursor-move"
>
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground flex-shrink-0">
{index + 1}
</div>
{index < selectedColumns.length - 1 && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
</React.Fragment>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium sm:text-sm truncate">
{col.columnLabel}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeColumn(colName)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
{/* 그룹화 순서 미리보기 */}
<div className="mt-2 rounded-lg border bg-muted/30 p-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
{selectedColumns.map((colName, index) => {
const col = table?.columns.find(
(c) => c.columnName === colName
);
return (
<React.Fragment key={colName}>
<span className="font-medium">{col?.columnLabel}</span>
{index < selectedColumns.length - 1 && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
)}
{/* 사용 가능한 컬럼 */}
<div>
<div className="mb-2 text-xs font-medium sm:text-sm">
</div>
<ScrollArea className={selectedColumns.length > 0 ? "h-[280px] sm:h-[320px]" : "h-[400px] sm:h-[450px]"}>
<div className="space-y-2 pr-4">
{table?.columns
.filter((col) => !selectedColumns.includes(col.columnName))
.map((col) => {
return (
<div
key={col.columnName}
className="flex items-center gap-3 rounded-lg border bg-background p-2 sm:p-3 transition-colors hover:bg-muted/50 cursor-pointer"
onClick={() => toggleColumn(col.columnName)}
>
<Checkbox
checked={false}
onCheckedChange={() => toggleColumn(col.columnName)}
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium sm:text-sm truncate">
{col.columnLabel}
</div>
<div className="text-[10px] text-muted-foreground sm:text-xs truncate">
{col.columnName}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>

View File

@ -47,6 +47,7 @@ import { CardModeRenderer } from "./CardModeRenderer";
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
// ========================================
// 인터페이스
@ -245,12 +246,48 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 상태 관리
// ========================================
// 사용자 정보
const { userId } = useAuth();
// TableOptions Context
const { registerTable, unregisterTable } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
useEffect(() => {
if (tableConfig.selectedTable && userId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${userId}`;
const savedSettings = localStorage.getItem(storageKey);
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
setColumnVisibility(parsed);
} catch (error) {
console.error("저장된 컬럼 설정 불러오기 실패:", error);
}
}
}
}, [tableConfig.selectedTable, userId]);
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
useEffect(() => {
if (columnVisibility.length > 0) {
const newOrder = columnVisibility
.map((cv) => cv.columnName)
.filter((name) => name !== "__checkbox__"); // 체크박스 제외
setColumnOrder(newOrder);
// localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && userId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${userId}`;
localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
}
}
}, [columnVisibility, tableConfig.selectedTable, userId]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -300,37 +337,47 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const tableId = `table-list-${component.id}`;
useEffect(() => {
if (!tableConfig.selectedTable || !displayColumns || displayColumns.length === 0) {
// tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음)
const columnsToRegister = (tableConfig.columns || [])
.filter((col) => col.visible !== false && col.columnName !== "__checkbox__");
if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) {
return;
}
registerTable({
const registration = {
tableId,
label: tableLabel || tableConfig.selectedTable,
tableName: tableConfig.selectedTable,
columns: displayColumns.map((col) => ({
columnName: col.field,
columnLabel: columnLabels[col.field] || col.label || col.field,
inputType: columnMeta[col.field]?.inputType || "text",
columns: columnsToRegister.map((col) => ({
columnName: col.columnName || col.field,
columnLabel: columnLabels[col.columnName] || col.displayName || col.label || col.columnName || col.field,
inputType: columnMeta[col.columnName]?.inputType || "text",
visible: col.visible !== false,
width: columnWidths[col.field] || col.width || 150,
width: columnWidths[col.columnName] || col.width || 150,
sortable: col.sortable !== false,
filterable: col.filterable !== false,
filterable: col.searchable !== false,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
});
};
return () => unregisterTable(tableId);
registerTable(registration);
return () => {
unregisterTable(tableId);
};
}, [
component.id,
tableId,
tableConfig.selectedTable,
displayColumns,
tableConfig.columns,
columnLabels,
columnMeta,
columnWidths,
tableLabel,
registerTable,
unregisterTable,
]);
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
@ -975,8 +1022,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const visibleColumns = useMemo(() => {
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
// columnVisibility가 있으면 가시성 적용
if (columnVisibility.length > 0) {
cols = cols.filter((col) => {
const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName);
return visibilityConfig ? visibilityConfig.visible : true;
});
}
// 체크박스 컬럼 (나중에 위치 결정)
let checkboxCol: ColumnConfig | null = null;
if (tableConfig.checkbox?.enabled) {
const checkboxCol: ColumnConfig = {
checkboxCol = {
columnName: "__checkbox__",
displayName: "",
visible: true,
@ -986,15 +1043,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
align: "center",
order: -1,
};
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
// columnOrder 상태가 있으면 그 순서대로 정렬
// columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외)
if (columnOrder.length > 0) {
const orderedCols = columnOrder
.map((colName) => cols.find((c) => c.columnName === colName))
@ -1003,17 +1054,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// columnOrder에 없는 새로운 컬럼들 추가
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
console.log("🔄 columnOrder 기반 정렬:", {
columnOrder,
orderedColsCount: orderedCols.length,
remainingColsCount: remainingCols.length,
});
return [...orderedCols, ...remainingCols];
cols = [...orderedCols, ...remainingCols];
} else {
cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
// 체크박스를 맨 앞 또는 맨 뒤에 추가
if (checkboxCol) {
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
return cols;
}, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]);
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
const lastColumnOrderRef = useRef<string>("");