feat(WEB): 테이블 컬럼 표시/숨김 설정 기능 추가

- useColumnSettings 훅: 컬럼 가시성 토글 로직
- useTableColumnStore: Zustand 기반 컬럼 설정 영속화 (localStorage)
- ColumnSettingsPopover: 컬럼 설정 UI 컴포넌트
- UniversalListPage/IntegratedListTemplateV2에 컬럼 설정 통합

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-20 18:09:17 +09:00
parent 30ca2afca8
commit ceeeeb1ef4
6 changed files with 353 additions and 6 deletions

View File

@@ -0,0 +1,73 @@
'use client';
import { Settings2, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { ColumnWithVisibility } from '@/hooks/useColumnSettings';
interface ColumnSettingsPopoverProps {
columns: ColumnWithVisibility[];
onToggle: (key: string) => void;
onReset: () => void;
hasHiddenColumns: boolean;
}
export function ColumnSettingsPopover({
columns,
onToggle,
onReset,
hasHiddenColumns,
}: ColumnSettingsPopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="relative h-8 px-2">
<Settings2 className="h-4 w-4" />
<span className="hidden sm:inline ml-1"></span>
{hasHiddenColumns && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-blue-500" />
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onReset}
>
<RotateCcw className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-1 max-h-[300px] overflow-y-auto">
{columns.map((col) => (
<label
key={col.key}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm cursor-pointer hover:bg-muted/50 ${
col.locked ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Checkbox
checked={col.visible}
onCheckedChange={() => onToggle(col.key)}
disabled={col.locked}
/>
<span>{col.label}</span>
{col.locked && (
<span className="text-xs text-muted-foreground ml-auto"></span>
)}
</label>
))}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -240,6 +240,13 @@ export interface IntegratedListTemplateV2Props<T = any> {
// 로딩 상태
isLoading?: boolean;
// ===== 컬럼 리사이즈 & 가시성 설정 (opt-in) =====
columnSettings?: {
columnWidths: Record<string, number>;
onColumnResize: (columnKey: string, width: number) => void;
settingsPopover: ReactNode;
};
}
export function IntegratedListTemplateV2<T = any>({
@@ -299,6 +306,7 @@ export function IntegratedListTemplateV2<T = any>({
pagination,
devMetadata,
isLoading,
columnSettings,
}: IntegratedListTemplateV2Props<T>) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -785,6 +793,7 @@ export function IntegratedListTemplateV2<T = any>({
))}
{tableHeaderActions}
{renderAutoFilters()}
{columnSettings?.settingsPopover}
</div>
</div>
</div>
@@ -929,6 +938,17 @@ export function IntegratedListTemplateV2<T = any>({
/>
) : (
<Table className="table-fixed">
{columnSettings && (
<colgroup>
{showCheckbox && <col style={{ width: 50 }} />}
{tableColumns.map((col) => (
<col
key={col.key}
style={columnSettings.columnWidths[col.key] ? { width: columnSettings.columnWidths[col.key] } : undefined}
/>
))}
</colgroup>
)}
<TableHeader>
<TableRow>
{renderCustomTableHeader ? (
@@ -953,7 +973,7 @@ export function IntegratedListTemplateV2<T = any>({
return (
<TableHead
key={column.key}
className={`${column.className || ''} ${isSortable ? 'cursor-pointer select-none hover:bg-muted/50' : ''}`}
className={`${column.className || ''} ${isSortable ? 'cursor-pointer select-none hover:bg-muted/50' : ''} ${columnSettings ? 'relative' : ''}`}
onClick={isSortable ? () => onSort(column.key) : undefined}
>
{column.key === "actions" && selectedItems.size === 0 ? "" : (
@@ -974,6 +994,33 @@ export function IntegratedListTemplateV2<T = any>({
)}
</div>
)}
{columnSettings && (
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-400 active:bg-blue-500 z-10"
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
const th = (e.target as HTMLElement).parentElement;
if (!th) return;
const startX = e.clientX;
const startWidth = th.offsetWidth;
const onMouseMove = (ev: MouseEvent) => {
const newWidth = Math.max(40, startWidth + ev.clientX - startX);
columnSettings.onColumnResize(column.key, newWidth);
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}}
/>
)}
</TableHead>
);
})}

View File

@@ -11,9 +11,11 @@
* - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true)
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { toast } from 'sonner';
import { Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -818,6 +820,46 @@ export function UniversalListPage<T>({
}));
}, [config.columns, config.columnsPerTab, activeTab]);
// ===== 컬럼 리사이즈 & 가시성 설정 (자동 활성화) =====
const enableColumnSettings = !config.disableColumnSettings;
const alwaysVisibleKeys = useMemo(
() => effectiveColumns.filter(col => NON_SORTABLE_KEYS.includes(col.key)).map(col => col.key),
[effectiveColumns]
);
const {
visibleColumns: colSettingsVisible,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: config.basePath,
columns: effectiveColumns,
alwaysVisibleKeys,
});
const hiddenColumnKeys = useMemo(
() => allColumnsWithVisibility.filter(c => !c.visible).map(c => c.key),
[allColumnsWithVisibility]
);
const templateColumns = enableColumnSettings ? colSettingsVisible : effectiveColumns;
const templateColumnSettings = useMemo(() => {
if (!enableColumnSettings) return undefined;
return {
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
};
}, [enableColumnSettings, columnWidths, setColumnWidth, allColumnsWithVisibility, toggleColumnVisibility, resetSettings, hasHiddenColumns]);
// ===== ID로 아이템 찾기 헬퍼 =====
const getItemById = useCallback(
(id: string): T | undefined => {
@@ -852,19 +894,33 @@ export function UniversalListPage<T>({
);
// ===== 렌더링 함수 래퍼 =====
const showCheckbox = config.showCheckbox !== false;
const renderTableRow = useCallback(
(item: T, index: number, globalIndex: number) => {
const id = effectiveGetItemId(item);
const isSelected = effectiveSelectedItems.has(id);
return config.renderTableRow(item, index, globalIndex, {
const row = config.renderTableRow(item, index, globalIndex, {
isSelected,
onToggle: () => toggleSelection(id),
onRowClick: () => handleRowClick(item),
onEdit: () => handleEdit(item),
onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined,
});
// 컬럼 설정 활성화 시 숨긴 컬럼의 셀을 React.Children으로 제거
if (!enableColumnSettings || hiddenColumnKeys.length === 0) return row;
if (!React.isValidElement(row)) return row;
const children = React.Children.toArray((row as React.ReactElement<{ children?: React.ReactNode }>).props.children);
const offset = showCheckbox ? 1 : 0;
const filtered = children.filter((_, cellIndex) => {
if (showCheckbox && cellIndex === 0) return true; // 체크박스 유지
const col = effectiveColumns[cellIndex - offset];
return !col || !hiddenColumnKeys.includes(col.key);
});
return React.cloneElement(row as React.ReactElement, {}, ...filtered);
},
[config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection]
[config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection, enableColumnSettings, hiddenColumnKeys, showCheckbox, effectiveColumns]
);
const renderMobileCard = useCallback(
@@ -955,8 +1011,10 @@ export function UniversalListPage<T>({
})
: config.tableHeaderActions
}
// 테이블 컬럼 (탭별 다른 컬럼 지원)
tableColumns={effectiveColumns}
// 테이블 컬럼 (가시성 필터링 적용)
tableColumns={templateColumns}
// 컬럼 리사이즈 & 가시성 설정
columnSettings={templateColumnSettings}
// 정렬 설정 (모든 페이지에서 활성화)
sortBy={sortBy}
sortOrder={sortOrder}

View File

@@ -413,6 +413,10 @@ export interface UniversalListConfig<T> {
/** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */
onSearchChange?: (search: string) => void;
// ===== 컬럼 리사이즈 & 가시성 설정 =====
/** 컬럼 설정 비활성화 (기본: false = 자동 활성화) */
disableColumnSettings?: boolean;
// ===== 커스텀 다이얼로그 슬롯 =====
/**
* 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등)

View File

@@ -0,0 +1,64 @@
import { useMemo, useCallback } from 'react';
import { useTableColumnStore } from '@/stores/useTableColumnStore';
import type { TableColumn } from '@/components/templates/UniversalListPage/types';
export interface ColumnWithVisibility extends TableColumn {
visible: boolean;
locked: boolean;
}
interface UseColumnSettingsParams {
pageId: string;
columns: TableColumn[];
alwaysVisibleKeys?: string[];
}
export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: UseColumnSettingsParams) {
const store = useTableColumnStore();
const settings = store.getPageSettings(pageId);
const visibleColumns = useMemo(() => {
return columns.filter((col) => !settings.hiddenColumns.includes(col.key));
}, [columns, settings.hiddenColumns]);
const allColumnsWithVisibility = useMemo((): ColumnWithVisibility[] => {
return columns.map((col) => ({
...col,
visible: !settings.hiddenColumns.includes(col.key),
locked: alwaysVisibleKeys.includes(col.key),
}));
}, [columns, settings.hiddenColumns, alwaysVisibleKeys]);
const columnWidths = settings.columnWidths;
const setColumnWidth = useCallback(
(key: string, width: number) => {
store.setColumnWidth(pageId, key, width);
},
[store, pageId]
);
const toggleColumnVisibility = useCallback(
(key: string) => {
if (alwaysVisibleKeys.includes(key)) return;
store.toggleColumnVisibility(pageId, key);
},
[store, pageId, alwaysVisibleKeys]
);
const resetSettings = useCallback(() => {
store.resetPageSettings(pageId);
}, [store, pageId]);
const hasHiddenColumns = settings.hiddenColumns.length > 0;
return {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
};
}

View File

@@ -0,0 +1,101 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { safeJsonParse } from '@/lib/utils';
interface PageColumnSettings {
columnWidths: Record<string, number>;
hiddenColumns: string[];
}
interface TableColumnState {
pageSettings: Record<string, PageColumnSettings>;
setColumnWidth: (pageId: string, columnKey: string, width: number) => void;
toggleColumnVisibility: (pageId: string, columnKey: string) => void;
resetPageSettings: (pageId: string) => void;
getPageSettings: (pageId: string) => PageColumnSettings;
}
function getUserId(): string {
if (typeof window === 'undefined') return 'default';
const userStr = localStorage.getItem('user');
if (!userStr) return 'default';
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
return user?.id ? String(user.id) : 'default';
}
function getStorageKey(): string {
return `sam-table-columns-${getUserId()}`;
}
const DEFAULT_PAGE_SETTINGS: PageColumnSettings = {
columnWidths: {},
hiddenColumns: [],
};
export const useTableColumnStore = create<TableColumnState>()(
persist(
(set, get) => ({
pageSettings: {},
setColumnWidth: (pageId: string, columnKey: string, width: number) => {
const { pageSettings } = get();
const current = pageSettings[pageId] || { ...DEFAULT_PAGE_SETTINGS };
set({
pageSettings: {
...pageSettings,
[pageId]: {
...current,
columnWidths: { ...current.columnWidths, [columnKey]: width },
},
},
});
},
toggleColumnVisibility: (pageId: string, columnKey: string) => {
const { pageSettings } = get();
const current = pageSettings[pageId] || { ...DEFAULT_PAGE_SETTINGS };
const hidden = current.hiddenColumns.includes(columnKey)
? current.hiddenColumns.filter((k) => k !== columnKey)
: [...current.hiddenColumns, columnKey];
set({
pageSettings: {
...pageSettings,
[pageId]: { ...current, hiddenColumns: hidden },
},
});
},
resetPageSettings: (pageId: string) => {
const { pageSettings } = get();
const { [pageId]: _, ...rest } = pageSettings;
set({ pageSettings: rest });
},
getPageSettings: (pageId: string) => {
return get().pageSettings[pageId] || DEFAULT_PAGE_SETTINGS;
},
}),
{
name: 'sam-table-columns',
storage: {
getItem: (name) => {
const key = getStorageKey();
const str = localStorage.getItem(key);
if (!str) {
const fallback = localStorage.getItem(name);
return fallback ? JSON.parse(fallback) : null;
}
return JSON.parse(str);
},
setItem: (name, value) => {
const key = getStorageKey();
localStorage.setItem(key, JSON.stringify(value));
},
removeItem: (name) => {
const key = getStorageKey();
localStorage.removeItem(key);
},
},
}
)
);