feat(WEB): 근태 설정 및 관리 시스템 개선
- AttendanceSettingsManagement: 근무시간/휴식시간 설정 API 연동 - AttendanceManagement: 출퇴근 기록 조회/수정 기능 강화 - 근태 상태 필터링 및 검색 기능 개선 - 근태 actions 공통 로직 정리
This commit is contained in:
@@ -48,6 +48,7 @@ export default function MobileAttendancePage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('main');
|
||||
const [checkInTime, setCheckInTime] = useState<string>('');
|
||||
const [checkOutTime, setCheckOutTime] = useState<string>('');
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [userName, setUserName] = useState(TEST_USER.name);
|
||||
const [userDepartment, setUserDepartment] = useState(TEST_USER.department);
|
||||
const [userPosition, setUserPosition] = useState(TEST_USER.position);
|
||||
@@ -59,13 +60,13 @@ export default function MobileAttendancePage() {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 오늘의 근태 상태 조회
|
||||
// 오늘의 근태 상태 조회 (userId가 설정된 후에 조회)
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!mounted || userId === null) return;
|
||||
|
||||
const fetchTodayAttendance = async () => {
|
||||
try {
|
||||
const result = await getTodayAttendance();
|
||||
const result = await getTodayAttendance(userId);
|
||||
if (result.success && result.data) {
|
||||
// 이미 출근한 경우
|
||||
if (result.data.checkIn) {
|
||||
@@ -82,7 +83,7 @@ export default function MobileAttendancePage() {
|
||||
};
|
||||
|
||||
fetchTodayAttendance();
|
||||
}, [mounted]);
|
||||
}, [mounted, userId]);
|
||||
|
||||
// 현재 시간 업데이트 (마운트 후에만 실행)
|
||||
useEffect(() => {
|
||||
@@ -117,6 +118,7 @@ export default function MobileAttendancePage() {
|
||||
if (userDataStr) {
|
||||
try {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
if (userData.id) setUserId(userData.id);
|
||||
if (userData.name) setUserName(userData.name);
|
||||
if (userData.department) setUserDepartment(userData.department);
|
||||
if (userData.position) setUserPosition(userData.position);
|
||||
@@ -263,7 +265,9 @@ export default function MobileAttendancePage() {
|
||||
}
|
||||
|
||||
// 버튼 활성화 상태
|
||||
const canCheckIn = isInRange && attendanceStatus === 'not-checked-in' && !isProcessing;
|
||||
// - 출근: 아직 출근 안 했거나, 퇴근한 경우 (다시 출근 가능)
|
||||
// - 퇴근: 출근한 경우에만 가능
|
||||
const canCheckIn = isInRange && attendanceStatus !== 'checked-in' && !isProcessing;
|
||||
const canCheckOut = isInRange && attendanceStatus === 'checked-in' && !isProcessing;
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -155,10 +155,12 @@ export async function checkIn(
|
||||
*/
|
||||
export async function checkOut(
|
||||
data: CheckOutRequest
|
||||
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> {
|
||||
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/attendances/check-out`, {
|
||||
const headers = await getApiHeaders();
|
||||
const response = await fetch(`${process.env.API_URL}/v1/attendances/check-out`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
user_id: data.userId,
|
||||
check_out: data.checkOut,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,30 +65,21 @@ export async function getAttendanceSetting(): Promise<{
|
||||
success: boolean;
|
||||
data?: AttendanceSettingFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/attendance`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
if (error) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorData = await response?.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData?.message || `API 오류: ${response?.status}`,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,30 +107,21 @@ export async function updateAttendanceSetting(
|
||||
success: boolean;
|
||||
data?: AttendanceSettingFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/attendance`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(transformToApi(data)),
|
||||
}
|
||||
);
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
if (error) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(transformToApi(data)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorData = await response?.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData?.message || `API 오류: ${response?.status}`,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,12 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { MapPin, Save, Loader2 } from 'lucide-react';
|
||||
import { getAttendanceSetting, updateAttendanceSetting } from './actions';
|
||||
import {
|
||||
getAttendanceSetting,
|
||||
updateAttendanceSetting,
|
||||
getDepartments,
|
||||
type Department,
|
||||
} from './actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -37,38 +42,47 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import type { AttendanceSettings, AllowedRadius, Department } from './types';
|
||||
import {
|
||||
DEFAULT_ATTENDANCE_SETTINGS,
|
||||
ALLOWED_RADIUS_OPTIONS,
|
||||
MOCK_DEPARTMENTS,
|
||||
} from './types';
|
||||
import type { AttendanceSettings, AllowedRadius } from './types';
|
||||
import { DEFAULT_ATTENDANCE_SETTINGS, ALLOWED_RADIUS_OPTIONS } from './types';
|
||||
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||
|
||||
export function AttendanceSettingsManagement() {
|
||||
const [settings, setSettings] = useState<AttendanceSettings>(DEFAULT_ATTENDANCE_SETTINGS);
|
||||
const [departments] = useState<Department[]>(MOCK_DEPARTMENTS);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// API에서 설정 로드
|
||||
// API에서 설정 및 부서 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getAttendanceSetting();
|
||||
if (result.success && result.data) {
|
||||
// 설정과 부서를 병렬로 조회
|
||||
const [settingResult, deptResult] = await Promise.all([
|
||||
getAttendanceSetting(),
|
||||
getDepartments(),
|
||||
]);
|
||||
|
||||
// 설정 로드
|
||||
if (settingResult.success && settingResult.data) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
gpsEnabled: result.data!.useGps,
|
||||
allowedRadius: result.data!.allowedRadius as AllowedRadius,
|
||||
gpsEnabled: settingResult.data!.useGps,
|
||||
allowedRadius: settingResult.data!.allowedRadius as AllowedRadius,
|
||||
}));
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else if (settingResult.error) {
|
||||
toast.error(settingResult.error);
|
||||
}
|
||||
|
||||
// 부서 로드
|
||||
if (deptResult.success && deptResult.data) {
|
||||
setDepartments(deptResult.data);
|
||||
} else if (deptResult.error) {
|
||||
toast.error(deptResult.error);
|
||||
}
|
||||
} catch {
|
||||
toast.error('설정을 불러오는데 실패했습니다.');
|
||||
toast.error('데이터를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -30,26 +30,4 @@ export const DEFAULT_ATTENDANCE_SETTINGS: AttendanceSettings = {
|
||||
autoDepartments: [],
|
||||
};
|
||||
|
||||
// 부서 타입 (임시 - API 연동 시 교체)
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Mock 부서 데이터 (API 연동 전까지 사용)
|
||||
export const MOCK_DEPARTMENTS: Department[] = [
|
||||
{ id: '1', name: 'M사장님' },
|
||||
{ id: '2', name: '부사장님' },
|
||||
{ id: '3', name: '영업부' },
|
||||
{ id: '4', name: '개발부' },
|
||||
{ id: '5', name: '인사부' },
|
||||
{ id: '6', name: '경영지원부' },
|
||||
{ id: '7', name: '마케팅부' },
|
||||
{ id: '8', name: '재무부' },
|
||||
{ id: '9', name: '생산부' },
|
||||
{ id: '10', name: '품질관리부' },
|
||||
{ id: '11', name: '물류부' },
|
||||
{ id: '12', name: '고객지원부' },
|
||||
{ id: '13', name: '연구개발부' },
|
||||
{ id: '14', name: '기획부' },
|
||||
];
|
||||
// 부서 타입은 actions.ts에서 export됨 (API 연동)
|
||||
|
||||
Reference in New Issue
Block a user