- 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>
263 lines
6.6 KiB
TypeScript
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 : '근태 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
} |