feat(WEB): 근태 설정 및 관리 시스템 개선

- AttendanceSettingsManagement: 근무시간/휴식시간 설정 API 연동
- AttendanceManagement: 출퇴근 기록 조회/수정 기능 강화
- 근태 상태 필터링 및 검색 기능 개선
- 근태 actions 공통 로직 정리
This commit is contained in:
2025-12-30 17:20:04 +09:00
parent a45ff9af28
commit 2443c0dc63
8 changed files with 150 additions and 113 deletions

View File

@@ -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,

View File

@@ -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(() => [

View File

@@ -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;