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 등)