'use client'; import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Clock, UserCheck, AlertCircle, Calendar, Download, Plus, FileText, Edit, } from 'lucide-react'; import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; import { format } from 'date-fns'; import { ko } from 'date-fns/locale'; import { UniversalListPage, type UniversalListConfig, type StatCard, type TabOption, type FilterFieldConfig, type FilterValues, } from '@/components/templates/UniversalListPage'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import { AttendanceInfoDialog } from './AttendanceInfoDialog'; import { ReasonInfoDialog } from './ReasonInfoDialog'; import { getAttendances, getEmployeesForAttendance, createAttendance, updateAttendance, } from './actions'; import type { AttendanceRecord, AttendanceStatus, SortOption, FilterOption, AttendanceFormData, ReasonFormData, EmployeeOption, } from './types'; import { ATTENDANCE_STATUS_LABELS, ATTENDANCE_STATUS_COLORS, SORT_OPTIONS, FILTER_OPTIONS, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; export function AttendanceManagement() { const router = useRouter(); // 근태 데이터 상태 const [attendanceRecords, setAttendanceRecords] = useState([]); const [employees, setEmployees] = useState([]); const [isLoading, setIsLoading] = useState(true); const [total, setTotal] = useState(0); // 검색 및 필터 상태 const [searchValue, setSearchValue] = useState(''); const [activeTab, setActiveTab] = useState('all'); const [filterOption, setFilterOption] = useState('all'); const [sortOption, setSortOption] = useState('dateDesc'); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; // 날짜 범위 상태 (기본값: 오늘) const today = new Date(); const todayStr = format(today, 'yyyy-MM-dd'); const [startDate, setStartDate] = useState(todayStr); const [endDate, setEndDate] = useState(todayStr); // 다이얼로그 상태 const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false); const [attendanceDialogMode, setAttendanceDialogMode] = useState<'create' | 'edit'>('create'); const [selectedAttendance, setSelectedAttendance] = useState(null); const [reasonDialogOpen, setReasonDialogOpen] = useState(false); const [selectedItems, setSelectedItems] = useState>(new Set()); const [isSaving, setIsSaving] = useState(false); // 데이터 로드 useEffect(() => { const fetchData = async () => { setIsLoading(true); try { // 사원 목록과 근태 목록 병렬 조회 const [employeesResult, attendancesResult] = await Promise.all([ getEmployeesForAttendance(), getAttendances({ per_page: 100, date_from: startDate, date_to: endDate, }), ]); setEmployees(employeesResult); setAttendanceRecords(attendancesResult.data); setTotal(attendancesResult.total); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[AttendanceManagement] fetchData error:', error); } finally { setIsLoading(false); } }; fetchData(); }, [startDate, endDate]); // 전체 직원 + 근태 기록 병합 (오늘 날짜 조회 시) // 근태 기록이 없는 직원은 'notYetIn' 상태로 표시 const mergedRecords = useMemo(() => { // 오늘 하루 조회 시에만 전체 직원 표시 const isSingleDay = startDate === endDate; if (!isSingleDay) { // 기간 조회 시에는 기존대로 근태 기록만 표시 return attendanceRecords; } // 근태 기록이 있는 직원 ID 집합 const employeesWithAttendance = new Set(attendanceRecords.map(r => r.employeeId)); // 근태 기록이 없는 직원들을 'notYetIn' 상태로 추가 const missingEmployees: AttendanceRecord[] = employees .filter(emp => !employeesWithAttendance.has(emp.id)) .map(emp => ({ id: `notyet-${emp.id}`, employeeId: emp.id, employeeName: emp.name, department: emp.department, position: emp.position, rank: emp.rank, baseDate: startDate, checkIn: null, checkOut: null, breakTime: null, overtimeHours: null, workMinutes: null, reason: null, status: 'notYetIn' as AttendanceStatus, remarks: null, createdAt: '', updatedAt: '', })); return [...attendanceRecords, ...missingEmployees]; }, [attendanceRecords, employees, startDate, endDate]); // 필터링된 데이터 const filteredRecords = useMemo(() => { let filtered = mergedRecords; // 탭(상태) 필터 if (activeTab !== 'all') { filtered = filtered.filter(r => r.status === activeTab); } // 상단 필터 드롭다운 (탭과 별개) if (filterOption !== 'all') { filtered = filtered.filter(r => r.status === filterOption); } // 검색 필터 if (searchValue) { const search = searchValue.toLowerCase(); filtered = filtered.filter(r => r.employeeName.toLowerCase().includes(search) || r.department.toLowerCase().includes(search) ); } // 정렬 filtered = [...filtered].sort((a, b) => { switch (sortOption) { case 'dateDesc': return new Date(b.baseDate).getTime() - new Date(a.baseDate).getTime(); case 'dateAsc': return new Date(a.baseDate).getTime() - new Date(b.baseDate).getTime(); case 'rank': return a.rank.localeCompare(b.rank, 'ko'); case 'deptAsc': return a.department.localeCompare(b.department, 'ko'); case 'deptDesc': return b.department.localeCompare(a.department, 'ko'); case 'nameAsc': return a.employeeName.localeCompare(b.employeeName, 'ko'); case 'nameDesc': return b.employeeName.localeCompare(a.employeeName, 'ko'); default: return 0; } }); return filtered; }, [mergedRecords, activeTab, filterOption, searchValue, sortOption]); // 페이지네이션된 데이터 const paginatedData = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; return filteredRecords.slice(startIndex, startIndex + itemsPerPage); }, [filteredRecords, currentPage, itemsPerPage]); // 통계 계산 (mergedRecords 기반) const stats = useMemo(() => { const notYetInCount = mergedRecords.filter(r => r.status === 'notYetIn').length; const onTimeCount = mergedRecords.filter(r => r.status === 'onTime').length; const lateCount = mergedRecords.filter(r => r.status === 'late').length; const absentCount = mergedRecords.filter(r => r.status === 'absent').length; const vacationCount = mergedRecords.filter(r => r.status === 'vacation').length; return { notYetInCount, onTimeCount, lateCount, absentCount, vacationCount }; }, [mergedRecords]); // StatCards 데이터 const statCards: StatCard[] = useMemo(() => [ { label: '미출근', value: `${stats.notYetInCount}명`, icon: AlertCircle, iconColor: 'text-gray-500', }, { label: '정시 출근', value: `${stats.onTimeCount}명`, icon: UserCheck, iconColor: 'text-green-500', }, { label: '지각', value: `${stats.lateCount}명`, icon: Clock, iconColor: 'text-yellow-500', }, { label: '휴가', value: `${stats.vacationCount}명`, icon: Calendar, iconColor: 'text-blue-500', }, ], [stats]); // 탭 옵션 (mergedRecords 기반) const tabs: TabOption[] = useMemo(() => [ { value: 'all', label: '전체', count: mergedRecords.length, color: 'gray' }, { value: 'notYetIn', label: '미출근', count: stats.notYetInCount, color: 'gray' }, { value: 'onTime', label: '정시 출근', count: stats.onTimeCount, color: 'green' }, { value: 'late', label: '지각', count: stats.lateCount, color: 'yellow' }, { value: 'absent', label: '결근', count: stats.absentCount, color: 'red' }, { value: 'vacation', label: '휴가', count: stats.vacationCount, color: 'blue' }, { value: 'businessTrip', label: '출장', count: mergedRecords.filter(r => r.status === 'businessTrip').length, color: 'purple' }, { value: 'fieldWork', label: '외근', count: mergedRecords.filter(r => r.status === 'fieldWork').length, color: 'orange' }, { value: 'overtime', label: '연장근무', count: mergedRecords.filter(r => r.status === 'overtime').length, color: 'indigo' }, ], [mergedRecords.length, stats]); // 테이블 컬럼 정의 const tableColumns = useMemo(() => [ { key: 'no', label: '번호', className: 'w-[60px] text-center' }, { key: 'department', label: '부서', className: 'min-w-[80px]' }, { key: 'position', label: '직책', className: 'min-w-[100px]' }, { key: 'name', label: '이름', className: 'min-w-[60px]' }, { key: 'rank', label: '직급', className: 'min-w-[60px]' }, { key: 'baseDate', label: '기준일', className: 'min-w-[100px]' }, { key: 'checkIn', label: '출근', className: 'min-w-[60px]' }, { key: 'checkOut', label: '퇴근', className: 'min-w-[60px]' }, { key: 'breakTime', label: '휴게', className: 'min-w-[60px]' }, { key: 'overtime', label: '연장근무', className: 'min-w-[80px]' }, { key: 'reason', label: '사유', className: 'min-w-[80px]' }, { key: 'actions', label: '작업', className: 'w-[60px] text-center' }, ], []); // 체크박스 토글 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 handleAddAttendance = useCallback(() => { setAttendanceDialogMode('create'); setSelectedAttendance(null); setAttendanceDialogOpen(true); }, []); const handleAddReason = useCallback(() => { setReasonDialogOpen(true); }, []); const handleEditAttendance = useCallback((record: AttendanceRecord) => { setAttendanceDialogMode('edit'); setSelectedAttendance(record); setAttendanceDialogOpen(true); }, []); const handleSaveAttendance = useCallback(async (data: AttendanceFormData) => { setIsSaving(true); try { if (attendanceDialogMode === 'create') { const result = await createAttendance(data); if (result.success && result.data) { setAttendanceRecords(prev => [result.data!, ...prev]); } else { console.error('Create failed:', result.error); } } else if (selectedAttendance) { const result = await updateAttendance(selectedAttendance.id, data); if (result.success && result.data) { setAttendanceRecords(prev => prev.map(r => r.id === selectedAttendance.id ? result.data! : r) ); } else { console.error('Update failed:', result.error); } } setAttendanceDialogOpen(false); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Save attendance error:', error); } finally { setIsSaving(false); } }, [attendanceDialogMode, selectedAttendance]); const handleSubmitReason = useCallback((data: ReasonFormData) => { console.log('Submit reason:', data); // 문서 작성 화면으로 이동 router.push(`/ko/hr/documents/new?type=${data.reasonType}`); }, [router]); const handleExcelDownload = useCallback(() => { console.log('Excel download'); // TODO: 엑셀 다운로드 기능 구현 }, []); const handleReasonClick = useCallback((record: AttendanceRecord) => { if (record.reason?.documentId) { router.push(`/ko/hr/documents/${record.reason.documentId}`); } }, [router]); // ===== 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('dateDesc'); setCurrentPage(1); }, []); // ===== UniversalListPage 설정 ===== const attendanceConfig: UniversalListConfig = useMemo(() => ({ title: '근태관리', description: '직원 출퇴근 및 근태 정보를 관리합니다', icon: Clock, basePath: '/hr/attendance-management', idField: 'id', actions: { getList: async () => ({ success: true, data: mergedRecords, totalCount: mergedRecords.length, }), }, columns: tableColumns, tabs: tabs, defaultTab: activeTab, filterConfig: filterConfig, initialFilters: filterValues, filterTitle: '근태 필터', computeStats: () => statCards, dateRangeSelector: { enabled: true, showPresets: true, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, createButton: { label: '근태 등록', icon: Plus, onClick: handleAddAttendance, }, searchPlaceholder: '이름, 부서 검색...', extraFilters: (
), itemsPerPage: itemsPerPage, clientSideFiltering: true, searchFilter: (item, searchValue) => { const search = searchValue.toLowerCase(); return ( item.employeeName.toLowerCase().includes(search) || item.department.toLowerCase().includes(search) ); }, tabFilter: (item, activeTab) => { if (activeTab === 'all') return true; return item.status === activeTab; }, customFilterFn: (items, filterValues) => { let filtered = items; const filterOption = filterValues.filter as string; if (filterOption && filterOption !== 'all') { filtered = filtered.filter(r => r.status === filterOption); } return filtered; }, customSortFn: (items, filterValues) => { const sortOption = filterValues.sort as SortOption || 'dateDesc'; return [...items].sort((a, b) => { switch (sortOption) { case 'dateDesc': return new Date(b.baseDate).getTime() - new Date(a.baseDate).getTime(); case 'dateAsc': return new Date(a.baseDate).getTime() - new Date(b.baseDate).getTime(); case 'rank': return a.rank.localeCompare(b.rank, 'ko'); case 'deptAsc': return a.department.localeCompare(b.department, 'ko'); case 'deptDesc': return b.department.localeCompare(a.department, 'ko'); case 'nameAsc': return a.employeeName.localeCompare(b.employeeName, 'ko'); case 'nameDesc': return b.employeeName.localeCompare(a.employeeName, 'ko'); default: return 0; } }); }, renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( e.stopPropagation()}> {globalIndex} {item.department || '-'} {item.position || '-'} {item.employeeName} {item.rank || '-'} {item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd (E)', { locale: ko }) : '-'} {item.checkIn ? item.checkIn.substring(0, 5) : '-'} {item.checkOut ? item.checkOut.substring(0, 5) : '-'} {item.breakTime || '-'} {item.overtimeHours || '-'} {item.reason ? ( ) : '-'} {isSelected && ( )} ); }, renderMobileCard: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( {item.department || '-'} {item.rank || '-'} } statusBadge={ {ATTENDANCE_STATUS_LABELS[item.status]} } isSelected={isSelected} onToggleSelection={onToggle} infoGrid={
{item.reason && ( )}
} actions={ isSelected ? (
) : undefined } /> ); }, renderDialogs: () => ( <> {/* 근태 정보 다이얼로그 */} {/* 사유 정보 다이얼로그 */} ), }), [ mergedRecords, tableColumns, tabs, activeTab, filterConfig, filterValues, statCards, startDate, endDate, handleAddAttendance, handleExcelDownload, handleAddReason, handleReasonClick, handleEditAttendance, attendanceDialogOpen, attendanceDialogMode, selectedAttendance, employees, handleSaveAttendance, reasonDialogOpen, handleSubmitReason, ]); // 로딩 상태 if (isLoading) { return ; } return ( config={attendanceConfig} initialData={mergedRecords} initialTotalCount={mergedRecords.length} /> ); }