diff --git a/src/components/molecules/ColumnSettingsPopover.tsx b/src/components/molecules/ColumnSettingsPopover.tsx new file mode 100644 index 00000000..9284795b --- /dev/null +++ b/src/components/molecules/ColumnSettingsPopover.tsx @@ -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 ( + + + + + +
+ 컬럼 표시 설정 + +
+
+ {columns.map((col) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx index 58e4665c..2fdca5ca 100644 --- a/src/components/templates/IntegratedListTemplateV2.tsx +++ b/src/components/templates/IntegratedListTemplateV2.tsx @@ -240,6 +240,13 @@ export interface IntegratedListTemplateV2Props { // 로딩 상태 isLoading?: boolean; + + // ===== 컬럼 리사이즈 & 가시성 설정 (opt-in) ===== + columnSettings?: { + columnWidths: Record; + onColumnResize: (columnKey: string, width: number) => void; + settingsPopover: ReactNode; + }; } export function IntegratedListTemplateV2({ @@ -299,6 +306,7 @@ export function IntegratedListTemplateV2({ pagination, devMetadata, isLoading, + columnSettings, }: IntegratedListTemplateV2Props) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -785,6 +793,7 @@ export function IntegratedListTemplateV2({ ))} {tableHeaderActions} {renderAutoFilters()} + {columnSettings?.settingsPopover} @@ -929,6 +938,17 @@ export function IntegratedListTemplateV2({ /> ) : ( + {columnSettings && ( + + {showCheckbox && } + {tableColumns.map((col) => ( + + ))} + + )} {renderCustomTableHeader ? ( @@ -953,7 +973,7 @@ export function IntegratedListTemplateV2({ return ( onSort(column.key) : undefined} > {column.key === "actions" && selectedItems.size === 0 ? "" : ( @@ -974,6 +994,33 @@ export function IntegratedListTemplateV2({ )} )} + {columnSettings && ( +
{ + 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); + }} + /> + )} ); })} diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index d1d08265..b864684a 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -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({ })); }, [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: ( + + ), + }; + }, [enableColumnSettings, columnWidths, setColumnWidth, allColumnsWithVisibility, toggleColumnVisibility, resetSettings, hasHiddenColumns]); + // ===== ID로 아이템 찾기 헬퍼 ===== const getItemById = useCallback( (id: string): T | undefined => { @@ -852,19 +894,33 @@ export function UniversalListPage({ ); // ===== 렌더링 함수 래퍼 ===== + 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({ }) : config.tableHeaderActions } - // 테이블 컬럼 (탭별 다른 컬럼 지원) - tableColumns={effectiveColumns} + // 테이블 컬럼 (가시성 필터링 적용) + tableColumns={templateColumns} + // 컬럼 리사이즈 & 가시성 설정 + columnSettings={templateColumnSettings} // 정렬 설정 (모든 페이지에서 활성화) sortBy={sortBy} sortOrder={sortOrder} diff --git a/src/components/templates/UniversalListPage/types.ts b/src/components/templates/UniversalListPage/types.ts index d4b1c709..8e42315f 100644 --- a/src/components/templates/UniversalListPage/types.ts +++ b/src/components/templates/UniversalListPage/types.ts @@ -413,6 +413,10 @@ export interface UniversalListConfig { /** 검색어 변경 콜백 (config 내부에서 설정, 서버 사이드 검색용) */ onSearchChange?: (search: string) => void; + // ===== 컬럼 리사이즈 & 가시성 설정 ===== + /** 컬럼 설정 비활성화 (기본: false = 자동 활성화) */ + disableColumnSettings?: boolean; + // ===== 커스텀 다이얼로그 슬롯 ===== /** * 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등) diff --git a/src/hooks/useColumnSettings.ts b/src/hooks/useColumnSettings.ts new file mode 100644 index 00000000..d7aa9ab3 --- /dev/null +++ b/src/hooks/useColumnSettings.ts @@ -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, + }; +} diff --git a/src/stores/useTableColumnStore.ts b/src/stores/useTableColumnStore.ts new file mode 100644 index 00000000..e7dc836a --- /dev/null +++ b/src/stores/useTableColumnStore.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { safeJsonParse } from '@/lib/utils'; + +interface PageColumnSettings { + columnWidths: Record; + hiddenColumns: string[]; +} + +interface TableColumnState { + pageSettings: Record; + 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 | 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()( + 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); + }, + }, + } + ) +);