- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선 - 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션 - 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등) - 미들웨어 토큰 갱신 로직 개선 - AuthenticatedLayout 구조 개선 - claudedocs 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
689 lines
22 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|