Files
sam-react-prod/src/components/attendance/actions.ts
byeongcheolryu 29e7b41615 chore(WEB): 다수 컴포넌트 개선 및 CEO 대시보드 추가
- CEO 대시보드 컴포넌트 추가
- AuthenticatedLayout 개선
- 각 모듈 actions.ts 에러 핸들링 개선
- API fetch-wrapper, refresh-token 로직 개선
- ReceivablesStatus 컴포넌트 업데이트
- globals.css 스타일 업데이트
- 기타 다수 컴포넌트 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 17:15:42 +09:00

263 lines
6.6 KiB
TypeScript

/**
* 근태관리 서버 액션
*
* API Endpoints:
* - POST /api/v1/attendances/check-in - 출근 체크인
* - POST /api/v1/attendances/check-out - 퇴근 체크아웃
* - GET /api/v1/attendances - 근태 목록 조회
*/
'use server';
import { isRedirectError } from 'next/dist/client/components/redirect';
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
// ============================================
// 타입 정의
// ============================================
/**
* GPS 데이터 타입
*/
export interface GpsData {
latitude: number;
longitude: number;
accuracy?: number;
}
/**
* 출근 체크인 요청 데이터
*/
export interface CheckInRequest {
userId?: number;
checkIn?: string; // HH:mm:ss format
gpsData?: GpsData;
}
/**
* 퇴근 체크아웃 요청 데이터
*/
export interface CheckOutRequest {
userId?: number;
checkOut?: string; // HH:mm:ss format
gpsData?: GpsData;
}
/**
* 근태 기록 응답 타입
*/
export interface AttendanceRecord {
id: number;
userId: number;
date: string;
checkIn: string | null;
checkOut: string | null;
status: string;
gpsData?: GpsData;
createdAt: string;
updatedAt: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
// ============================================
// 헬퍼 함수
// ============================================
/**
* API 응답에서 프론트엔드 형식으로 변환
*/
function transformApiToFrontend(apiData: Record<string, unknown>): AttendanceRecord {
return {
id: apiData.id as number,
userId: apiData.user_id as number,
date: apiData.date as string,
checkIn: apiData.check_in as string | null,
checkOut: apiData.check_out as string | null,
status: apiData.status as string,
gpsData: apiData.gps_data as GpsData | undefined,
createdAt: apiData.created_at as string,
updatedAt: apiData.updated_at as string,
};
}
// ============================================
// API 함수
// ============================================
/**
* 출근 체크인
* POST /v1/attendances/check-in
*/
export async function checkIn(
data: CheckInRequest
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-in`, {
method: 'POST',
body: JSON.stringify({
user_id: data.userId,
check_in: data.checkIn,
gps_data: data.gpsData
? {
latitude: data.gpsData.latitude,
longitude: data.gpsData.longitude,
accuracy: data.gpsData.accuracy,
}
: undefined,
}),
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '출근 기록에 실패했습니다.' };
}
const result: ApiResponse<Record<string, unknown>> = await response.json();
if (result.success && result.data) {
return {
success: true,
data: transformApiToFrontend(result.data),
};
}
return {
success: false,
error: result.message || '출근 기록에 실패했습니다.',
};
} catch (error) {
if (isRedirectError(error)) throw error;
console.error('[checkIn] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '출근 기록에 실패했습니다.',
};
}
}
/**
* 퇴근 체크아웃
* POST /v1/attendances/check-out
*/
export async function checkOut(
data: CheckOutRequest
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-out`, {
method: 'POST',
body: JSON.stringify({
user_id: data.userId,
check_out: data.checkOut,
gps_data: data.gpsData
? {
latitude: data.gpsData.latitude,
longitude: data.gpsData.longitude,
accuracy: data.gpsData.accuracy,
}
: undefined,
}),
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '퇴근 기록에 실패했습니다.' };
}
const result: ApiResponse<Record<string, unknown>> = await response.json();
if (result.success && result.data) {
return {
success: true,
data: transformApiToFrontend(result.data),
};
}
return {
success: false,
error: result.message || '퇴근 기록에 실패했습니다.',
};
} catch (err) {
if (isRedirectError(error)) throw error;
console.error('[checkOut] Error:', err);
return {
success: false,
error: err instanceof Error ? err.message : '퇴근 기록에 실패했습니다.',
};
}
}
/**
* 오늘 근태 상태 조회
* GET /v1/attendances?date=YYYY-MM-DD
*/
export async function getTodayAttendance(): Promise<{
success: boolean;
data?: AttendanceRecord;
error?: string;
__authError?: boolean;
}> {
try {
const today = new Date().toISOString().split('T')[0];
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances?date=${today}&per_page=1`,
{
method: 'GET',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '근태 조회에 실패했습니다.' };
}
const result: ApiResponse<PaginatedResponse<Record<string, unknown>>> = await response.json();
if (result.success && result.data) {
const items = result.data.data || [];
if (items.length > 0) {
return {
success: true,
data: transformApiToFrontend(items[0]),
};
}
// 오늘 기록이 없으면 undefined 반환
return { success: true, data: undefined };
}
return {
success: false,
error: result.message || '근태 조회에 실패했습니다.',
};
} catch (error) {
if (isRedirectError(error)) throw error;
console.error('[getTodayAttendance] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '근태 조회에 실패했습니다.',
};
}
}