{
+ 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);
+ },
+ },
+ }
+ )
+);