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:
73
src/components/molecules/ColumnSettingsPopover.tsx
Normal file
73
src/components/molecules/ColumnSettingsPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -413,6 +413,10 @@ export interface UniversalListConfig<T> {
|
||||
/** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */
|
||||
onSearchChange?: (search: string) => void;
|
||||
|
||||
// ===== 컬럼 리사이즈 & 가시성 설정 =====
|
||||
/** 컬럼 설정 비활성화 (기본: false = 자동 활성화) */
|
||||
disableColumnSettings?: boolean;
|
||||
|
||||
// ===== 커스텀 다이얼로그 슬롯 =====
|
||||
/**
|
||||
* 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등)
|
||||
|
||||
64
src/hooks/useColumnSettings.ts
Normal file
64
src/hooks/useColumnSettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
101
src/stores/useTableColumnStore.ts
Normal file
101
src/stores/useTableColumnStore.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user