'use client'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2, Search } from 'lucide-react'; import { getEmployees, deleteEmployee, deleteEmployees, getEmployeeStats } from './actions'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; import { UniversalListPage, type UniversalListConfig, type TabOption, type StatCard, type FilterFieldConfig, type FilterValues, } from '@/components/templates/UniversalListPage'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { FieldSettingsDialog } from './FieldSettingsDialog'; import { UserInviteDialog } from './UserInviteDialog'; import type { Employee, EmployeeStatus, FieldSettings, } from './types'; import { EMPLOYEE_STATUS_LABELS, EMPLOYEE_STATUS_COLORS, DEFAULT_FIELD_SETTINGS, USER_ROLE_LABELS, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; // 필터 옵션 타입 type FilterOption = 'all' | 'hasUserId' | 'noUserId' | 'active' | 'leave' | 'resigned'; // 정렬 옵션 타입 type SortOption = 'rank' | 'hireDateDesc' | 'hireDateAsc' | 'departmentAsc' | 'departmentDesc' | 'nameAsc' | 'nameDesc'; // 필터 옵션 레이블 const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [ { value: 'all', label: '전체' }, { value: 'hasUserId', label: '사용자 아이디 보유' }, { value: 'noUserId', label: '사용자 아이디 미보유' }, { value: 'active', label: '재직' }, { value: 'leave', label: '휴직' }, { value: 'resigned', label: '퇴직' }, ]; // 정렬 옵션 레이블 const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'rank', label: '직급순' }, { value: 'hireDateDesc', label: '입사일 최신순' }, { value: 'hireDateAsc', label: '입사일 등록순' }, { value: 'departmentAsc', label: '부서 오름차순' }, { value: 'departmentDesc', label: '부서 내림차순' }, { value: 'nameAsc', label: '이름 오름차순' }, { value: 'nameDesc', label: '이름 내림차순' }, ]; export function EmployeeManagement() { const router = useRouter(); // 사원 데이터 상태 const [employees, setEmployees] = useState([]); const [isLoading, setIsLoading] = useState(true); const isInitialLoadDone = useRef(false); const [total, setTotal] = useState(0); // 검색 및 필터 상태 const [searchValue, setSearchValue] = useState(''); const [activeTab, setActiveTab] = useState('all'); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; // 날짜 범위 상태 (Input type="date" 용) - 초기값 비움: 전체 기간 조회 const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); // 필터 및 정렬 상태 const [filterOption, setFilterOption] = useState('all'); const [sortOption, setSortOption] = useState('rank'); // 다이얼로그 상태 const [fieldSettingsOpen, setFieldSettingsOpen] = useState(false); const [fieldSettings, setFieldSettings] = useState(DEFAULT_FIELD_SETTINGS); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [employeeToDelete, setEmployeeToDelete] = useState(null); const [userInviteOpen, setUserInviteOpen] = useState(false); const [selectedItems, setSelectedItems] = useState>(new Set()); const [isDeleting, setIsDeleting] = useState(false); // 데이터 로드 useEffect(() => { const fetchEmployees = async () => { if (!isInitialLoadDone.current) { setIsLoading(true); } try { const result = await getEmployees({ per_page: 100, // 충분히 많은 데이터 로드 }); setEmployees(result.data); setTotal(result.total); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[EmployeeManagement] fetchEmployees error:', error); } finally { setIsLoading(false); isInitialLoadDone.current = true; } }; fetchEmployees(); }, []); // 필터링된 데이터 const filteredEmployees = useMemo(() => { let filtered = employees; // 탭 필터 (상태) if (activeTab !== 'all') { filtered = filtered.filter(e => e.status === activeTab); } // 셀렉트박스 필터 if (filterOption !== 'all') { switch (filterOption) { case 'hasUserId': filtered = filtered.filter(e => e.userInfo?.userId); break; case 'noUserId': filtered = filtered.filter(e => !e.userInfo?.userId); break; case 'active': filtered = filtered.filter(e => e.status === 'active'); break; case 'leave': filtered = filtered.filter(e => e.status === 'leave'); break; case 'resigned': filtered = filtered.filter(e => e.status === 'resigned'); break; } } // 검색 필터 if (searchValue) { const search = searchValue.toLowerCase(); filtered = filtered.filter(e => e.name.toLowerCase().includes(search) || e.employeeCode?.toLowerCase().includes(search) || e.email?.toLowerCase().includes(search) ); } // 날짜 필터는 UniversalListPage에서 dateField 설정을 통해 자동 처리됨 // 정렬 filtered = [...filtered].sort((a, b) => { switch (sortOption) { case 'rank': // 직급 순서 정의 (높은 직급이 먼저) const rankOrder: Record = { '회장': 1, '사장': 2, '부사장': 3, '전무': 4, '상무': 5, '이사': 6, '부장': 7, '차장': 8, '과장': 9, '대리': 10, '주임': 11, '사원': 12 }; return (rankOrder[a.rank || ''] || 99) - (rankOrder[b.rank || ''] || 99); case 'hireDateDesc': return new Date(b.hireDate || 0).getTime() - new Date(a.hireDate || 0).getTime(); case 'hireDateAsc': return new Date(a.hireDate || 0).getTime() - new Date(b.hireDate || 0).getTime(); case 'departmentAsc': const deptA = a.departmentPositions?.[0]?.departmentName || ''; const deptB = b.departmentPositions?.[0]?.departmentName || ''; return deptA.localeCompare(deptB, 'ko'); case 'departmentDesc': const deptA2 = a.departmentPositions?.[0]?.departmentName || ''; const deptB2 = b.departmentPositions?.[0]?.departmentName || ''; return deptB2.localeCompare(deptA2, 'ko'); case 'nameAsc': return a.name.localeCompare(b.name, 'ko'); case 'nameDesc': return b.name.localeCompare(a.name, 'ko'); default: return 0; } }); return filtered; }, [employees, activeTab, filterOption, sortOption, searchValue]); // 페이지네이션된 데이터 const paginatedData = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; return filteredEmployees.slice(startIndex, startIndex + itemsPerPage); }, [filteredEmployees, currentPage, itemsPerPage]); // 통계 계산 // SSR Hydration 오류 방지: new Date() 대신 고정 날짜 사용 const stats = useMemo(() => { const activeCount = employees.filter(e => e.status === 'active').length; const leaveCount = employees.filter(e => e.status === 'leave').length; const resignedCount = employees.filter(e => e.status === 'resigned').length; const activeEmployees = employees.filter(e => e.status === 'active' && e.hireDate); // 고정 기준일 사용 (SSR/CSR 일치) const referenceDate = new Date('2025-12-16T00:00:00Z'); const totalTenure = activeEmployees.reduce((sum, e) => { const hireDate = new Date(e.hireDate!); const years = Math.max(0, (referenceDate.getTime() - hireDate.getTime()) / (1000 * 60 * 60 * 24 * 365)); return sum + years; }, 0); const averageTenure = activeEmployees.length > 0 ? totalTenure / activeEmployees.length : 0; return { activeCount, leaveCount, resignedCount, averageTenure }; }, [employees]); // StatCards 데이터 const statCards: StatCard[] = useMemo(() => [ { label: '재직', value: `${stats.activeCount}명`, icon: UserCheck, iconColor: 'text-green-500', }, { label: '휴직', value: `${stats.leaveCount}명`, icon: Clock, iconColor: 'text-yellow-500', }, { label: '퇴직', value: `${stats.resignedCount}명`, icon: UserX, iconColor: 'text-gray-500', }, { label: '평균근속년수', value: `${stats.averageTenure.toFixed(1)}년`, icon: Calendar, iconColor: 'text-blue-500', }, ], [stats]); // 탭 옵션 const tabs: TabOption[] = useMemo(() => [ { value: 'all', label: '전체', count: employees.length, color: 'gray' }, { value: 'active', label: '재직', count: stats.activeCount, color: 'green' }, { value: 'leave', label: '휴직', count: stats.leaveCount, color: 'yellow' }, { value: 'resigned', label: '퇴직', count: stats.resignedCount, color: 'gray' }, ], [employees.length, stats]); // 테이블 컬럼 정의 const tableColumns = useMemo(() => [ { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, { key: 'employeeCode', label: '사원코드', className: 'min-w-[100px]', sortable: true }, { key: 'department', label: '부서', className: 'min-w-[100px]', sortable: true }, { key: 'position', label: '직책', className: 'min-w-[100px]', sortable: true }, { key: 'name', label: '이름', className: 'min-w-[80px]', sortable: true }, { key: 'rank', label: '직급', className: 'min-w-[80px]', sortable: true }, { key: 'phone', label: '휴대폰', className: 'min-w-[120px]', sortable: true }, { key: 'email', label: '이메일', className: 'min-w-[150px]', sortable: true }, { key: 'hireDate', label: '입사일', className: 'min-w-[100px]', sortable: true }, { key: 'status', label: '상태', className: 'min-w-[80px]', sortable: true }, { key: 'userId', label: '사용자아이디', className: 'min-w-[100px]', sortable: true }, { key: 'userRole', label: '권한', className: 'min-w-[80px]', sortable: true }, { key: 'actions', label: '작업', className: 'w-[100px] text-right' }, ], []); // 체크박스 토글 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(() => { if (selectedItems.size === paginatedData.length && paginatedData.length > 0) { setSelectedItems(new Set()); } else { const allIds = new Set(paginatedData.map((item) => item.id)); setSelectedItems(allIds); } }, [selectedItems.size, paginatedData]); // 일괄 삭제 핸들러 const handleBulkDelete = useCallback(async () => { const ids = Array.from(selectedItems); if (ids.length === 0) return; setIsDeleting(true); try { const result = await deleteEmployees(ids); if (result.success) { setEmployees(prev => prev.filter(emp => !ids.includes(emp.id))); setSelectedItems(new Set()); } else { console.error('Bulk delete failed:', result.error); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Bulk delete error:', error); } finally { setIsDeleting(false); } }, [selectedItems]); // 핸들러 const handleAddEmployee = useCallback(() => { router.push('/ko/hr/employee-management?mode=new'); }, [router]); const handleCSVUpload = useCallback(() => { router.push('/ko/hr/employee-management/csv-upload'); }, [router]); const handleDeleteEmployee = useCallback(async () => { if (!employeeToDelete) return; setIsDeleting(true); try { const result = await deleteEmployee(employeeToDelete.id); if (result.success) { setEmployees(prev => prev.filter(emp => emp.id !== employeeToDelete.id)); setDeleteDialogOpen(false); setEmployeeToDelete(null); } else { console.error('Delete failed:', result.error); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Delete error:', error); } finally { setIsDeleting(false); } }, [employeeToDelete]); const handleRowClick = useCallback((row: Employee) => { router.push(`/ko/hr/employee-management/${row.id}?mode=view`); }, [router]); const handleEdit = useCallback((id: string) => { router.push(`/ko/hr/employee-management/${id}?mode=edit`); }, [router]); const openDeleteDialog = useCallback((employee: Employee) => { setEmployeeToDelete(employee); setDeleteDialogOpen(true); }, []); // ===== 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; } setCurrentPage(1); }, []); const handleFilterReset = useCallback(() => { setFilterOption('all'); setSortOption('rank'); setCurrentPage(1); }, []); // ===== UniversalListPage 설정 ===== const employeeConfig: UniversalListConfig = useMemo(() => ({ title: '사원관리', description: '사원 정보를 관리합니다', icon: Users, basePath: '/hr/employee-management', idField: 'id', actions: { getList: async () => ({ success: true, data: employees, totalCount: employees.length, }), deleteBulk: async (ids) => { setIsDeleting(true); try { const result = await deleteEmployees(ids); if (result.success) { setEmployees(prev => prev.filter(emp => !ids.includes(emp.id))); setSelectedItems(new Set()); } return result; } finally { setIsDeleting(false); } }, }, columns: tableColumns, tabs: tabs, defaultTab: activeTab, filterConfig: filterConfig, initialFilters: filterValues, filterTitle: '사원 필터', computeStats: () => statCards, // 검색창 (공통 컴포넌트에서 자동 생성) hideSearch: true, searchValue: searchValue, onSearchChange: setSearchValue, dateRangeSelector: { enabled: true, showPresets: true, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, dateField: 'hireDate', // 입사일 기준 자동 필터링 }, createButton: { label: '사원 등록', icon: Plus, onClick: handleAddEmployee, }, searchPlaceholder: '이름, 사원코드, 이메일 검색...', extraFilters: (
), itemsPerPage: itemsPerPage, clientSideFiltering: true, searchFilter: (item, searchValue) => { const search = searchValue.toLowerCase(); return ( item.name.toLowerCase().includes(search) || (item.employeeCode?.toLowerCase().includes(search) ?? false) || (item.email?.toLowerCase().includes(search) ?? false) ); }, tabFilter: (item, activeTab) => { if (activeTab === 'all') return true; return item.status === activeTab; }, customFilterFn: (items, filterValues) => { if (!items || items.length === 0) return items; let filtered = items; const filterOption = filterValues.filter as FilterOption; if (filterOption && filterOption !== 'all') { switch (filterOption) { case 'hasUserId': filtered = filtered.filter(e => e.userInfo?.userId); break; case 'noUserId': filtered = filtered.filter(e => !e.userInfo?.userId); break; case 'active': filtered = filtered.filter(e => e.status === 'active'); break; case 'leave': filtered = filtered.filter(e => e.status === 'leave'); break; case 'resigned': filtered = filtered.filter(e => e.status === 'resigned'); break; } } return filtered; }, customSortFn: (items, filterValues) => { const sortOption = filterValues.sort as SortOption || 'rank'; const rankOrder: Record = { '회장': 1, '사장': 2, '부사장': 3, '전무': 4, '상무': 5, '이사': 6, '부장': 7, '차장': 8, '과장': 9, '대리': 10, '주임': 11, '사원': 12 }; return [...items].sort((a, b) => { switch (sortOption) { case 'rank': return (rankOrder[a.rank || ''] || 99) - (rankOrder[b.rank || ''] || 99); case 'hireDateDesc': return new Date(b.hireDate || 0).getTime() - new Date(a.hireDate || 0).getTime(); case 'hireDateAsc': return new Date(a.hireDate || 0).getTime() - new Date(b.hireDate || 0).getTime(); case 'departmentAsc': const deptA = a.departmentPositions?.[0]?.departmentName || ''; const deptB = b.departmentPositions?.[0]?.departmentName || ''; return deptA.localeCompare(deptB, 'ko'); case 'departmentDesc': const deptA2 = a.departmentPositions?.[0]?.departmentName || ''; const deptB2 = b.departmentPositions?.[0]?.departmentName || ''; return deptB2.localeCompare(deptA2, 'ko'); case 'nameAsc': return a.name.localeCompare(b.name, 'ko'); case 'nameDesc': return b.name.localeCompare(a.name, 'ko'); default: return 0; } }); }, renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle, onRowClick } = handlers; return ( handleRowClick(item)} > e.stopPropagation()}> {globalIndex} {item.employeeCode || '-'} {item.departmentPositions?.length > 0 ? item.departmentPositions.map(dp => dp.departmentName).join(', ') : '-'} {item.departmentPositions?.length > 0 ? item.departmentPositions.map(dp => dp.positionName).join(', ') : '-'} {item.name} {item.rank || '-'} {item.phone || '-'} {item.email || '-'} {item.hireDate ? new Date(item.hireDate).toLocaleDateString('ko-KR') : '-'} {EMPLOYEE_STATUS_LABELS[item.status]} {item.userInfo?.userId || '-'} {item.userInfo ? USER_ROLE_LABELS[item.userInfo.role] : '-'} e.stopPropagation()}> {isSelected && (
)}
); }, renderMobileCard: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( #{globalIndex} {item.employeeCode} } statusBadge={ {EMPLOYEE_STATUS_LABELS[item.status]} } isSelected={isSelected} onToggleSelection={onToggle} onCardClick={() => handleRowClick(item)} infoGrid={
0 ? item.departmentPositions.map(dp => dp.departmentName).join(', ') : '-'} /> 0 ? item.departmentPositions.map(dp => dp.positionName).join(', ') : '-'} /> {item.userInfo && ( <> )}
} actions={ isSelected ? (
) : undefined } /> ); }, renderDialogs: () => ( <> {/* 필드 설정 다이얼로그 */} {/* 사용자 초대 다이얼로그 */} { setUserInviteOpen(false); }} /> {/* 삭제 확인 다이얼로그 */} "{employeeToDelete?.name}" 사원을 삭제하시겠습니까?
삭제된 사원 정보는 복구할 수 없습니다. } loading={isDeleting} /> ), }), [ employees, tableColumns, tabs, activeTab, filterConfig, filterValues, statCards, startDate, endDate, handleAddEmployee, handleCSVUpload, handleRowClick, handleEdit, openDeleteDialog, fieldSettingsOpen, fieldSettings, userInviteOpen, deleteDialogOpen, employeeToDelete, isDeleting, handleDeleteEmployee, ]); return ( config={employeeConfig} initialData={employees} initialTotalCount={employees.length} externalIsLoading={isLoading} /> ); }