From 2443c0dc6374dbdb10382befb1ce627f69b2093f Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 30 Dec 2025 17:20:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B7=BC=ED=83=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AttendanceSettingsManagement: 근무시간/휴식시간 설정 API 연동 - AttendanceManagement: 출퇴근 기록 조회/수정 기능 강화 - 근태 상태 필터링 및 검색 기능 개선 - 근태 actions 공통 로직 정리 --- .../(protected)/hr/attendance/page.tsx | 14 ++- src/components/attendance/actions.ts | 8 +- .../hr/AttendanceManagement/actions.ts | 13 ++- .../hr/AttendanceManagement/index.tsx | 92 ++++++++++++++----- .../hr/AttendanceManagement/types.ts | 8 ++ .../AttendanceSettingsManagement/actions.ts | 58 ++++-------- .../AttendanceSettingsManagement/index.tsx | 46 ++++++---- .../AttendanceSettingsManagement/types.ts | 24 +---- 8 files changed, 150 insertions(+), 113 deletions(-) diff --git a/src/app/[locale]/(protected)/hr/attendance/page.tsx b/src/app/[locale]/(protected)/hr/attendance/page.tsx index 54afb623..d2039dce 100644 --- a/src/app/[locale]/(protected)/hr/attendance/page.tsx +++ b/src/app/[locale]/(protected)/hr/attendance/page.tsx @@ -48,6 +48,7 @@ export default function MobileAttendancePage() { const [viewMode, setViewMode] = useState('main'); const [checkInTime, setCheckInTime] = useState(''); const [checkOutTime, setCheckOutTime] = useState(''); + const [userId, setUserId] = useState(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 ( diff --git a/src/components/attendance/actions.ts b/src/components/attendance/actions.ts index b5f25d89..2055cbe5 100644 --- a/src/components/attendance/actions.ts +++ b/src/components/attendance/actions.ts @@ -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, diff --git a/src/components/hr/AttendanceManagement/actions.ts b/src/components/hr/AttendanceManagement/actions.ts index 5ae3c2ef..f7df4358 100644 --- a/src/components/hr/AttendanceManagement/actions.ts +++ b/src/components/hr/AttendanceManagement/actions.ts @@ -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, diff --git a/src/components/hr/AttendanceManagement/index.tsx b/src/components/hr/AttendanceManagement/index.tsx index 67dc758c..40c0ac0c 100644 --- a/src/components/hr/AttendanceManagement/index.tsx +++ b/src/components/hr/AttendanceManagement/index.tsx @@ -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(() => [ diff --git a/src/components/hr/AttendanceManagement/types.ts b/src/components/hr/AttendanceManagement/types.ts index 54057191..318af193 100644 --- a/src/components/hr/AttendanceManagement/types.ts +++ b/src/components/hr/AttendanceManagement/types.ts @@ -7,6 +7,7 @@ // ============================================ export type AttendanceStatus = + | 'notYetIn' | 'onTime' | 'late' | 'absent' @@ -18,6 +19,7 @@ export type AttendanceStatus = // 근태 상태 라벨 export const ATTENDANCE_STATUS_LABELS: Record = { + notYetIn: '미출근', onTime: '정시 출근', late: '지각', absent: '결근', @@ -30,6 +32,7 @@ export const ATTENDANCE_STATUS_LABELS: Record = { // 근태 상태 색상 export const ATTENDANCE_STATUS_COLORS: Record = { + 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; diff --git a/src/components/settings/AttendanceSettingsManagement/actions.ts b/src/components/settings/AttendanceSettingsManagement/actions.ts index fb8a1f38..3ef45697 100644 --- a/src/components/settings/AttendanceSettingsManagement/actions.ts +++ b/src/components/settings/AttendanceSettingsManagement/actions.ts @@ -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}`, }; } diff --git a/src/components/settings/AttendanceSettingsManagement/index.tsx b/src/components/settings/AttendanceSettingsManagement/index.tsx index e9e648e6..1b99fc01 100644 --- a/src/components/settings/AttendanceSettingsManagement/index.tsx +++ b/src/components/settings/AttendanceSettingsManagement/index.tsx @@ -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(DEFAULT_ATTENDANCE_SETTINGS); - const [departments] = useState(MOCK_DEPARTMENTS); + const [departments, setDepartments] = useState([]); // 로딩 상태 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); } diff --git a/src/components/settings/AttendanceSettingsManagement/types.ts b/src/components/settings/AttendanceSettingsManagement/types.ts index d1460f7f..583009e1 100644 --- a/src/components/settings/AttendanceSettingsManagement/types.ts +++ b/src/components/settings/AttendanceSettingsManagement/types.ts @@ -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 연동)