feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리
- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선 - 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션 - 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등) - 미들웨어 토큰 갱신 로직 개선 - AuthenticatedLayout 구조 개선 - claudedocs 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, Fragment, useState, RefObject } from "react";
|
||||
import { LucideIcon, Trash2 } from "lucide-react";
|
||||
import { LucideIcon, Trash2, Plus } from "lucide-react";
|
||||
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
@@ -100,6 +101,34 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
icon?: LucideIcon;
|
||||
headerActions?: ReactNode;
|
||||
|
||||
// ===== 공통 헤더 옵션 (달력/등록버튼) =====
|
||||
/**
|
||||
* 날짜 범위 선택기 (왼쪽 배치)
|
||||
* - enabled: 달력 표시 여부
|
||||
* - showPresets: 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘)
|
||||
* - startDate/endDate: 외부 상태 연동
|
||||
* - onChange: 날짜 변경 콜백
|
||||
*/
|
||||
dateRangeSelector?: {
|
||||
enabled: boolean;
|
||||
showPresets?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
onStartDateChange?: (date: string) => void;
|
||||
onEndDateChange?: (date: string) => void;
|
||||
};
|
||||
/**
|
||||
* 등록 버튼 (오른쪽 끝 배치)
|
||||
* - label: 버튼 텍스트 (예: '등록', '공정 등록')
|
||||
* - onClick: 클릭 핸들러
|
||||
* - icon: 아이콘 (기본: Plus)
|
||||
*/
|
||||
createButton?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
// 탭 콘텐츠 (헤더 액션 아래, 검색 위에 표시되는 커스텀 탭)
|
||||
tabsContent?: ReactNode;
|
||||
|
||||
@@ -191,6 +220,8 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
description,
|
||||
icon,
|
||||
headerActions,
|
||||
dateRangeSelector,
|
||||
createButton,
|
||||
tabsContent,
|
||||
stats,
|
||||
alertBanner,
|
||||
@@ -333,9 +364,32 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
|
||||
{headerActions && (
|
||||
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
|
||||
{(dateRangeSelector?.enabled || createButton || headerActions) && (
|
||||
<div className="flex items-center gap-2 flex-wrap w-full">
|
||||
{/* 날짜 범위 선택기 (왼쪽) */}
|
||||
{dateRangeSelector?.enabled && (
|
||||
<DateRangeSelector
|
||||
startDate={dateRangeSelector.startDate || ''}
|
||||
endDate={dateRangeSelector.endDate || ''}
|
||||
onStartDateChange={dateRangeSelector.onStartDateChange}
|
||||
onEndDateChange={dateRangeSelector.onEndDateChange}
|
||||
hidePresets={dateRangeSelector.showPresets === false}
|
||||
/>
|
||||
)}
|
||||
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
|
||||
{headerActions}
|
||||
{/* 등록 버튼 (오른쪽 끝) */}
|
||||
{createButton && (
|
||||
<Button className="ml-auto" onClick={createButton.onClick}>
|
||||
{createButton.icon ? (
|
||||
<createButton.icon className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{createButton.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -412,10 +466,10 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
{selectedItems.size}개 항목 선택됨
|
||||
</span>
|
||||
)}
|
||||
{/* 테이블 헤더 액션 (총 N건 등) - 필터 앞에 배치 */}
|
||||
{tableHeaderActions}
|
||||
{/* filterConfig 기반 자동 필터 (PC) */}
|
||||
{renderAutoFilters()}
|
||||
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) - 기존 방식 */}
|
||||
{tableHeaderActions}
|
||||
{selectedItems.size >= 1 && onBulkDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -431,6 +485,24 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 (~1279px) - TabChip 탭 */}
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className="xl:hidden mb-4 overflow-x-auto">
|
||||
<div className="flex gap-2 min-w-max">
|
||||
{tabs.map((tab) => (
|
||||
<TabChip
|
||||
key={tab.value}
|
||||
label={tab.label}
|
||||
count={tab.count}
|
||||
active={activeTab === tab.value}
|
||||
onClick={() => onTabChange?.(tab.value)}
|
||||
color={tab.color as any}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
{(tabs || [{ value: 'default', label: '', count: 0 }]).map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
||||
@@ -568,11 +640,11 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 - 데스크톱에서만 표시 */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="hidden xl:flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
전체 {pagination.totalItems}개 중 {startIndex + 1}-{Math.min(startIndex + pagination.itemsPerPage, pagination.totalItems)}개 표시
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
전체 {pagination.totalItems}개 중 {pagination.totalItems > 0 ? startIndex + 1 : 0}-{Math.min(startIndex + pagination.itemsPerPage, pagination.totalItems)}개 표시
|
||||
</div>
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -619,8 +691,8 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 일괄 삭제 확인 다이얼로그 - 단일 삭제와 동일한 디자인 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
|
||||
@@ -38,6 +38,11 @@ export function UniversalListPage<T>({
|
||||
config,
|
||||
initialData,
|
||||
initialTotalCount,
|
||||
externalPagination,
|
||||
externalSelection,
|
||||
onTabChange,
|
||||
onSearchChange,
|
||||
onFilterChange: onFilterChangeCallback,
|
||||
}: UniversalListPageProps<T>) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -176,13 +181,32 @@ export function UniversalListPage<T>({
|
||||
}
|
||||
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
|
||||
|
||||
// 초기 로딩
|
||||
// 초기 로딩 (initialData가 없거나 빈 배열인 경우)
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
if (!initialData || initialData.length === 0) {
|
||||
fetchData();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// initialData prop 변경 감지 (부모 컴포넌트에서 데이터 로드 후 전달하는 경우)
|
||||
useEffect(() => {
|
||||
if (initialData && initialData.length > 0) {
|
||||
setRawData(initialData);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// config.tabs 변경 감지 (동적 탭 카운트 업데이트용)
|
||||
useEffect(() => {
|
||||
if (config.tabs) {
|
||||
setTabs(config.tabs);
|
||||
}
|
||||
}, [config.tabs]);
|
||||
|
||||
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
|
||||
useEffect(() => {
|
||||
config.onDataChange?.(rawData);
|
||||
}, [rawData, config.onDataChange]);
|
||||
|
||||
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
|
||||
useEffect(() => {
|
||||
if (!config.clientSideFiltering && !isLoading) {
|
||||
@@ -203,30 +227,42 @@ export function UniversalListPage<T>({
|
||||
}, [config.fetchTabs]);
|
||||
|
||||
// ===== 선택 핸들러 =====
|
||||
// 외부 선택 상태 사용 시 외부 핸들러 사용
|
||||
const effectiveSelectedItems = externalSelection?.selectedItems ?? selectedItems;
|
||||
const effectiveGetItemId = externalSelection?.getItemId ?? getItemId;
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
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(() => {
|
||||
const currentData = displayData;
|
||||
if (selectedItems.size === currentData.length && currentData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
if (externalSelection) {
|
||||
externalSelection.onToggleSelectAll();
|
||||
} else {
|
||||
const allIds = new Set(currentData.map((item) => getItemId(item)));
|
||||
setSelectedItems(allIds);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [displayData, selectedItems.size, getItemId]);
|
||||
}, [externalSelection, displayData, selectedItems.size, getItemId]);
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
@@ -264,35 +300,37 @@ export function UniversalListPage<T>({
|
||||
}, []);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
if (effectiveSelectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setIsBulkDelete(true);
|
||||
setDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
}, [effectiveSelectedItems.size]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
try {
|
||||
if (isBulkDelete) {
|
||||
if (config.actions.deleteBulk) {
|
||||
const result = await config.actions.deleteBulk(Array.from(selectedItems));
|
||||
const result = await config.actions.deleteBulk(Array.from(effectiveSelectedItems));
|
||||
if (result.success) {
|
||||
toast.success(`${selectedItems.size}건이 삭제되었습니다.`);
|
||||
toast.success(`${effectiveSelectedItems.size}건이 삭제되었습니다.`);
|
||||
// 클라이언트 사이드: 로컬 데이터에서 제거
|
||||
if (config.clientSideFiltering) {
|
||||
setRawData((prev) =>
|
||||
prev.filter((item) => !selectedItems.has(getItemId(item)))
|
||||
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
|
||||
);
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
setSelectedItems(new Set());
|
||||
if (!externalSelection) {
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} else if (config.actions.deleteItem) {
|
||||
const ids = Array.from(selectedItems);
|
||||
} 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);
|
||||
@@ -301,15 +339,17 @@ export function UniversalListPage<T>({
|
||||
toast.success(`${successCount}건이 삭제되었습니다.`);
|
||||
if (config.clientSideFiltering) {
|
||||
setRawData((prev) =>
|
||||
prev.filter((item) => !selectedItems.has(getItemId(item)))
|
||||
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
|
||||
);
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
setSelectedItems(new Set());
|
||||
if (!externalSelection) {
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
}
|
||||
} else if (itemToDelete) {
|
||||
if (config.actions.deleteItem) {
|
||||
if (config.actions?.deleteItem) {
|
||||
const id = getItemId(itemToDelete);
|
||||
const result = await config.actions.deleteItem(id);
|
||||
if (result.success) {
|
||||
@@ -331,21 +371,26 @@ export function UniversalListPage<T>({
|
||||
setDeleteDialogOpen(false);
|
||||
setItemToDelete(null);
|
||||
}
|
||||
}, [config.actions, config.clientSideFiltering, fetchData, getItemId, isBulkDelete, itemToDelete, selectedItems]);
|
||||
}, [config.actions, config.clientSideFiltering, externalSelection, fetchData, getItemId, isBulkDelete, itemToDelete, effectiveSelectedItems]);
|
||||
|
||||
// ===== 검색 핸들러 =====
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
}, []);
|
||||
// 외부 콜백 호출 (서버 사이드 검색용)
|
||||
onSearchChange?.(value);
|
||||
}, [onSearchChange]);
|
||||
|
||||
// ===== 필터 핸들러 =====
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
}, []);
|
||||
// 외부 콜백 호출 (서버 사이드 필터링용)
|
||||
onFilterChangeCallback?.(newFilters);
|
||||
}, [filters, onFilterChangeCallback]);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setFilters(config.initialFilters || {});
|
||||
@@ -358,7 +403,9 @@ export function UniversalListPage<T>({
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
}, []);
|
||||
// 외부 콜백 호출 (서버 사이드 필터링용)
|
||||
onTabChange?.(value);
|
||||
}, [onTabChange]);
|
||||
|
||||
// ===== 페이지네이션 핸들러 =====
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
@@ -382,23 +429,40 @@ export function UniversalListPage<T>({
|
||||
return filters as FilterValues;
|
||||
}, [filters]);
|
||||
|
||||
// ===== 탭별 컬럼 선택 =====
|
||||
const effectiveColumns = useMemo(() => {
|
||||
if (config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]) {
|
||||
return config.columnsPerTab[activeTab];
|
||||
}
|
||||
return config.columns;
|
||||
}, [config.columns, config.columnsPerTab, activeTab]);
|
||||
|
||||
// ===== ID로 아이템 찾기 헬퍼 =====
|
||||
const getItemById = useCallback(
|
||||
(id: string): T | undefined => {
|
||||
return rawData.find((item) => getItemId(item) === id);
|
||||
},
|
||||
[rawData, getItemId]
|
||||
);
|
||||
|
||||
// ===== 페이지네이션 config =====
|
||||
// 외부 페이지네이션 사용 시 외부 설정 사용
|
||||
const paginationConfig: PaginationConfig = useMemo(
|
||||
() => ({
|
||||
() => externalPagination ?? {
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: handlePageChange,
|
||||
}),
|
||||
[currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
|
||||
},
|
||||
[externalPagination, currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
|
||||
);
|
||||
|
||||
// ===== 렌더링 함수 래퍼 =====
|
||||
const renderTableRow = useCallback(
|
||||
(item: T, index: number, globalIndex: number) => {
|
||||
const id = getItemId(item);
|
||||
const isSelected = selectedItems.has(id);
|
||||
const id = effectiveGetItemId(item);
|
||||
const isSelected = effectiveSelectedItems.has(id);
|
||||
return config.renderTableRow(item, index, globalIndex, {
|
||||
isSelected,
|
||||
onToggle: () => toggleSelection(id),
|
||||
@@ -407,7 +471,7 @@ export function UniversalListPage<T>({
|
||||
onDelete: () => handleDeleteClick(item),
|
||||
});
|
||||
},
|
||||
[config, getItemId, handleDeleteClick, handleEdit, handleRowClick, selectedItems, toggleSelection]
|
||||
[config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection]
|
||||
);
|
||||
|
||||
const renderMobileCard = useCallback(
|
||||
@@ -428,7 +492,7 @@ export function UniversalListPage<T>({
|
||||
const deleteConfirmDescription =
|
||||
config.deleteConfirmMessage?.description ||
|
||||
(isBulkDelete
|
||||
? `선택한 ${selectedItems.size}건을 삭제하시겠습니까?`
|
||||
? `선택한 ${effectiveSelectedItems.size}건을 삭제하시겠습니까?`
|
||||
: '이 항목을 삭제하시겠습니까?');
|
||||
|
||||
return (
|
||||
@@ -438,7 +502,15 @@ export function UniversalListPage<T>({
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
icon={config.icon}
|
||||
headerActions={config.headerActions?.({ onCreate: handleCreate })}
|
||||
headerActions={config.headerActions?.({
|
||||
onCreate: handleCreate,
|
||||
selectedItems: effectiveSelectedItems,
|
||||
onClearSelection: () => setSelectedItems(new Set()),
|
||||
onRefresh: fetchData,
|
||||
})}
|
||||
// 공통 헤더 옵션 (달력/등록버튼)
|
||||
dateRangeSelector={config.dateRangeSelector}
|
||||
createButton={config.createButton}
|
||||
// 탭 콘텐츠
|
||||
tabsContent={config.tabsContent}
|
||||
// 통계 카드
|
||||
@@ -461,12 +533,38 @@ export function UniversalListPage<T>({
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle={config.filterTitle}
|
||||
// 테이블 앞 콘텐츠
|
||||
beforeTableContent={config.beforeTableContent}
|
||||
// 테이블 헤더 액션
|
||||
tableHeaderActions={config.tableHeaderActions}
|
||||
// 테이블 컬럼
|
||||
tableColumns={config.columns}
|
||||
// 테이블 앞 콘텐츠 (함수일 경우 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: externalPagination?.totalItems ?? totalCount,
|
||||
selectedItems: effectiveSelectedItems,
|
||||
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
|
||||
})
|
||||
: config.tableHeaderActions
|
||||
}
|
||||
// 테이블 컬럼 (탭별 다른 컬럼 지원)
|
||||
tableColumns={effectiveColumns}
|
||||
// 커스텀 테이블 헤더 (동적 컬럼용)
|
||||
renderCustomTableHeader={
|
||||
config.renderCustomTableHeader
|
||||
? () =>
|
||||
config.renderCustomTableHeader!({
|
||||
displayData,
|
||||
selectedItems: effectiveSelectedItems,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
// 테이블 푸터
|
||||
tableFooter={config.tableFooter}
|
||||
// 데이터
|
||||
@@ -474,11 +572,11 @@ export function UniversalListPage<T>({
|
||||
totalCount={totalCount}
|
||||
allData={config.clientSideFiltering ? filteredData : undefined}
|
||||
// 체크박스 선택
|
||||
selectedItems={selectedItems}
|
||||
selectedItems={effectiveSelectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={getItemId}
|
||||
onBulkDelete={config.actions.deleteItem ? handleBulkDeleteClick : undefined}
|
||||
getItemId={effectiveGetItemId}
|
||||
onBulkDelete={config.actions?.deleteItem ? handleBulkDeleteClick : undefined}
|
||||
// 표시 옵션
|
||||
showCheckbox={config.showCheckbox}
|
||||
showRowNumber={config.showRowNumber}
|
||||
@@ -517,6 +615,15 @@ export function UniversalListPage<T>({
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 커스텀 다이얼로그 슬롯 */}
|
||||
{config.renderDialogs?.({
|
||||
data: displayData,
|
||||
selectedItems: effectiveSelectedItems,
|
||||
activeTab,
|
||||
onRefresh: fetchData,
|
||||
getItemById,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,12 @@ export interface UniversalListConfig<T> {
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
columns: TableColumn[];
|
||||
/**
|
||||
* 탭별 다른 컬럼 구조 (columnsPerTab 설정 시 columns 대신 사용)
|
||||
* - key: 탭 value
|
||||
* - value: 해당 탭에서 사용할 컬럼 배열
|
||||
*/
|
||||
columnsPerTab?: Record<string, TableColumn[]>;
|
||||
|
||||
// ===== 필터 설정 =====
|
||||
/** 필터 필드 설정 */
|
||||
@@ -179,11 +185,66 @@ export interface UniversalListConfig<T> {
|
||||
}>;
|
||||
|
||||
// ===== 커스텀 액션 =====
|
||||
/** 헤더 액션 (등록 버튼 등) */
|
||||
headerActions?: (params: { onCreate?: () => void }) => ReactNode;
|
||||
/**
|
||||
* 헤더 액션 (선택 기반 동적 버튼 등)
|
||||
* - onCreate: 등록 버튼 클릭 핸들러
|
||||
* - selectedItems: 현재 선택된 아이템 ID Set
|
||||
* - onClearSelection: 선택 해제 함수
|
||||
* - onRefresh: 데이터 새로고침 함수
|
||||
*/
|
||||
headerActions?: (params: {
|
||||
onCreate?: () => void;
|
||||
selectedItems: Set<string>;
|
||||
onClearSelection: () => void;
|
||||
onRefresh: () => void;
|
||||
}) => ReactNode;
|
||||
/** 커스텀 액션 버튼 (상신, 승인 등) */
|
||||
customActions?: CustomAction<T>[];
|
||||
|
||||
// ===== 공통 헤더 옵션 (달력/등록버튼) =====
|
||||
/**
|
||||
* 날짜 범위 선택기 (왼쪽 배치)
|
||||
* - enabled: 달력 표시 여부
|
||||
* - showPresets: 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘)
|
||||
* - startDate/endDate: 외부 상태 연동
|
||||
* - onChange: 날짜 변경 콜백
|
||||
*/
|
||||
dateRangeSelector?: {
|
||||
enabled: boolean;
|
||||
showPresets?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
onStartDateChange?: (date: string) => void;
|
||||
onEndDateChange?: (date: string) => void;
|
||||
};
|
||||
/**
|
||||
* 등록 버튼 (오른쪽 끝 배치)
|
||||
* - label: 버튼 텍스트 (예: '등록', '공정 등록')
|
||||
* - onClick: 클릭 핸들러
|
||||
* - icon: 아이콘 (기본: Plus)
|
||||
*/
|
||||
createButton?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
// ===== 커스텀 테이블 헤더 =====
|
||||
/**
|
||||
* 동적 컬럼을 위한 커스텀 테이블 헤더 렌더링 (columns 대신 사용)
|
||||
* - displayData: 현재 페이지에 표시되는 데이터
|
||||
* - selectedItems: 선택된 아이템 ID Set
|
||||
* - onToggleSelectAll: 전체 선택/해제 핸들러
|
||||
*/
|
||||
renderCustomTableHeader?: (params: {
|
||||
displayData: T[];
|
||||
selectedItems: Set<string>;
|
||||
onToggleSelectAll: () => void;
|
||||
}) => ReactNode;
|
||||
|
||||
/** 데이터 변경 콜백 (동적 컬럼 계산 등에 사용) */
|
||||
onDataChange?: (data: T[]) => void;
|
||||
|
||||
// ===== 추가 옵션 =====
|
||||
/** 검색 플레이스홀더 */
|
||||
searchPlaceholder?: string;
|
||||
@@ -217,12 +278,25 @@ export interface UniversalListConfig<T> {
|
||||
customSortFn?: (items: T[], filterValues: Record<string, string | string[]>) => T[];
|
||||
|
||||
// ===== 테이블 헤더 액션 =====
|
||||
/** 테이블 헤더 우측 추가 액션 (총건, 필터 해제 버튼 등) */
|
||||
tableHeaderActions?: ReactNode;
|
||||
/** 테이블 헤더 우측 추가 액션 (총건, 필터 해제 버튼, 일괄 액션 등)
|
||||
* - ReactNode: 정적 콘텐츠
|
||||
* - 함수: 동적으로 params를 받아서 렌더링
|
||||
* - totalCount: 총 데이터 수
|
||||
* - selectedItems: 선택된 아이템 ID Set
|
||||
* - onClearSelection: 선택 해제 함수
|
||||
*/
|
||||
tableHeaderActions?: ReactNode | ((params: {
|
||||
totalCount: number;
|
||||
selectedItems: Set<string>;
|
||||
onClearSelection: () => void;
|
||||
}) => ReactNode);
|
||||
|
||||
// ===== 추가 슬롯 =====
|
||||
/** 테이블 앞 커스텀 콘텐츠 */
|
||||
beforeTableContent?: ReactNode;
|
||||
/** 테이블 앞 커스텀 콘텐츠 (ReactNode 또는 selectedItems를 받는 함수) */
|
||||
beforeTableContent?: ReactNode | ((props: {
|
||||
selectedItems: Set<string>;
|
||||
onClearSelection: () => void;
|
||||
}) => ReactNode);
|
||||
/** 테이블 하단 푸터 */
|
||||
tableFooter?: ReactNode;
|
||||
/** 경고 배너 */
|
||||
@@ -231,6 +305,39 @@ export interface UniversalListConfig<T> {
|
||||
tabsContent?: ReactNode;
|
||||
/** 추가 필터 (Select, DatePicker 등) */
|
||||
extraFilters?: ReactNode;
|
||||
|
||||
// ===== 커스텀 다이얼로그 슬롯 =====
|
||||
/**
|
||||
* 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등)
|
||||
* - data: 현재 표시 중인 데이터 배열
|
||||
* - selectedItems: 선택된 아이템 ID Set
|
||||
* - activeTab: 현재 활성 탭
|
||||
* - onRefresh: 데이터 새로고침 함수
|
||||
*/
|
||||
renderDialogs?: (params: {
|
||||
data: T[];
|
||||
selectedItems: Set<string>;
|
||||
activeTab: string;
|
||||
onRefresh: () => void;
|
||||
getItemById: (id: string) => T | undefined;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
// ===== 외부 페이지네이션 설정 =====
|
||||
export interface ExternalPagination {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
// ===== 외부 선택 상태 설정 =====
|
||||
export interface ExternalSelection<T> {
|
||||
selectedItems: Set<string>;
|
||||
onToggleSelection: (id: string) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
getItemId: (item: T) => string;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 Props =====
|
||||
@@ -240,6 +347,36 @@ export interface UniversalListPageProps<T> {
|
||||
initialData?: T[];
|
||||
/** 초기 총 개수 */
|
||||
initialTotalCount?: number;
|
||||
/**
|
||||
* 외부 페이지네이션 (서버 사이드 페이지네이션용)
|
||||
* - 설정 시 내부 페이지네이션 무시
|
||||
* - currentPage, totalPages, onPageChange 등 외부에서 관리
|
||||
*/
|
||||
externalPagination?: ExternalPagination;
|
||||
/**
|
||||
* 외부 선택 상태 관리 (특수 행 타입이 있는 테이블용)
|
||||
* - 설정 시 내부 선택 상태 무시
|
||||
* - 선택 가능한 행만 필터링하는 로직은 외부에서 처리
|
||||
*/
|
||||
externalSelection?: ExternalSelection<T>;
|
||||
/**
|
||||
* 탭 변경 콜백 (서버 사이드 필터링용)
|
||||
* - 설정 시 탭 변경 시 외부에 알림
|
||||
* - 외부에서 API 호출 후 데이터 갱신 가능
|
||||
*/
|
||||
onTabChange?: (tab: string) => void;
|
||||
/**
|
||||
* 검색어 변경 콜백 (서버 사이드 검색용)
|
||||
* - 설정 시 검색어 변경 시 외부에 알림
|
||||
* - 외부에서 API 호출 후 데이터 갱신 가능
|
||||
*/
|
||||
onSearchChange?: (search: string) => void;
|
||||
/**
|
||||
* 필터 변경 콜백 (서버 사이드 필터링용)
|
||||
* - 설정 시 필터 변경 시 외부에 알림
|
||||
* - 외부에서 API 호출 후 데이터 갱신 가능
|
||||
*/
|
||||
onFilterChange?: (filters: Record<string, string | string[]>) => void;
|
||||
}
|
||||
|
||||
// ===== 내부 상태 타입 =====
|
||||
|
||||
Reference in New Issue
Block a user