- UniversalListPage 템플릿에 searchFilter, useClientSearch 지원 추가 - 검색 입력 시 리렌더링(포커스 유실) 버그 수정 - 29개 리스트 페이지에 searchFilter 함수 추가 - SiteBriefingListClient 누락된 searchFilter 추가 - IntegratedListTemplateV2 검색 로직 정리 - 검색 기능 수정내역 가이드 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
853 lines
31 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
} |