feat(WEB): 근태 설정 및 관리 시스템 개선
- AttendanceSettingsManagement: 근무시간/휴식시간 설정 API 연동 - AttendanceManagement: 출퇴근 기록 조회/수정 기능 강화 - 근태 상태 필터링 및 검색 기능 개선 - 근태 actions 공통 로직 정리
This commit is contained in:
@@ -60,6 +60,11 @@ function transformApiToFrontend(apiData: AttendanceApiData): AttendanceRecord {
|
||||
// 기존 tenant_profile 호환성 유지
|
||||
const legacyProfile = apiData.user?.tenant_profile;
|
||||
|
||||
// check_in/check_out: Model $appends로 최상위 레벨에서 먼저 읽고, 없으면 json_details에서 읽음
|
||||
// Model accessor가 check_ins[] 배열에서 가장 빠른 시간, check_outs[]에서 가장 늦은 시간 반환
|
||||
const checkIn = apiData.check_in ?? jsonDetails.check_in ?? null;
|
||||
const checkOut = apiData.check_out ?? jsonDetails.check_out ?? null;
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
employeeId: String(apiData.user_id),
|
||||
@@ -68,9 +73,11 @@ function transformApiToFrontend(apiData: AttendanceApiData): AttendanceRecord {
|
||||
position: profile?.position_key || legacyProfile?.position?.name || '',
|
||||
rank: legacyProfile?.rank || '',
|
||||
baseDate: apiData.base_date,
|
||||
checkIn: jsonDetails.check_in || null,
|
||||
checkOut: jsonDetails.check_out || null,
|
||||
breakTime: jsonDetails.break_time || null,
|
||||
checkIn,
|
||||
checkOut, // break_minutes: Model $appends로 최상위에서 먼저 읽고, 분 단위를 문자열로 변환
|
||||
breakTime: apiData.break_minutes != null
|
||||
? formatMinutesToBreakTime(apiData.break_minutes)
|
||||
: (jsonDetails.break_time || null),
|
||||
overtimeHours: jsonDetails.overtime_minutes
|
||||
? formatMinutesToHours(jsonDetails.overtime_minutes)
|
||||
: null,
|
||||
|
||||
@@ -75,11 +75,11 @@ export function AttendanceManagement() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
// 날짜 범위 상태 (기본값: 오늘)
|
||||
const today = new Date();
|
||||
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [startDate, setStartDate] = useState(format(firstDayOfMonth, 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(format(today, 'yyyy-MM-dd'));
|
||||
const todayStr = format(today, 'yyyy-MM-dd');
|
||||
const [startDate, setStartDate] = useState(todayStr);
|
||||
const [endDate, setEndDate] = useState(todayStr);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
|
||||
@@ -116,9 +116,49 @@ export function AttendanceManagement() {
|
||||
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 = attendanceRecords;
|
||||
let filtered = mergedRecords;
|
||||
|
||||
// 탭(상태) 필터
|
||||
if (activeTab !== 'all') {
|
||||
@@ -162,7 +202,7 @@ export function AttendanceManagement() {
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [attendanceRecords, activeTab, filterOption, searchValue, sortOption]);
|
||||
}, [mergedRecords, activeTab, filterOption, searchValue, sortOption]);
|
||||
|
||||
// 페이지네이션된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
@@ -170,18 +210,25 @@ export function AttendanceManagement() {
|
||||
return filteredRecords.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredRecords, currentPage, itemsPerPage]);
|
||||
|
||||
// 통계 계산
|
||||
// 통계 계산 (mergedRecords 기반)
|
||||
const stats = useMemo(() => {
|
||||
const onTimeCount = attendanceRecords.filter(r => r.status === 'onTime').length;
|
||||
const lateCount = attendanceRecords.filter(r => r.status === 'late').length;
|
||||
const absentCount = attendanceRecords.filter(r => r.status === 'absent').length;
|
||||
const vacationCount = attendanceRecords.filter(r => r.status === 'vacation').length;
|
||||
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 { onTimeCount, lateCount, absentCount, vacationCount };
|
||||
}, [attendanceRecords]);
|
||||
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}명`,
|
||||
@@ -194,12 +241,6 @@ export function AttendanceManagement() {
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-500',
|
||||
},
|
||||
{
|
||||
label: '결근',
|
||||
value: `${stats.absentCount}명`,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: '휴가',
|
||||
value: `${stats.vacationCount}명`,
|
||||
@@ -208,17 +249,18 @@ export function AttendanceManagement() {
|
||||
},
|
||||
], [stats]);
|
||||
|
||||
// 탭 옵션
|
||||
// 탭 옵션 (mergedRecords 기반)
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: '전체', count: attendanceRecords.length, color: 'gray' },
|
||||
{ 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: attendanceRecords.filter(r => r.status === 'businessTrip').length, color: 'purple' },
|
||||
{ value: 'fieldWork', label: '외근', count: attendanceRecords.filter(r => r.status === 'fieldWork').length, color: 'orange' },
|
||||
{ value: 'overtime', label: '연장근무', count: attendanceRecords.filter(r => r.status === 'overtime').length, color: 'indigo' },
|
||||
], [attendanceRecords.length, stats]);
|
||||
{ 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: TableColumn[] = useMemo(() => [
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// ============================================
|
||||
|
||||
export type AttendanceStatus =
|
||||
| 'notYetIn'
|
||||
| 'onTime'
|
||||
| 'late'
|
||||
| 'absent'
|
||||
@@ -18,6 +19,7 @@ export type AttendanceStatus =
|
||||
|
||||
// 근태 상태 라벨
|
||||
export const ATTENDANCE_STATUS_LABELS: Record<AttendanceStatus, string> = {
|
||||
notYetIn: '미출근',
|
||||
onTime: '정시 출근',
|
||||
late: '지각',
|
||||
absent: '결근',
|
||||
@@ -30,6 +32,7 @@ export const ATTENDANCE_STATUS_LABELS: Record<AttendanceStatus, string> = {
|
||||
|
||||
// 근태 상태 색상
|
||||
export const ATTENDANCE_STATUS_COLORS: Record<AttendanceStatus, string> = {
|
||||
notYetIn: 'bg-gray-100 text-gray-700',
|
||||
onTime: 'bg-green-100 text-green-700',
|
||||
late: 'bg-yellow-100 text-yellow-700',
|
||||
absent: 'bg-red-100 text-red-700',
|
||||
@@ -48,6 +51,7 @@ export type FilterOption = 'all' | AttendanceStatus;
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'notYetIn', label: '미출근' },
|
||||
{ value: 'onTime', label: '정시 출근' },
|
||||
{ value: 'late', label: '지각' },
|
||||
{ value: 'absent', label: '결근' },
|
||||
@@ -93,6 +97,10 @@ export interface AttendanceApiData {
|
||||
user_id: number;
|
||||
base_date: string;
|
||||
status: AttendanceStatus;
|
||||
// Model $appends로 인해 최상위 레벨에 추가되는 필드
|
||||
check_in?: string | null;
|
||||
check_out?: string | null;
|
||||
break_minutes?: number | null;
|
||||
json_details: {
|
||||
check_in?: string;
|
||||
check_out?: string;
|
||||
|
||||
Reference in New Issue
Block a user