Files
sam-react-prod/src/components/hr/VacationManagement/index.tsx
유병철 a5578bf669 feat: UniversalListPage 검색 기능 개선 및 리렌더링 버그 수정
- UniversalListPage 템플릿에 searchFilter, useClientSearch 지원 추가
- 검색 입력 시 리렌더링(포커스 유실) 버그 수정
- 29개 리스트 페이지에 searchFilter 함수 추가
- SiteBriefingListClient 누락된 searchFilter 추가
- IntegratedListTemplateV2 검색 로직 정리
- 검색 기능 수정내역 가이드 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:07 +09:00

853 lines
31 KiB
TypeScript

'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { format } from 'date-fns';
import {
Plus,
Calendar,
Check,
X,
Clock,
Heart,
TrendingUp,
} from 'lucide-react';
import {
getLeaves,
getLeaveBalances,
getLeaveGrants,
createLeave,
createLeaveGrant,
approveLeavesMany,
rejectLeavesMany,
type LeaveRecord,
type LeaveBalanceRecord,
type LeaveGrantRecord,
type LeaveGrantType,
} from './actions';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
type TableColumn,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { VacationGrantDialog } from './VacationGrantDialog';
import { VacationRequestDialog } from './VacationRequestDialog';
import type {
MainTabType,
VacationUsageRecord,
VacationGrantRecord,
VacationRequestRecord,
SortOption,
FilterOption,
VacationType,
RequestStatus,
} from './types';
import {
MAIN_TAB_LABELS,
SORT_OPTIONS,
FILTER_OPTIONS,
VACATION_TYPE_LABELS,
REQUEST_STATUS_LABELS,
REQUEST_STATUS_COLORS,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// ===== Mock 데이터 생성 (request 탭용 - 신청 현황은 leaves API 사용 예정) =====
const generateRequestData = (): VacationRequestRecord[] => {
const departments = ['개발팀', '디자인팀', '기획팀', '영업팀', '인사팀'];
const positions = ['팀장', '파트장', '선임', '주임', '사원'];
const ranks = ['부장', '차장', '과장', '대리', '사원'];
const statuses: RequestStatus[] = ['pending', 'approved', 'rejected'];
return Array.from({ length: 7 }, (_, i) => {
const startDate = new Date(2025, 11, (i % 28) + 1);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + Math.floor(Math.random() * 5) + 1);
return {
id: `request-${i + 1}`,
employeeId: `EMP${String(i + 1).padStart(3, '0')}`,
employeeName: ['김철수', '이영희', '박민수', '정수진', '최동현', '강미영', '윤상호'][i],
department: departments[i % departments.length],
position: positions[i % positions.length],
rank: ranks[i % ranks.length],
startDate: format(startDate, 'yyyy-MM-dd'),
endDate: format(endDate, 'yyyy-MM-dd'),
vacationDays: Math.floor(Math.random() * 5) + 1,
status: statuses[i % statuses.length],
requestDate: format(new Date(2025, 11, i + 1), 'yyyy-MM-dd'),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
};
export function VacationManagement() {
// ===== 상태 관리 =====
const [mainTab, setMainTab] = useState<MainTabType>('usage');
const [searchQuery, setSearchQuery] = useState('');
const [filterOption, setFilterOption] = useState<FilterOption>('all');
const [sortOption, setSortOption] = useState<SortOption>('rank');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const itemsPerPage = 20;
// 날짜 범위 상태 (input type="date" 용)
const [startDate, setStartDate] = useState('2025-12-01');
const [endDate, setEndDate] = useState('2025-12-31');
// 다이얼로그 상태
const [grantDialogOpen, setGrantDialogOpen] = useState(false);
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
// 로딩/처리중 상태
const [isLoading, setIsLoading] = useState(false);
const isInitialLoadDone = useRef(false);
const [isProcessing, setIsProcessing] = useState(false);
// 데이터 상태 (usage/grant 탭은 API, request는 Mock)
const [usageData, setUsageData] = useState<VacationUsageRecord[]>([]);
const [grantData, setGrantData] = useState<VacationGrantRecord[]>([]);
const [requestData, setRequestData] = useState<VacationRequestRecord[]>([]);
const [apiLeaveRecords, setApiLeaveRecords] = useState<LeaveRecord[]>([]);
// ===== API 데이터 로드 =====
/**
* 휴가 사용현황 데이터 로드 (usage 탭)
*/
const fetchUsageData = useCallback(async () => {
if (!isInitialLoadDone.current) {
setIsLoading(true);
}
try {
const currentYear = new Date().getFullYear();
const result = await getLeaveBalances({ year: currentYear, perPage: 100 });
if (result.success && result.data) {
// API 데이터를 VacationUsageRecord 형식으로 변환
// 사원관리와 동일: 직책=job_title_label, 직급=json_extra.rank
const converted: VacationUsageRecord[] = result.data.items.map((item: LeaveBalanceRecord) => ({
id: String(item.id),
employeeId: String(item.userId),
employeeName: item.displayName || item.employeeName,
department: item.department,
position: item.jobTitle || '-', // job_title_label → 직책
rank: item.rank || '-', // json_extra.rank → 직급
hireDate: item.hireDate || '-',
baseVacation: '15일', // 기본 연차
grantedVacation: `${Math.max(0, item.totalDays - 15)}`, // 부여일수 = 총일수 - 기본15일
usedVacation: `${item.usedDays}`,
remainingVacation: `${item.remainingDays}`,
annual: { total: item.totalDays, used: item.usedDays, remaining: item.remainingDays },
monthly: { total: 0, used: 0, remaining: 0 },
reward: { total: 0, used: 0, remaining: 0 },
other: { total: 0, used: 0, remaining: 0 },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
setUsageData(converted);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] fetchUsageData error:', error);
} finally {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, []);
/**
* 휴가 부여현황 데이터 로드 (grant 탭)
*/
const fetchGrantData = useCallback(async () => {
if (!isInitialLoadDone.current) {
setIsLoading(true);
}
try {
const currentYear = new Date().getFullYear();
const result = await getLeaveGrants({ year: currentYear, perPage: 100 });
if (result.success && result.data) {
// API 데이터를 VacationGrantRecord 형식으로 변환
// 사원관리와 동일: 직책=job_title_label, 직급=json_extra.rank
const converted: VacationGrantRecord[] = result.data.items.map((item: LeaveGrantRecord) => ({
id: String(item.id),
employeeId: String(item.userId),
employeeName: item.employeeName,
department: item.department,
position: item.jobTitle || '-', // job_title_label → 직책
rank: item.rank || '-', // json_extra.rank → 직급
vacationType: item.grantType as VacationType,
grantDate: item.grantDate.split('T')[0],
grantDays: item.grantDays,
reason: item.reason || undefined,
createdAt: item.createdAt,
updatedAt: item.createdAt,
}));
setGrantData(converted);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] fetchGrantData error:', error);
} finally {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, []);
/**
* 휴가 신청현황 데이터 로드 (request 탭)
*/
const fetchLeaveRequests = useCallback(async () => {
if (!isInitialLoadDone.current) {
setIsLoading(true);
}
try {
const result = await getLeaves({
dateFrom: startDate,
dateTo: endDate,
perPage: 100,
});
if (result.success && result.data) {
setApiLeaveRecords(result.data.items);
// API 데이터를 VacationRequestRecord 형식으로 변환
// position = 직책 (팀장, 팀원), rank = 직급 (부장, 과장)
const converted: VacationRequestRecord[] = result.data.items.map((item) => {
const profile = item.userProfile;
return {
id: String(item.id),
employeeId: String(item.userId),
employeeName: item.user?.name || '알 수 없음',
department: profile?.department?.name || '-',
position: profile?.job_title_label || '-', // job_title_label → 직책
rank: profile?.rank || '-', // json_extra.rank → 직급 (사원관리와 동일)
startDate: item.startDate,
endDate: item.endDate,
vacationDays: item.days,
status: item.status as RequestStatus,
requestDate: item.createdAt.split('T')[0],
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
});
setRequestData(converted);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] fetchLeaveRequests error:', error);
} finally {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, [startDate, endDate]);
// 초기 데이터 로드
useEffect(() => {
fetchUsageData();
fetchGrantData();
fetchLeaveRequests();
}, [fetchUsageData, fetchGrantData, fetchLeaveRequests]);
// ===== 탭 변경 핸들러 =====
const handleMainTabChange = useCallback((value: string) => {
setMainTab(value as MainTabType);
setSelectedItems(new Set());
setSearchQuery('');
}, []);
// ===== 필터링된 데이터 =====
const filteredUsageData = useMemo(() => {
return usageData.filter(item =>
item.employeeName.includes(searchQuery) ||
item.department.includes(searchQuery)
);
}, [usageData, searchQuery]);
const filteredGrantData = useMemo(() => {
return grantData.filter(item =>
item.employeeName.includes(searchQuery) ||
item.department.includes(searchQuery)
);
}, [grantData, searchQuery]);
const filteredRequestData = useMemo(() => {
return requestData.filter(item =>
item.employeeName.includes(searchQuery) ||
item.department.includes(searchQuery)
);
}, [requestData, searchQuery]);
// ===== 현재 탭 데이터 =====
const currentData = useMemo(() => {
switch (mainTab) {
case 'usage': return filteredUsageData;
case 'grant': return filteredGrantData;
case 'request': return filteredRequestData;
default: return [];
}
}, [mainTab, filteredUsageData, filteredGrantData, filteredRequestData]);
// ===== 승인/거절 핸들러 =====
const handleApproveClick = useCallback((selected: Set<string>) => {
// 버튼 클릭 시 UniversalListPage의 선택 상태를 내부 state로 복사
setSelectedItems(selected);
setApproveDialogOpen(true);
}, []);
const handleApproveConfirm = useCallback(async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
const result = await approveLeavesMany(ids);
if (result.success) {
await fetchLeaveRequests();
await fetchUsageData(); // 휴가 사용현황도 갱신
} else {
console.error('[VacationManagement] 승인 실패:', result.error);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] handleApproveConfirm error:', error);
} finally {
setSelectedItems(new Set());
setApproveDialogOpen(false);
setIsProcessing(false);
}
}, [selectedItems, isProcessing, fetchLeaveRequests, fetchUsageData]);
const handleRejectClick = useCallback((selected: Set<string>) => {
// 버튼 클릭 시 UniversalListPage의 선택 상태를 내부 state로 복사
setSelectedItems(selected);
setRejectDialogOpen(true);
}, []);
const handleRejectConfirm = useCallback(async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
const result = await rejectLeavesMany(ids, '관리자에 의해 반려됨');
if (result.success) {
await fetchLeaveRequests();
} else {
console.error('[VacationManagement] 반려 실패:', result.error);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[VacationManagement] handleRejectConfirm error:', error);
} finally {
setSelectedItems(new Set());
setRejectDialogOpen(false);
setIsProcessing(false);
}
}, [selectedItems, isProcessing, fetchLeaveRequests]);
// ===== 통계 카드 (기획서: 휴가 승인 대기, 연차, 경조사, 연간 연차 사용률) =====
const statCards: StatCard[] = useMemo(() => {
// 휴가 승인 대기 (pending 상태인 request 수)
const pendingCount = requestData.filter(r => r.status === 'pending').length;
// 연차 사용자 수
const annualCount = usageData.length;
// 경조사 휴가 수
const condolenceCount = grantData.filter(g => g.vacationType === 'condolence').length;
// 연간 연차 사용률 계산 (총 사용일 / 총 부여일 * 100)
const totalUsed = usageData.reduce((sum, u) => sum + u.annual.used, 0);
const totalAvailable = usageData.reduce((sum, u) => sum + u.annual.total, 0);
const usageRate = totalAvailable > 0 ? ((totalUsed / totalAvailable) * 100).toFixed(1) : '0.0';
return [
{ label: '휴가 승인 대기', value: `${pendingCount}`, icon: Clock, iconColor: 'text-yellow-500' },
{ label: '연차', value: `${annualCount}`, icon: Calendar, iconColor: 'text-blue-500' },
{ label: '경조사', value: `${condolenceCount}`, icon: Heart, iconColor: 'text-pink-500' },
{ label: '연간 연차 사용률', value: `${usageRate}%`, icon: TrendingUp, iconColor: 'text-green-500' },
];
}, [usageData, grantData, requestData]);
// ===== 탭 옵션 (카드 아래에 표시됨) =====
const tabs: TabOption[] = useMemo(() => [
{ value: 'usage', label: MAIN_TAB_LABELS.usage, count: usageData.length, color: 'blue' },
{ value: 'grant', label: MAIN_TAB_LABELS.grant, count: grantData.length, color: 'green' },
{ value: 'request', label: MAIN_TAB_LABELS.request, count: requestData.length, color: 'purple' },
], [usageData.length, grantData.length, requestData.length]);
// ===== 테이블 컬럼 (탭별) =====
const tableColumns: TableColumn[] = useMemo(() => {
if (mainTab === 'usage') {
// 휴가 사용현황: 번호|부서|직책|이름|직급|입사일|기본|부여|사용|잔액
return [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'department', label: '부서' },
{ key: 'position', label: '직책' },
{ key: 'name', label: '이름' },
{ key: 'rank', label: '직급' },
{ key: 'hireDate', label: '입사일' },
{ key: 'base', label: '기본', className: 'text-center' },
{ key: 'granted', label: '부여', className: 'text-center' },
{ key: 'used', label: '사용', className: 'text-center' },
{ key: 'remaining', label: '잔여', className: 'text-center' },
];
} else if (mainTab === 'grant') {
// 휴가 부여현황: 번호|부서|직책|이름|직급|유형|부여일|부여휴가일수|사유
return [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'department', label: '부서' },
{ key: 'position', label: '직책' },
{ key: 'name', label: '이름' },
{ key: 'rank', label: '직급' },
{ key: 'type', label: '유형' },
{ key: 'grantDate', label: '부여일' },
{ key: 'grantDays', label: '부여휴가일수', className: 'text-center' },
{ key: 'reason', label: '사유' },
];
} else {
// 휴가 신청현황: 번호|부서|직책|이름|직급|휴가기간|휴가일수|상태|신청일
return [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'department', label: '부서' },
{ key: 'position', label: '직책' },
{ key: 'name', label: '이름' },
{ key: 'rank', label: '직급' },
{ key: 'period', label: '휴가기간' },
{ key: 'days', label: '휴가일수', className: 'text-center' },
{ key: 'status', label: '상태', className: 'text-center' },
{ key: 'requestDate', label: '신청일' },
];
}
}, [mainTab]);
// ===== 테이블 행 렌더링 =====
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={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>{hireDateStr}</TableCell>
<TableCell className="text-center">{record.baseVacation}</TableCell>
<TableCell className="text-center">{record.grantedVacation}</TableCell>
<TableCell className="text-center">{record.usedVacation}</TableCell>
<TableCell className="text-center font-medium">{record.remainingVacation}</TableCell>
</TableRow>
);
} 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={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>
<Badge variant="outline">{VACATION_TYPE_LABELS[record.vacationType]}</Badge>
</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={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>
{startDateStr} ~ {endDateStr}
</TableCell>
<TableCell className="text-center">{record.vacationDays}</TableCell>
<TableCell className="text-center">
<Badge className={REQUEST_STATUS_COLORS[record.status]}>
{REQUEST_STATUS_LABELS[record.status]}
</Badge>
</TableCell>
<TableCell>{requestDateStr}</TableCell>
</TableRow>
);
}
}, [mainTab]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = 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;
return (
<ListMobileCard
id={record.id}
title={record.employeeName}
headerBadges={<Badge variant="outline">{record.department}</Badge>}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="직급" value={record.rank} />
<InfoField label="입사일" value={record.hireDate} />
<InfoField label="기본" value={record.baseVacation} />
<InfoField label="부여" value={record.grantedVacation} />
<InfoField label="사용" value={record.usedVacation} />
<InfoField label="잔여" value={record.remainingVacation} />
</div>
}
/>
);
} else if (mainTab === 'grant') {
const record = item as VacationGrantRecord;
return (
<ListMobileCard
id={record.id}
title={record.employeeName}
headerBadges={<Badge variant="outline">{VACATION_TYPE_LABELS[record.vacationType]}</Badge>}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="부서" value={record.department} />
<InfoField label="직급" value={record.rank} />
<InfoField label="부여일" value={record.grantDate} />
<InfoField label="일수" value={`${record.grantDays}`} />
{record.reason && <InfoField label="사유" value={record.reason} />}
</div>
}
/>
);
} else {
const record = item as VacationRequestRecord;
return (
<ListMobileCard
id={record.id}
title={record.employeeName}
headerBadges={
<Badge className={REQUEST_STATUS_COLORS[record.status]}>
{REQUEST_STATUS_LABELS[record.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="부서" value={record.department} />
<InfoField label="직급" value={record.rank} />
<InfoField label="기간" value={`${record.startDate} ~ ${record.endDate}`} />
<InfoField label="일수" value={`${record.vacationDays}`} />
<InfoField label="신청일" value={record.requestDate} />
</div>
}
actions={
record.status === 'pending' && (
<div className="flex gap-2">
<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(new Set([record.id]))}>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
)
}
/>
);
}
}, [mainTab, handleApproveClick, handleRejectClick]);
// ===== 헤더 액션 (탭별 버튼들만 - DateRangeSelector와 검색창은 공통 옵션 사용) =====
const headerActions = useCallback(({ selectedItems: selected }: { selectedItems: Set<string>; onClearSelection?: () => void; onRefresh?: () => void }) => (
<div className="flex items-center gap-2">
{/* 탭별 액션 버튼 */}
{mainTab === 'grant' && (
<Button onClick={() => setGrantDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
)}
{mainTab === 'request' && (
<>
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
{selected.size > 0 && (
<>
<Button variant="default" onClick={() => handleApproveClick(selected)}>
<Check className="h-4 w-4 mr-2" />
</Button>
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
<X className="h-4 w-4 mr-2" />
</Button>
</>
)}
<Button onClick={() => setRequestDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</>
)}
</div>
), [mainTab, handleApproveClick, handleRejectClick]);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'filter',
label: '필터',
type: 'single',
options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
},
], []);
const filterValues: FilterValues = useMemo(() => ({
filter: filterOption,
sort: sortOption,
}), [filterOption, sortOption]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'filter':
setFilterOption(value as FilterOption);
break;
case 'sort':
setSortOption(value as SortOption);
break;
}
}, []);
const handleFilterReset = useCallback(() => {
setFilterOption('all');
setSortOption('rank');
}, []);
// ===== 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,
// 공통 패턴: dateRangeSelector
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
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);
}
}}
/>
{/* 승인 확인 다이얼로그 */}
<ConfirmDialog
open={approveDialogOpen}
onOpenChange={setApproveDialogOpen}
title="휴가 승인"
description={`정말 ${selectedItems.size}건을 승인하시겠습니까?`}
confirmText="승인"
variant="success"
onConfirm={handleApproveConfirm}
/>
{/* 거절 확인 다이얼로그 */}
<ConfirmDialog
open={rejectDialogOpen}
onOpenChange={setRejectDialogOpen}
title="휴가 거절"
description={`정말 ${selectedItems.size}건을 거절하시겠습니까?`}
confirmText="거절"
variant="destructive"
onConfirm={handleRejectConfirm}
/>
</>
),
}), [
currentData,
tableColumns,
tabs,
mainTab,
itemsPerPage,
statCards,
filterConfig,
headerActions,
renderTableRow,
renderMobileCard,
grantDialogOpen,
requestDialogOpen,
approveDialogOpen,
rejectDialogOpen,
selectedItems.size,
handleApproveConfirm,
handleRejectConfirm,
fetchGrantData,
fetchUsageData,
fetchLeaveRequests,
]);
return (
<UniversalListPage<any>
config={vacationConfig}
initialData={currentData}
initialTotalCount={currentData.length}
onTabChange={handleMainTabChange}
onSearchChange={setSearchQuery}
onFilterChange={handleFilterChange}
externalIsLoading={isLoading}
/>
);
}