Files
sam-react-prod/src/components/templates/UniversalListPage/index.tsx
유병철 9d66d554ec feat: 회계/급여 관리 개선 및 공통 템플릿 보강
- 회계: 매출/청구/입출금 관리 UI 개선
- 급여: SalaryDetailDialog 대폭 개선, SalaryRegistrationDialog 신규
- 공통: IntegratedDetailTemplate, UniversalListPage 보강
- UI: currency-input 컴포넌트 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:26:15 +09:00

1118 lines
42 KiB
TypeScript

'use client';
/**
* UniversalListPage - 통합 리스트 페이지 컴포넌트
*
* 59개 리스트 페이지를 하나의 config 기반 컴포넌트로 통합
* 기존 기능 100% 유지, 테이블 영역만 공통화
*
* 지원 모드:
* - 서버 사이드 필터링/페이지네이션 (기본)
* - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true)
*/
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';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
IntegratedListTemplateV2,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
import { downloadExcel, downloadSelectedExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import type {
UniversalListPageProps,
TabOption,
FilterValues,
} from './types';
import { NON_SORTABLE_KEYS } from './types';
export function UniversalListPage<T>({
config,
initialData,
initialTotalCount,
externalPagination,
externalSelection,
onTabChange,
onSearchChange,
onFilterChange: onFilterChangeCallback,
externalIsLoading,
}: UniversalListPageProps<T>) {
const router = useRouter();
const params = useParams();
const locale = (params.locale as string) || 'ko';
const { canCreate: permCanCreate, canDelete: permCanDelete, canExport } = usePermission();
// ===== 상태 관리 =====
// 원본 데이터 (클라이언트 사이드 필터링용)
const [rawData, setRawData] = useState<T[]>(initialData || []);
// UI 상태
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(!initialData);
const [searchValue, setSearchValue] = useState(''); // UI 입력용 (즉시 반영)
const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // API 호출용 (debounced)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함
const [activeTab, setActiveTab] = useState(
config.defaultTab || config.tabs?.[0]?.value || 'default'
);
const [filters, setFilters] = useState<Record<string, string | string[]>>(
config.initialFilters || {}
);
const [tabs, setTabs] = useState<TabOption[]>(config.tabs || []);
// 정렬 상태
const [sortBy, setSortBy] = useState<string | undefined>(undefined);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// 모달 상태 (detailMode === 'modal'일 때 사용)
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<T | null>(null);
// 삭제 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<T | null>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
// 엑셀 다운로드 상태
const [isExcelDownloading, setIsExcelDownloading] = useState(false);
const itemsPerPage = config.itemsPerPage || 20;
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
const [isMobileLoading, setIsMobileLoading] = useState(false);
// 초기 데이터 로딩 완료 여부 (검색/필터 변경 시 전체 스켈레톤 방지)
const isInitialFetchDone = useRef(false);
// 서버 사이드 페이지네이션 상태 (API에서 반환하는 값)
const [serverTotalCount, setServerTotalCount] = useState<number>(initialTotalCount || 0);
const [serverTotalPages, setServerTotalPages] = useState<number>(1);
// ===== 검색 Debounce (300ms) =====
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchValue(searchValue);
// 검색 변경 시 페이지를 1로 리셋 (서버 사이드 페이지네이션에서 올바른 결과 보장)
setCurrentPage(1);
}, 300);
return () => clearTimeout(timer);
}, [searchValue]);
// ===== ID 추출 헬퍼 =====
const getItemId = useCallback(
(item: T): string => {
if (typeof config.idField === 'function') {
return config.idField(item);
}
return String(item[config.idField]);
},
[config.idField]
);
// ===== 데이터 필터링 =====
// 서버 사이드 모드에서 searchFilter를 통한 클라이언트 사이드 검색 활성화 여부
const isServerSearchFiltered = !config.clientSideFiltering && !!debouncedSearchValue && !!config.searchFilter;
const filteredData = useMemo(() => {
if (!config.clientSideFiltering) {
// 서버 사이드 모드
let serverData = rawData;
// searchFilter가 정의되어 있으면 클라이언트 사이드 검색 적용 (백엔드 검색 미지원 대비)
if (debouncedSearchValue && config.searchFilter) {
serverData = rawData.filter((item) =>
config.searchFilter!(item, debouncedSearchValue)
);
}
// 서버 사이드에서도 컬럼 정렬 지원 (onSortChange 미정의 시 클라이언트 사이드 정렬)
if (sortBy && !config.onSortChange) {
serverData = [...serverData].sort((a, b) => {
const aValue = (a as Record<string, unknown>)[sortBy];
const bValue = (b as Record<string, unknown>)[sortBy];
if (aValue == null && bValue == null) return 0;
if (aValue == null) return sortOrder === 'asc' ? 1 : -1;
if (bValue == null) return sortOrder === 'asc' ? -1 : 1;
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
const aStr = String(aValue);
const bStr = String(bValue);
const comparison = aStr.localeCompare(bStr, 'ko');
return sortOrder === 'asc' ? comparison : -comparison;
});
}
return serverData;
}
let filtered = [...rawData];
// 커스텀 필터 함수 (filterConfig 기반 복잡한 필터링)
if (config.customFilterFn) {
filtered = config.customFilterFn(filtered, filters);
}
// 탭 필터
if (activeTab !== 'all' && config.tabFilter) {
filtered = filtered.filter((item) => config.tabFilter!(item, activeTab));
}
// 검색 필터 (debounced 값 사용)
if (debouncedSearchValue && config.searchFilter) {
filtered = filtered.filter((item) =>
config.searchFilter!(item, debouncedSearchValue)
);
}
// 날짜 범위 필터 (dateRangeSelector.dateField 설정 시 자동 적용)
const { dateRangeSelector } = config;
if (dateRangeSelector?.enabled && dateRangeSelector.dateField && dateRangeSelector.startDate && dateRangeSelector.endDate) {
const dateField = dateRangeSelector.dateField;
const start = new Date(dateRangeSelector.startDate);
const end = new Date(dateRangeSelector.endDate);
end.setHours(23, 59, 59, 999); // 종료일 끝까지 포함
filtered = filtered.filter((item) => {
const itemDate = (item as Record<string, unknown>)[dateField];
if (!itemDate) return false;
const date = new Date(String(itemDate));
return date >= start && date <= end;
});
}
// 커스텀 정렬 함수
if (config.customSortFn) {
filtered = config.customSortFn(filtered, filters);
}
// 컬럼 기반 정렬 (sortBy가 있을 때)
if (sortBy) {
filtered = [...filtered].sort((a, b) => {
const aValue = (a as Record<string, unknown>)[sortBy];
const bValue = (b as Record<string, unknown>)[sortBy];
// null/undefined 처리
if (aValue == null && bValue == null) return 0;
if (aValue == null) return sortOrder === 'asc' ? 1 : -1;
if (bValue == null) return sortOrder === 'asc' ? -1 : 1;
// 숫자 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열 비교 (한글 지원)
const aStr = String(aValue);
const bStr = String(bValue);
const comparison = aStr.localeCompare(bStr, 'ko');
return sortOrder === 'asc' ? comparison : -comparison;
});
}
return filtered;
}, [rawData, activeTab, debouncedSearchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.onSortChange, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn, config.dateRangeSelector]);
// 페이지네이션 (클라이언트 사이드 + 서버 사이드 검색 시)
const paginatedData = useMemo(() => {
if (!config.clientSideFiltering) {
// 서버 사이드 검색 시 클라이언트 사이드 페이지네이션 적용
if (debouncedSearchValue && config.searchFilter) {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredData.slice(startIndex, startIndex + itemsPerPage);
}
return rawData;
}
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredData.slice(startIndex, startIndex + itemsPerPage);
}, [config.clientSideFiltering, config.searchFilter, debouncedSearchValue, filteredData, currentPage, itemsPerPage, rawData]);
// 총 개수 및 페이지 수
// 서버 사이드 페이지네이션: API에서 반환한 값 사용
// 클라이언트 사이드 페이지네이션: 로컬 데이터 길이 사용
const totalCount = config.clientSideFiltering
? filteredData.length
: (isServerSearchFiltered ? filteredData.length : serverTotalCount);
const totalPages = config.clientSideFiltering
? Math.ceil(totalCount / itemsPerPage)
: (isServerSearchFiltered ? (Math.ceil(filteredData.length / itemsPerPage) || 1) : serverTotalPages);
// 삭제 등으로 현재 페이지가 빈 페이지가 되면 마지막 유효 페이지로 이동
useEffect(() => {
if (currentPage > 1 && (totalPages === 0 || currentPage > totalPages)) {
setCurrentPage(Math.max(1, totalPages));
}
}, [totalPages, currentPage]);
// 표시할 데이터
// 서버 사이드 모드에서도 filteredData 사용 (클라이언트 사이드 정렬 반영)
const displayData = (config.clientSideFiltering || isServerSearchFiltered) ? paginatedData : filteredData;
// ===== 탭 카운트 계산 (클라이언트 사이드) =====
const computedTabs = useMemo(() => {
if (!config.clientSideFiltering || !config.tabs || !config.tabFilter) {
return tabs;
}
return config.tabs.map((tab) => {
if (tab.value === 'all') {
return { ...tab, count: rawData.length };
}
const count = rawData.filter((item) => config.tabFilter!(item, tab.value)).length;
return { ...tab, count };
});
}, [config.clientSideFiltering, config.tabs, config.tabFilter, rawData, tabs]);
// ===== 데이터 로딩 =====
// isMobileAppend: 모바일 인피니티 스크롤로 추가 로드 시 true
const fetchData = useCallback(async (isMobileAppend = false) => {
// 모바일 추가 로드면 isMobileLoading, 그 외에는 isLoading
if (isMobileAppend) {
setIsMobileLoading(true);
} else if (!isInitialFetchDone.current) {
// 초기 로딩 시에만 전체 스켈레톤 표시
setIsLoading(true);
}
try {
// 서버 사이드 + searchFilter 정의 + 검색 중: 전체 데이터를 받아서 클라이언트 사이드 필터링
const useClientSearch = !config.clientSideFiltering && !!config.searchFilter && !!debouncedSearchValue;
const result = await config.actions.getList(
config.clientSideFiltering
? { pageSize: 9999 } // 클라이언트 사이드: 전체 데이터 로드
: {
page: useClientSearch ? 1 : currentPage,
pageSize: useClientSearch ? 9999 : itemsPerPage,
search: useClientSearch ? undefined : debouncedSearchValue,
filters,
tab: activeTab,
}
);
if (result.success && result.data) {
setRawData(result.data);
// 서버 사이드 페이지네이션: API에서 반환한 totalCount, totalPages 저장
if (!config.clientSideFiltering) {
if (typeof result.totalCount === 'number') {
setServerTotalCount(result.totalCount);
}
if (typeof result.totalPages === 'number') {
setServerTotalPages(result.totalPages);
}
}
} else {
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
}
} catch (error) {
console.error('[UniversalListPage] Fetch error:', error);
toast.error('데이터를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
setIsMobileLoading(false);
isInitialFetchDone.current = true;
}
}, [config.actions, config.clientSideFiltering, config.searchFilter, currentPage, itemsPerPage, debouncedSearchValue, filters, activeTab]);
// 초기 로딩 (initialData가 없거나 빈 배열인 경우)
useEffect(() => {
if (!initialData || initialData.length === 0) {
fetchData();
}
}, []);
// initialData prop 변경 감지 (부모 컴포넌트에서 데이터 로드 후 전달하는 경우)
// 삭제 후 빈 배열도 동기화해야 빈 페이지가 올바르게 표시됨
useEffect(() => {
if (initialData) {
setRawData(initialData);
}
}, [initialData]);
// config.tabs 변경 감지 (동적 탭 카운트 업데이트용)
useEffect(() => {
if (config.tabs) {
setTabs(config.tabs);
}
}, [config.tabs]);
// 선택 항목 변경 시 외부 콜백 호출
useEffect(() => {
if (config.onSelectionChange && !externalSelection) {
config.onSelectionChange(selectedItems);
}
}, [selectedItems, config.onSelectionChange, externalSelection]);
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
// ⚠️ config.onDataChange를 deps에서 제외: 콜백 참조 변경으로 인한 무한 루프 방지
useEffect(() => {
config.onDataChange?.(rawData);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawData]);
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
// 이전 페이지를 추적하여 모바일 인피니티 스크롤 감지
const [prevPage, setPrevPage] = useState(1);
// 날짜 범위 변경 감지용 (서버 사이드 필터링에서 날짜 변경 시 데이터 새로고침)
const dateRangeKey = config.dateRangeSelector?.enabled
? `${config.dateRangeSelector.startDate || ''}-${config.dateRangeSelector.endDate || ''}`
: '';
useEffect(() => {
if (!config.clientSideFiltering && !externalPagination && !isLoading && !isMobileLoading) {
// 페이지가 증가하는 경우 = 모바일 인피니티 스크롤
const isMobileAppend = currentPage > prevPage && currentPage > 1;
fetchData(isMobileAppend);
setPrevPage(currentPage);
}
}, [currentPage, debouncedSearchValue, filters, activeTab, dateRangeKey]);
// 동적 탭 로딩
useEffect(() => {
if (config.fetchTabs) {
config.fetchTabs().then((fetchedTabs) => {
setTabs(fetchedTabs);
if (!activeTab || activeTab === 'all') {
setActiveTab(fetchedTabs[0]?.value || 'all');
}
});
}
}, [config.fetchTabs]);
// ===== 선택 핸들러 =====
// 외부 선택 상태 사용 시 외부 핸들러 사용
const effectiveSelectedItems = externalSelection?.selectedItems ?? selectedItems;
const effectiveGetItemId = externalSelection?.getItemId ?? getItemId;
const toggleSelection = useCallback(
(id: string) => {
if (externalSelection) {
externalSelection.onToggleSelection(id);
} else {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}
},
[externalSelection]
);
const toggleSelectAll = useCallback(() => {
if (externalSelection) {
externalSelection.onToggleSelectAll();
} else {
const currentData = displayData;
if (selectedItems.size === currentData.length && currentData.length > 0) {
setSelectedItems(new Set());
} else {
const allIds = new Set(currentData.map((item) => getItemId(item)));
setSelectedItems(allIds);
}
}
}, [externalSelection, displayData, selectedItems.size, getItemId]);
// ===== 행 클릭 핸들러 =====
const handleRowClick = useCallback(
(item: T) => {
const id = getItemId(item);
const detailMode = config.detailMode || 'page';
if (detailMode === 'modal') {
setSelectedItem(item);
setIsModalOpen(true);
} else if (detailMode === 'page') {
router.push(`/${locale}${config.basePath}/${id}?mode=view`);
}
},
[config.basePath, config.detailMode, getItemId, locale, router]
);
const handleEdit = useCallback(
(item: T) => {
const id = getItemId(item);
router.push(`/${locale}${config.basePath}/${id}?mode=edit`);
},
[config.basePath, getItemId, locale, router]
);
const handleCreate = useCallback(() => {
router.push(`/${locale}${config.basePath}?mode=new`);
}, [config.basePath, locale, router]);
// ===== 삭제 핸들러 =====
const handleDeleteClick = useCallback((item: T) => {
setItemToDelete(item);
setIsBulkDelete(false);
setDeleteDialogOpen(true);
}, []);
const handleBulkDeleteClick = useCallback(() => {
if (effectiveSelectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setIsBulkDelete(true);
setDeleteDialogOpen(true);
}, [effectiveSelectedItems.size]);
const handleDeleteConfirm = useCallback(async () => {
try {
if (isBulkDelete) {
if (config.actions.deleteBulk) {
const result = await config.actions.deleteBulk(Array.from(effectiveSelectedItems));
if (result.success) {
toast.success(`${effectiveSelectedItems.size}건이 삭제되었습니다.`);
// 클라이언트 사이드: 로컬 데이터에서 제거
if (config.clientSideFiltering) {
setRawData((prev) =>
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
);
} else {
fetchData();
}
if (!externalSelection) {
setSelectedItems(new Set());
}
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} else if (config.actions?.deleteItem) {
const ids = Array.from(effectiveSelectedItems);
let successCount = 0;
for (const id of ids) {
const result = await config.actions.deleteItem(id);
if (result.success) successCount++;
}
toast.success(`${successCount}건이 삭제되었습니다.`);
if (config.clientSideFiltering) {
setRawData((prev) =>
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
);
} else {
fetchData();
}
if (!externalSelection) {
setSelectedItems(new Set());
}
}
} else if (itemToDelete) {
if (config.actions?.deleteItem) {
const id = getItemId(itemToDelete);
const result = await config.actions.deleteItem(id);
if (result.success) {
toast.success('삭제되었습니다.');
if (config.clientSideFiltering) {
setRawData((prev) => prev.filter((item) => getItemId(item) !== id));
} else {
fetchData();
}
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
}
}
} catch (error) {
console.error('[UniversalListPage] Delete error:', error);
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setDeleteDialogOpen(false);
setItemToDelete(null);
}
}, [config.actions, config.clientSideFiltering, externalSelection, fetchData, getItemId, isBulkDelete, itemToDelete, effectiveSelectedItems]);
// ===== 검색 핸들러 =====
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value); // UI 즉시 반영
// 페이지 초기화와 외부 콜백은 debounce 후 실행됨 (아래 useEffect에서 처리)
}, []);
// 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용)
// config.onSearchChange: config 내부에서 설정한 검색 콜백도 호출
// ⚠️ config.onSearchChange는 deps에서 제외 (config 재생성 → 무한 루프 방지, config.onDataChange 패턴 참고)
useEffect(() => {
onSearchChange?.(debouncedSearchValue);
config.onSearchChange?.(debouncedSearchValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchValue, onSearchChange]);
// ===== 필터 핸들러 =====
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
setCurrentPage(1);
setSelectedItems(new Set());
// 외부 콜백 호출 (서버 사이드 필터링용)
onFilterChangeCallback?.(newFilters);
}, [filters, onFilterChangeCallback]);
const handleFilterReset = useCallback(() => {
setFilters(config.initialFilters || {});
setCurrentPage(1);
setSelectedItems(new Set());
}, [config.initialFilters]);
// ===== 엑셀 다운로드 핸들러 =====
const handleExcelDownload = useCallback(async () => {
if (!config.excelDownload) return;
const { columns, filename = 'export', sheetName = 'Sheet1', fetchAllUrl, fetchAllParams, mapResponse } = config.excelDownload;
setIsExcelDownloading(true);
try {
let dataToDownload: T[];
// 디버깅: 데이터 개수 확인
// fetchAllUrl이 있으면 서버에서 전체 데이터 페이지별 순차 조회 (clientSideFiltering 여부 무관)
if (fetchAllUrl) {
const PAGE_SIZE = 1000;
// 동적 파라미터 구성
const baseParams = new URLSearchParams();
if (fetchAllParams) {
const additionalParams = fetchAllParams({
activeTab,
filters,
searchValue: debouncedSearchValue,
});
Object.entries(additionalParams).forEach(([key, value]) => {
if (value) baseParams.append(key, value);
});
}
// 1) 첫 페이지 호출로 total 확인
const firstParams = new URLSearchParams(baseParams);
firstParams.set('size', String(PAGE_SIZE));
firstParams.set('per_page', String(PAGE_SIZE));
firstParams.set('page', '1');
const firstResponse = await fetch(`${fetchAllUrl}?${firstParams.toString()}`);
const firstResult = await firstResponse.json();
if (!firstResult.success) {
throw new Error(firstResult.message || '데이터 조회에 실패했습니다.');
}
const firstPageData = mapResponse
? mapResponse(firstResult)
: (firstResult.data?.data ?? firstResult.data ?? []);
const total = firstResult.data?.total ?? firstPageData.length;
const totalPages = Math.ceil(total / PAGE_SIZE);
// 2) 나머지 페이지 병렬 호출
const allData: T[] = [...(firstPageData as T[])];
if (totalPages > 1) {
const remainingPages = Array.from(
{ length: totalPages - 1 },
(_, i) => i + 2
);
const pageResults = await Promise.all(
remainingPages.map(async (page) => {
const pageParams = new URLSearchParams(baseParams);
pageParams.set('size', String(PAGE_SIZE));
pageParams.set('per_page', String(PAGE_SIZE));
pageParams.set('page', String(page));
const res = await fetch(`${fetchAllUrl}?${pageParams.toString()}`);
const result = await res.json();
if (!result.success) {
console.warn(`[Excel] 페이지 ${page} 조회 실패:`, result.message);
return [];
}
return mapResponse
? mapResponse(result)
: (result.data?.data ?? result.data ?? []);
})
);
for (const pageData of pageResults) {
allData.push(...(pageData as T[]));
}
}
dataToDownload = allData;
}
// fetchAllUrl 없으면 현재 로드된 데이터 사용
else {
dataToDownload = rawData;
}
if (dataToDownload.length === 0) {
toast.warning('다운로드할 데이터가 없습니다.');
return;
}
await downloadExcel({
data: dataToDownload as Record<string, unknown>[],
columns: columns as ExcelColumn<Record<string, unknown>>[],
filename,
sheetName,
});
toast.success(`${dataToDownload.length}건 다운로드 완료`);
} catch (error) {
console.error('[Excel] 다운로드 실패:', error);
toast.error(error instanceof Error ? error.message : '엑셀 다운로드에 실패했습니다.');
} finally {
setIsExcelDownloading(false);
}
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, debouncedSearchValue]);
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = useCallback(async () => {
if (!config.excelDownload) return;
const { columns, filename = 'export', sheetName = 'Sheet1' } = config.excelDownload;
const selectedIds = Array.from(effectiveSelectedItems);
if (selectedIds.length === 0) {
toast.warning('선택된 항목이 없습니다.');
return;
}
// 현재 데이터에서 선택된 항목 필터링
const selectedData = rawData.filter((item) => selectedIds.includes(getItemId(item)));
await downloadSelectedExcel({
data: selectedData as Record<string, unknown>[],
columns: columns as ExcelColumn<Record<string, unknown>>[],
selectedIds,
idField: 'id',
filename: `${filename}_선택`,
sheetName,
});
toast.success(`${selectedData.length}건 다운로드 완료`);
}, [config.excelDownload, effectiveSelectedItems, rawData, getItemId]);
// 엑셀 전체 다운로드 버튼 (헤더 영역)
const renderExcelDownloadButton = useMemo(() => {
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
return null;
}
return (
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
disabled={isExcelDownloading}
className="gap-2"
>
{isExcelDownloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isExcelDownloading ? '다운로드 중...' : '엑셀 다운로드'}
</Button>
);
}, [config.excelDownload, canExport, isExcelDownloading, handleExcelDownload]);
// 엑셀 선택 다운로드 버튼 (selectionActions 영역 - "전체 N건 / N개 항목 선택됨" 뒤)
const renderExcelSelectedDownloadButton = useMemo(() => {
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
return null;
}
const { enableSelectedDownload = true } = config.excelDownload;
if (!enableSelectedDownload || effectiveSelectedItems.size === 0) {
return null;
}
return (
<Button
variant="outline"
size="sm"
onClick={handleSelectedExcelDownload}
className="gap-2"
>
<Download className="h-4 w-4" />
({effectiveSelectedItems.size})
</Button>
);
}, [config.excelDownload, canExport, effectiveSelectedItems.size, handleSelectedExcelDownload]);
// ===== 정렬 핸들러 =====
const handleSort = useCallback((key: string) => {
let newSortBy: string | undefined;
let newSortOrder: 'asc' | 'desc' = 'asc';
if (sortBy === key) {
// 같은 컬럼 클릭: asc → desc → 정렬 해제
if (sortOrder === 'asc') {
newSortBy = key;
newSortOrder = 'desc';
} else {
newSortBy = undefined;
newSortOrder = 'asc';
}
} else {
// 다른 컬럼 클릭: 해당 컬럼으로 asc 정렬
newSortBy = key;
newSortOrder = 'asc';
}
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setCurrentPage(1);
// 서버 사이드 정렬: 부모 컴포넌트에 콜백 전달
if (!config.clientSideFiltering && config.onSortChange) {
config.onSortChange(newSortBy, newSortOrder);
}
}, [sortBy, sortOrder, config.clientSideFiltering, config.onSortChange]);
// ===== 탭 핸들러 =====
const handleTabChange = useCallback((value: string) => {
setActiveTab(value);
setCurrentPage(1);
setSelectedItems(new Set());
// 외부 콜백 호출 (서버 사이드 필터링용)
onTabChange?.(value);
}, [onTabChange]);
// ===== 페이지네이션 핸들러 =====
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
setSelectedItems(new Set());
}, []);
// ===== 통계 카드 계산 =====
const computedStats = useMemo(() => {
if (config.computeStats) {
return config.computeStats(
config.clientSideFiltering ? rawData : displayData,
config.clientSideFiltering ? rawData.length : totalCount
);
}
return config.stats;
}, [config.computeStats, config.stats, config.clientSideFiltering, rawData, displayData, totalCount]);
// ===== 필터 값 변환 =====
const filterValuesObj: FilterValues = useMemo(() => {
return filters as FilterValues;
}, [filters]);
// ===== 탭별 컬럼 선택 + sortable 기본값 적용 =====
const effectiveColumns = useMemo(() => {
const baseColumns = config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]
? config.columnsPerTab[activeTab]
: config.columns;
// sortable 기본값 적용:
// - NON_SORTABLE_KEYS에 해당하는 키: 기본 false
// - 그 외 모든 데이터 컬럼: 기본 true
// - 명시적으로 지정된 값이 있으면 그 값 사용
return baseColumns.map(col => ({
...col,
sortable: col.sortable ?? !NON_SORTABLE_KEYS.includes(col.key),
}));
}, [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 => {
return rawData.find((item) => getItemId(item) === id);
},
[rawData, getItemId]
);
// ===== 페이지네이션 config =====
// 외부 페이지네이션 사용 시 외부 설정 사용
// 단, 서버 사이드 검색 모드(searchFilter)에서는 필터링된 데이터 기준으로 재계산
const paginationConfig: PaginationConfig = useMemo(
() => {
if (isServerSearchFiltered && externalPagination) {
return {
...externalPagination,
currentPage,
totalPages: Math.ceil(filteredData.length / itemsPerPage) || 1,
totalItems: filteredData.length,
onPageChange: handlePageChange,
};
}
return externalPagination ?? {
currentPage,
totalPages,
totalItems: totalCount,
itemsPerPage,
onPageChange: handlePageChange,
};
},
[externalPagination, isServerSearchFiltered, filteredData.length, currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
);
// ===== 렌더링 함수 래퍼 =====
const showCheckbox = config.showCheckbox !== false;
const renderTableRow = useCallback(
(item: T, index: number, globalIndex: number) => {
const id = effectiveGetItemId(item);
const isSelected = effectiveSelectedItems.has(id);
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, enableColumnSettings, hiddenColumnKeys, showCheckbox, effectiveColumns]
);
const renderMobileCard = useCallback(
(item: T, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return config.renderMobileCard(item, index, globalIndex, {
isSelected,
onToggle,
onRowClick: () => handleRowClick(item),
onEdit: () => handleEdit(item),
onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined,
});
},
[config, handleDeleteClick, handleEdit, handleRowClick]
);
// ===== 삭제 확인 메시지 =====
const deleteConfirmTitle = config.deleteConfirmMessage?.title || '삭제 확인';
const deleteConfirmDescription =
config.deleteConfirmMessage?.description ||
(isBulkDelete
? `선택한 ${effectiveSelectedItems.size}건을 삭제하시겠습니까?`
: '이 항목을 삭제하시겠습니까?');
return (
<>
<IntegratedListTemplateV2
// 페이지 헤더
title={config.title}
description={config.description}
icon={config.icon}
headerActions={
<>
{/* 엑셀 다운로드 버튼 (config.excelDownload 설정 시 자동 추가) */}
{renderExcelDownloadButton}
{/* 커스텀 헤더 액션 */}
{config.headerActions?.({
onCreate: handleCreate,
selectedItems: effectiveSelectedItems,
onClearSelection: () => setSelectedItems(new Set()),
onRefresh: fetchData,
})}
</>
}
// 공통 헤더 옵션 (달력/등록버튼)
dateRangeSelector={config.dateRangeSelector}
createButton={permCanCreate ? config.createButton : undefined}
// 탭 콘텐츠
tabsContent={config.tabsContent}
// 통계 카드
stats={computedStats}
// 경고 배너
alertBanner={config.alertBanner}
// 검색 및 필터
// hideSearch: true이면서 config에 onSearchChange/searchFilter가 없으면 검색 완전 비활성화
// hideSearch: true이면서 onSearchChange/searchFilter가 있으면 헤더 검색창만 표시 (Card SearchFilter 숨김)
searchValue={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : searchValue}
onSearchChange={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : handleSearchChange}
searchPlaceholder={config.searchPlaceholder}
extraFilters={config.extraFilters}
hideSearch={config.hideSearch}
// 탭 (빈 배열일 때는 undefined로 전달해서 IntegratedListTemplateV2의 기본 탭 사용)
tabs={computedTabs.length > 0 ? computedTabs : undefined}
activeTab={activeTab}
onTabChange={handleTabChange}
tabsPosition={config.tabsPosition}
// 필터 시스템
filterConfig={config.filterConfig}
filterValues={filterValuesObj}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle={config.filterTitle}
// 테이블 앞 콘텐츠 (함수일 경우 params 전달)
beforeTableContent={
typeof config.beforeTableContent === 'function'
? config.beforeTableContent({
selectedItems: effectiveSelectedItems,
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
})
: config.beforeTableContent
}
// 테이블 헤더 액션 (함수일 경우 params 전달)
tableHeaderActions={
typeof config.tableHeaderActions === 'function'
? config.tableHeaderActions({
totalCount: isServerSearchFiltered ? filteredData.length : (externalPagination?.totalItems ?? totalCount),
selectedItems: effectiveSelectedItems,
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
})
: config.tableHeaderActions
}
// 테이블 컬럼 (가시성 필터링 적용)
tableColumns={templateColumns}
// 컬럼 리사이즈 & 가시성 설정
columnSettings={templateColumnSettings}
// 정렬 설정 (모든 페이지에서 활성화)
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
// 커스텀 테이블 헤더 (동적 컬럼용)
renderCustomTableHeader={
config.renderCustomTableHeader
? () =>
config.renderCustomTableHeader!({
displayData,
selectedItems: effectiveSelectedItems,
onToggleSelectAll: toggleSelectAll,
})
: undefined
}
// 테이블 푸터
tableFooter={config.tableFooter}
// 테이블 뒤 컨텐츠 (캘린더 등)
afterTableContent={
typeof config.afterTableContent === 'function'
? config.afterTableContent({ data: displayData, selectedItems: effectiveSelectedItems })
: config.afterTableContent
}
// 데이터
data={displayData}
totalCount={totalCount}
allData={config.clientSideFiltering ? filteredData : undefined}
// 모바일 인피니티 스크롤 로딩 상태
isMobileLoading={isMobileLoading}
// 체크박스 선택
selectedItems={effectiveSelectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={effectiveGetItemId}
onBulkDelete={config.actions?.deleteItem ? handleBulkDeleteClick : undefined}
selectionActions={
<>
{renderExcelSelectedDownloadButton}
{config.selectionActions?.({
selectedItems: effectiveSelectedItems,
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
onRefresh: fetchData,
})}
</>
}
// 표시 옵션
showCheckbox={config.showCheckbox}
showRowNumber={config.showRowNumber}
// 렌더링 함수
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={paginationConfig}
// 로딩 상태 (외부 로딩 상태 우선 사용)
isLoading={externalIsLoading ?? isLoading}
/>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteConfirm}
title={deleteConfirmTitle}
description={deleteConfirmDescription}
/>
{/* 상세 모달 (detailMode === 'modal'일 때) */}
{config.detailMode === 'modal' && config.DetailModalComponent && (
<config.DetailModalComponent
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedItem(null);
}}
item={selectedItem}
onRefresh={fetchData}
/>
)}
{/* 커스텀 다이얼로그 슬롯 */}
{config.renderDialogs?.({
data: displayData,
selectedItems: effectiveSelectedItems,
activeTab,
onRefresh: fetchData,
getItemById,
})}
</>
);
}
// 타입 re-export
export * from './types';