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:
byeongcheolryu
2026-01-16 15:19:09 +09:00
parent 8639eee5df
commit ad493bcea6
90 changed files with 19864 additions and 20305 deletions

View File

@@ -3,7 +3,6 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { format } from 'date-fns';
import {
Download,
Plus,
Calendar,
Check,
@@ -40,13 +39,14 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
IntegratedListTemplateV2,
UniversalListPage,
type UniversalListConfig,
type TableColumn,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
} from '@/components/templates/UniversalListPage';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { VacationGrantDialog } from './VacationGrantDialog';
@@ -109,7 +109,6 @@ export function VacationManagement() {
const [filterOption, setFilterOption] = useState<FilterOption>('all');
const [sortOption, setSortOption] = useState<SortOption>('rank');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 날짜 범위 상태 (input type="date" 용)
@@ -263,28 +262,8 @@ export function VacationManagement() {
setMainTab(value as MainTabType);
setSelectedItems(new Set());
setSearchQuery('');
setCurrentPage(1);
}, []);
// ===== 체크박스 핸들러 =====
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;
});
}, []);
const toggleSelectAll = useCallback(() => {
const currentData = mainTab === 'usage' ? filteredUsageData : mainTab === 'grant' ? filteredGrantData : filteredRequestData;
if (selectedItems.size === currentData.length && currentData.length > 0) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(currentData.map(item => item.id)));
}
}, [mainTab, selectedItems.size]);
// ===== 필터링된 데이터 =====
const filteredUsageData = useMemo(() => {
return usageData.filter(item =>
@@ -317,18 +296,12 @@ export function VacationManagement() {
}
}, [mainTab, filteredUsageData, filteredGrantData, filteredRequestData]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return currentData.slice(startIndex, startIndex + itemsPerPage);
}, [currentData, currentPage, itemsPerPage]);
const totalPages = Math.ceil(currentData.length / itemsPerPage);
// ===== 승인/거절 핸들러 =====
const handleApproveClick = useCallback(() => {
if (selectedItems.size === 0) return;
const handleApproveClick = useCallback((selected: Set<string>) => {
// 버튼 클릭 시 UniversalListPage의 선택 상태를 내부 state로 복사
setSelectedItems(selected);
setApproveDialogOpen(true);
}, [selectedItems.size]);
}, []);
const handleApproveConfirm = useCallback(async () => {
if (isProcessing) return;
@@ -352,10 +325,11 @@ export function VacationManagement() {
}
}, [selectedItems, isProcessing, fetchLeaveRequests, fetchUsageData]);
const handleRejectClick = useCallback(() => {
if (selectedItems.size === 0) return;
const handleRejectClick = useCallback((selected: Set<string>) => {
// 버튼 클릭 시 UniversalListPage의 선택 상태를 내부 state로 복사
setSelectedItems(selected);
setRejectDialogOpen(true);
}, [selectedItems.size]);
}, []);
const handleRejectConfirm = useCallback(async () => {
if (isProcessing) return;
@@ -455,22 +429,28 @@ export function VacationManagement() {
}, [mainTab]);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: any, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
const renderTableRow = useCallback((
item: any,
index: number,
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
if (mainTab === 'usage') {
const record = item as VacationUsageRecord;
const hireDateStr = record.hireDate && record.hireDate !== '-' ? format(new Date(record.hireDate), 'yyyy-MM-dd') : '-';
return (
<TableRow key={record.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(record.id)} />
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{record.department}</TableCell>
<TableCell>{record.position}</TableCell>
<TableCell className="font-medium">{record.employeeName}</TableCell>
<TableCell>{record.rank}</TableCell>
<TableCell>{record.hireDate !== '-' ? format(new Date(record.hireDate), 'yyyy-MM-dd') : '-'}</TableCell>
<TableCell>{hireDateStr}</TableCell>
<TableCell className="text-center">{record.baseVacation}</TableCell>
<TableCell className="text-center">{record.grantedVacation}</TableCell>
<TableCell className="text-center">{record.usedVacation}</TableCell>
@@ -479,10 +459,11 @@ export function VacationManagement() {
);
} else if (mainTab === 'grant') {
const record = item as VacationGrantRecord;
const grantDateStr = record.grantDate ? format(new Date(record.grantDate), 'yyyy-MM-dd') : '-';
return (
<TableRow key={record.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(record.id)} />
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{record.department}</TableCell>
@@ -492,17 +473,20 @@ export function VacationManagement() {
<TableCell>
<Badge variant="outline">{VACATION_TYPE_LABELS[record.vacationType]}</Badge>
</TableCell>
<TableCell>{format(new Date(record.grantDate), 'yyyy-MM-dd')}</TableCell>
<TableCell>{grantDateStr}</TableCell>
<TableCell className="text-center">{record.grantDays}</TableCell>
<TableCell className="text-muted-foreground">{record.reason || '-'}</TableCell>
</TableRow>
);
} else {
const record = item as VacationRequestRecord;
const startDateStr = record.startDate ? format(new Date(record.startDate), 'yyyy-MM-dd') : '-';
const endDateStr = record.endDate ? format(new Date(record.endDate), 'yyyy-MM-dd') : '-';
const requestDateStr = record.requestDate ? format(new Date(record.requestDate), 'yyyy-MM-dd') : '-';
return (
<TableRow key={record.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(record.id)} />
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{record.department}</TableCell>
@@ -510,7 +494,7 @@ export function VacationManagement() {
<TableCell className="font-medium">{record.employeeName}</TableCell>
<TableCell>{record.rank}</TableCell>
<TableCell>
{format(new Date(record.startDate), 'yyyy-MM-dd')} ~ {format(new Date(record.endDate), 'yyyy-MM-dd')}
{startDateStr} ~ {endDateStr}
</TableCell>
<TableCell className="text-center">{record.vacationDays}</TableCell>
<TableCell className="text-center">
@@ -518,20 +502,20 @@ export function VacationManagement() {
{REQUEST_STATUS_LABELS[record.status]}
</Badge>
</TableCell>
<TableCell>{format(new Date(record.requestDate), 'yyyy-MM-dd')}</TableCell>
<TableCell>{requestDateStr}</TableCell>
</TableRow>
);
}
}, [mainTab, selectedItems, toggleSelection]);
}, [mainTab]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: any,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
) => {
const { isSelected, onToggle } = handlers;
if (mainTab === 'usage') {
const record = item as VacationUsageRecord;
return (
@@ -598,10 +582,10 @@ export function VacationManagement() {
actions={
record.status === 'pending' && (
<div className="flex gap-2">
<Button variant="default" className="flex-1" onClick={handleApproveClick}>
<Button variant="default" className="flex-1" onClick={() => handleApproveClick(new Set([record.id]))}>
<Check className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" className="flex-1" onClick={handleRejectClick}>
<Button variant="outline" className="flex-1" onClick={() => handleRejectClick(new Set([record.id]))}>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
@@ -613,7 +597,7 @@ export function VacationManagement() {
}, [mainTab, handleApproveClick, handleRejectClick]);
// ===== 헤더 액션 (DateRangeSelector + 버튼들) =====
const headerActions = (
const headerActions = useCallback(({ selectedItems: selected }: { selectedItems: Set<string>; onClearSelection?: () => void; onRefresh?: () => void }) => (
<>
<DateRangeSelector
startDate={startDate}
@@ -633,13 +617,13 @@ export function VacationManagement() {
{mainTab === 'request' && (
<>
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
{selectedItems.size > 0 && (
{selected.size > 0 && (
<>
<Button variant="default" onClick={handleApproveClick}>
<Button variant="default" onClick={() => handleApproveClick(selected)}>
<Check className="h-4 w-4 mr-2" />
</Button>
<Button variant="destructive" onClick={handleRejectClick}>
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
<X className="h-4 w-4 mr-2" />
</Button>
@@ -651,15 +635,9 @@ export function VacationManagement() {
</Button>
</>
)}
{/* 엑셀 다운로드 버튼 - 주석처리 */}
{/* <Button variant="outline" onClick={() => console.log('엑셀 다운로드')}>
<Download className="h-4 w-4 mr-2" />
엑셀 다운로드
</Button> */}
</div>
</>
);
), [startDate, endDate, mainTab, handleApproveClick, handleRejectClick]);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
@@ -698,153 +676,193 @@ export function VacationManagement() {
setSortOption(value as SortOption);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setFilterOption('all');
setSortOption('rank');
setCurrentPage(1);
}, []);
// ===== UniversalListPage 설정 =====
const vacationConfig: UniversalListConfig<any> = useMemo(() => ({
title: '휴가관리',
description: '직원들의 휴가 현황을 관리합니다',
icon: Calendar,
basePath: '/hr/vacation-management',
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: currentData,
totalCount: currentData.length,
}),
},
columns: tableColumns,
tabs: tabs,
defaultTab: mainTab,
searchPlaceholder: '이름, 부서 검색...',
itemsPerPage: itemsPerPage,
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
return (
item.employeeName?.includes(searchValue) ||
item.department?.includes(searchValue)
);
},
// tabFilter 제거: 탭별로 다른 데이터를 사용하므로 원래 tabs.count 유지
computeStats: () => statCards,
filterConfig: filterConfig,
headerActions: headerActions,
renderTableRow: renderTableRow,
renderMobileCard: renderMobileCard,
renderDialogs: () => (
<>
{/* 휴가 부여 다이얼로그 */}
<VacationGrantDialog
open={grantDialogOpen}
onOpenChange={setGrantDialogOpen}
onSave={async (data) => {
try {
const result = await createLeaveGrant({
userId: parseInt(data.employeeId, 10),
grantType: data.vacationType as LeaveGrantType,
grantDate: data.grantDate,
grantDays: data.grantDays,
reason: data.reason,
});
if (result.success) {
await fetchGrantData();
await fetchUsageData();
} else {
alert(`휴가 부여 실패: ${result.error}`);
console.error('[VacationManagement] 휴가 부여 실패:', result.error);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] 휴가 부여 에러:', error);
alert('휴가 부여 중 오류가 발생했습니다.');
} finally {
setGrantDialogOpen(false);
}
}}
/>
{/* 휴가 신청 다이얼로그 */}
<VacationRequestDialog
open={requestDialogOpen}
onOpenChange={setRequestDialogOpen}
onSave={async (data) => {
try {
const result = await createLeave({
userId: parseInt(data.employeeId, 10),
leaveType: data.leaveType,
startDate: data.startDate,
endDate: data.endDate,
days: data.vacationDays,
});
if (result.success) {
await fetchLeaveRequests();
await fetchUsageData();
} else {
alert(`휴가 신청 실패: ${result.error}`);
console.error('[VacationManagement] 휴가 신청 실패:', result.error);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] 휴가 신청 에러:', error);
alert('휴가 신청 중 오류가 발생했습니다.');
} finally {
setRequestDialogOpen(false);
}
}}
/>
{/* 승인 확인 다이얼로그 */}
<AlertDialog open={approveDialogOpen} onOpenChange={setApproveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleApproveConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 거절 확인 다이얼로그 */}
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleRejectConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
),
}), [
currentData,
tableColumns,
tabs,
mainTab,
itemsPerPage,
statCards,
filterConfig,
headerActions,
renderTableRow,
renderMobileCard,
grantDialogOpen,
requestDialogOpen,
approveDialogOpen,
rejectDialogOpen,
selectedItems.size,
handleApproveConfirm,
handleRejectConfirm,
fetchGrantData,
fetchUsageData,
fetchLeaveRequests,
]);
return (
<>
{/* IntegratedListTemplateV2 - 카드 아래에 탭 표시됨 */}
<IntegratedListTemplateV2
title="휴가관리"
description="직원들의 휴가 현황을 관리합니다"
icon={Calendar}
headerActions={headerActions}
stats={statCards}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="이름, 부서 검색..."
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="휴가 필터"
tabs={tabs}
activeTab={mainTab}
onTabChange={handleMainTabChange}
tableColumns={tableColumns}
data={paginatedData}
totalCount={currentData.length}
allData={currentData}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item: any) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: currentData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 다이얼로그 */}
<VacationGrantDialog
open={grantDialogOpen}
onOpenChange={setGrantDialogOpen}
onSave={async (data) => {
try {
// VacationGrantFormData를 CreateLeaveGrantRequest 형식으로 변환
const result = await createLeaveGrant({
userId: parseInt(data.employeeId, 10),
grantType: data.vacationType as LeaveGrantType,
grantDate: data.grantDate,
grantDays: data.grantDays,
reason: data.reason,
});
if (result.success) {
await fetchGrantData();
await fetchUsageData(); // 잔여휴가도 갱신
} else {
alert(`휴가 부여 실패: ${result.error}`);
console.error('[VacationManagement] 휴가 부여 실패:', result.error);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] 휴가 부여 에러:', error);
alert('휴가 부여 중 오류가 발생했습니다.');
} finally {
setGrantDialogOpen(false);
}
}}
/>
<VacationRequestDialog
open={requestDialogOpen}
onOpenChange={setRequestDialogOpen}
onSave={async (data) => {
try {
// VacationRequestFormData를 CreateLeaveRequest 형식으로 변환
const result = await createLeave({
userId: parseInt(data.employeeId, 10),
leaveType: data.leaveType,
startDate: data.startDate,
endDate: data.endDate,
days: data.vacationDays,
});
if (result.success) {
await fetchLeaveRequests();
await fetchUsageData(); // 잔여휴가도 갱신
} else {
alert(`휴가 신청 실패: ${result.error}`);
console.error('[VacationManagement] 휴가 신청 실패:', result.error);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] 휴가 신청 에러:', error);
alert('휴가 신청 중 오류가 발생했습니다.');
} finally {
setRequestDialogOpen(false);
}
}}
/>
{/* 승인 확인 다이얼로그 */}
<AlertDialog open={approveDialogOpen} onOpenChange={setApproveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleApproveConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 거절 확인 다이얼로그 */}
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleRejectConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
<UniversalListPage<any>
config={vacationConfig}
initialData={currentData}
initialTotalCount={currentData.length}
onTabChange={handleMainTabChange}
onSearchChange={setSearchQuery}
onFilterChange={handleFilterChange}
/>
);
}