Files
sam-react-prod/src/components/hr/AttendanceManagement/index.tsx
byeongcheolryu ad493bcea6 feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리
- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선
- 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션
- 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등)
- 미들웨어 토큰 갱신 로직 개선
- AuthenticatedLayout 구조 개선
- claudedocs 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:19:09 +09:00

689 lines
22 KiB
TypeScript

'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<AttendanceRecord[]>([]);
const [employees, setEmployees] = useState<EmployeeOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [total, setTotal] = useState(0);
// 검색 및 필터 상태
const [searchValue, setSearchValue] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const [filterOption, setFilterOption] = useState<FilterOption>('all');
const [sortOption, setSortOption] = useState<SortOption>('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<AttendanceRecord | null>(null);
const [reasonDialogOpen, setReasonDialogOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(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<AttendanceRecord> = 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: (
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleAddReason}>
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
),
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 (
<TableRow
key={item.id}
className="hover:bg-muted/50"
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{item.department || '-'}</TableCell>
<TableCell>{item.position || '-'}</TableCell>
<TableCell>{item.employeeName}</TableCell>
<TableCell>{item.rank || '-'}</TableCell>
<TableCell>
{item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd (E)', { locale: ko }) : '-'}
</TableCell>
<TableCell>{item.checkIn ? item.checkIn.substring(0, 5) : '-'}</TableCell>
<TableCell>{item.checkOut ? item.checkOut.substring(0, 5) : '-'}</TableCell>
<TableCell>{item.breakTime || '-'}</TableCell>
<TableCell>{item.overtimeHours || '-'}</TableCell>
<TableCell>
{item.reason ? (
<Button
variant="link"
size="sm"
className="p-0 h-auto text-blue-600 hover:text-blue-800"
onClick={() => handleReasonClick(item)}
>
{item.reason.label}
</Button>
) : '-'}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditAttendance(item)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
renderMobileCard: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
id={item.id}
title={item.employeeName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">
{item.department || '-'}
</Badge>
<Badge variant="outline" className="text-xs">
{item.rank || '-'}
</Badge>
</div>
}
statusBadge={
<Badge className={ATTENDANCE_STATUS_COLORS[item.status]}>
{ATTENDANCE_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="직책" value={item.position || '-'} />
<InfoField
label="기준일"
value={item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd') : '-'}
/>
<InfoField label="출근" value={item.checkIn ? item.checkIn.substring(0, 5) : '-'} />
<InfoField label="퇴근" value={item.checkOut ? item.checkOut.substring(0, 5) : '-'} />
<InfoField label="휴게" value={item.breakTime || '-'} />
<InfoField label="연장근무" value={item.overtimeHours || '-'} />
{item.reason && (
<InfoField label="사유" value={item.reason.label} />
)}
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEditAttendance(item); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},
renderDialogs: () => (
<>
{/* 근태 정보 다이얼로그 */}
<AttendanceInfoDialog
open={attendanceDialogOpen}
onOpenChange={setAttendanceDialogOpen}
mode={attendanceDialogMode}
attendance={selectedAttendance}
employees={employees}
onSave={handleSaveAttendance}
/>
{/* 사유 정보 다이얼로그 */}
<ReasonInfoDialog
open={reasonDialogOpen}
onOpenChange={setReasonDialogOpen}
employees={employees}
onSubmit={handleSubmitReason}
/>
</>
),
}), [
mergedRecords,
tableColumns,
tabs,
activeTab,
filterConfig,
filterValues,
statCards,
startDate,
endDate,
handleAddAttendance,
handleExcelDownload,
handleAddReason,
handleReasonClick,
handleEditAttendance,
attendanceDialogOpen,
attendanceDialogMode,
selectedAttendance,
employees,
handleSaveAttendance,
reasonDialogOpen,
handleSubmitReason,
]);
// 로딩 상태
if (isLoading) {
return <ContentLoadingSpinner text="근태 정보를 불러오는 중..." />;
}
return (
<UniversalListPage<AttendanceRecord>
config={attendanceConfig}
initialData={mergedRecords}
initialTotalCount={mergedRecords.length}
/>
);
}