Files
sam-react-prod/src/components/hr/AttendanceManagement/actions.ts
유병철 cbb38d48b9 refactor(WEB): 전체 actions.ts에 공통 API 유틸 적용
- buildApiUrl / executePaginatedAction 패턴으로 전환 (40+ actions 파일)
- 직접 URLSearchParams 조립 → buildApiUrl 유틸 사용
- 수동 페이지네이션 메타 변환 → executePaginatedAction 자동 처리
- HandoverReportDocumentModal, OrderDocumentModal 개선
- 급여관리 SalaryManagement 코드 개선
- CLAUDE.md Server Action 공통 유틸 규칙 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:59:59 +09:00

346 lines
12 KiB
TypeScript

/**
* 근태관리 서버 액션
*
* API Endpoints:
* - GET /api/v1/attendances - 목록 조회
* - GET /api/v1/attendances/{id} - 상세 조회
* - POST /api/v1/attendances - 등록
* - PATCH /api/v1/attendances/{id} - 수정
* - DELETE /api/v1/attendances/{id} - 삭제
* - POST /api/v1/attendances/bulk-delete - 일괄 삭제
* - GET /api/v1/attendances/monthly-stats - 월간 통계
* - GET /api/v1/attendances/export - 엑셀 내보내기
*/
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { cookies } from 'next/headers';
import type {
AttendanceRecord,
AttendanceApiData,
AttendanceFormData,
AttendanceStats,
EmployeeOption,
} from './types';
// ============================================
// 헬퍼 함수
// ============================================
/**
* API 응답 데이터를 프론트엔드 형식으로 변환
*/
function transformApiToFrontend(apiData: AttendanceApiData): AttendanceRecord {
const jsonDetails = apiData.json_details || {};
// tenant_profiles 배열에서 첫 번째 프로필 추출 (API with 쿼리 결과)
const profile = apiData.user?.tenant_profiles?.[0];
const department = profile?.department;
// 기존 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),
employeeName: apiData.user?.name || '',
department: department?.name || legacyProfile?.department?.name || '',
position: profile?.position_key || legacyProfile?.position?.name || '',
rank: legacyProfile?.rank || '',
baseDate: apiData.base_date,
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,
workMinutes: jsonDetails.work_minutes || null,
reason: jsonDetails.reason || null,
status: apiData.status,
remarks: apiData.remarks || null,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
/**
* 프론트엔드 폼 데이터를 API 형식으로 변환
*/
function transformFrontendToApi(data: AttendanceFormData): Record<string, unknown> {
const jsonDetails: Record<string, unknown> = {};
// 출퇴근 시간 변환
if (data.checkInHour && data.checkInMinute) {
jsonDetails.check_in = `${data.checkInHour.padStart(2, '0')}:${data.checkInMinute.padStart(2, '0')}:00`;
}
if (data.checkOutHour && data.checkOutMinute) {
jsonDetails.check_out = `${data.checkOutHour.padStart(2, '0')}:${data.checkOutMinute.padStart(2, '0')}:00`;
}
// 연장근무 시간 변환 (분 단위)
const nightOvertimeMinutes =
(parseInt(data.nightOvertimeHours || '0', 10) * 60) +
parseInt(data.nightOvertimeMinutes || '0', 10);
const weekendOvertimeMinutes =
(parseInt(data.weekendOvertimeHours || '0', 10) * 60) +
parseInt(data.weekendOvertimeMinutes || '0', 10);
if (nightOvertimeMinutes > 0 || weekendOvertimeMinutes > 0) {
jsonDetails.overtime_minutes = nightOvertimeMinutes + weekendOvertimeMinutes;
}
return {
user_id: parseInt(data.employeeId, 10),
base_date: data.baseDate,
status: data.status || 'onTime',
json_details: jsonDetails,
remarks: data.remarks || null,
};
}
/**
* 분을 휴게시간 문자열로 변환 (예: 90 -> "1:30")
*/
function formatMinutesToBreakTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${mins.toString().padStart(2, '0')}`;
}
/**
* 분을 시간 문자열로 변환 (예: 210 -> "3시간 30분")
*/
function formatMinutesToHours(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0 && mins > 0) {
return `${hours}시간 ${mins}`;
} else if (hours > 0) {
return `${hours}시간`;
} else {
return `${mins}`;
}
}
// ============================================
// 사원 목록 조회 (근태 등록용)
// ============================================
interface EmployeeApiData {
id: number;
user_id: number;
name: string;
employee_code?: string;
status: string;
user?: {
id: number;
name: string;
};
tenant_user_profile?: {
department?: { id: number; name: string };
position?: { id: number; name: string };
rank?: string;
};
department?: { id: number; name: string };
position_key?: string;
}
export async function getEmployeesForAttendance(): Promise<EmployeeOption[]> {
const result = await executeServerAction<PaginatedApiResponse<EmployeeApiData>>({
url: buildApiUrl('/api/v1/employees', { per_page: 100, status: 'active' }),
errorMessage: '사원 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return [];
return result.data.data.map((emp) => ({
id: String(emp.user?.id || emp.user_id),
name: emp.user?.name || emp.name,
department: emp.department?.name || emp.tenant_user_profile?.department?.name || '',
position: emp.position_key || emp.tenant_user_profile?.position?.name || '',
rank: emp.tenant_user_profile?.rank || '',
}));
}
// ============================================
// 근태 API 함수
// ============================================
export async function getAttendances(params?: {
page?: number; per_page?: number; user_id?: string; date?: string;
date_from?: string; date_to?: string; status?: string;
department_id?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
}): Promise<{ data: AttendanceRecord[]; total: number; lastPage: number }> {
const result = await executeServerAction<PaginatedApiResponse<AttendanceApiData>>({
url: buildApiUrl('/api/v1/attendances', {
page: params?.page,
per_page: params?.per_page,
user_id: params?.user_id,
date: params?.date,
date_from: params?.date_from,
date_to: params?.date_to,
status: params?.status && params.status !== 'all' ? params.status : undefined,
department_id: params?.department_id,
sort_by: params?.sort_by,
sort_dir: params?.sort_dir,
}),
errorMessage: '근태 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
return {
data: result.data.data.map(transformApiToFrontend),
total: result.data.total,
lastPage: result.data.last_page,
};
}
export async function getAttendanceById(id: string): Promise<AttendanceRecord | null> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/attendances/${id}`),
transform: (data: AttendanceApiData) => transformApiToFrontend(data),
errorMessage: '근태 조회에 실패했습니다.',
});
return result.success ? result.data || null : null;
}
export async function createAttendance(
data: AttendanceFormData
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: buildApiUrl('/api/v1/attendances'),
method: 'POST',
body: apiData,
transform: (d: AttendanceApiData) => transformApiToFrontend(d),
errorMessage: '근태 등록에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
export async function updateAttendance(
id: string,
data: AttendanceFormData
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/attendances/${id}`),
method: 'PATCH',
body: apiData,
transform: (d: AttendanceApiData) => transformApiToFrontend(d),
errorMessage: '근태 수정에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
export async function deleteAttendance(id: string): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/attendances/${id}`),
method: 'DELETE',
errorMessage: '근태 삭제에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
export async function deleteAttendances(ids: string[]): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/attendances/bulk-delete'),
method: 'POST',
body: { ids: ids.map(id => parseInt(id, 10)) },
errorMessage: '근태 일괄 삭제에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
export async function getMonthlyStats(params: {
year: number; month: number; user_id?: string;
}): Promise<AttendanceStats | null> {
interface MonthlyStatsApiData {
year: number; month: number; total_days: number;
by_status: {
onTime: number; late: number; absent: number; vacation: number;
businessTrip: number; fieldWork: number; overtime: number; remote: number;
};
total_work_minutes: number; total_overtime_minutes: number;
}
const result = await executeServerAction<MonthlyStatsApiData>({
url: buildApiUrl('/api/v1/attendances/monthly-stats', {
year: params.year,
month: params.month,
user_id: params.user_id,
}),
errorMessage: '월간 통계 조회에 실패했습니다.',
});
if (!result.success || !result.data) return null;
return {
year: result.data.year,
month: result.data.month,
totalDays: result.data.total_days,
byStatus: result.data.by_status,
totalWorkMinutes: result.data.total_work_minutes,
totalOvertimeMinutes: result.data.total_overtime_minutes,
};
}
// ============================================
// 엑셀 내보내기 (native fetch - keep as-is)
// ============================================
export async function exportAttendanceExcel(params?: {
date_from?: string; date_to?: string; user_id?: string;
status?: string; department_id?: string;
}): Promise<{
success: boolean; data?: Blob; filename?: string; error?: string;
}> {
try {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
const headers: HeadersInit = {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.API_KEY || '',
};
const url = buildApiUrl('/api/v1/attendances/export', {
date_from: params?.date_from,
date_to: params?.date_to,
user_id: params?.user_id,
status: params?.status && params.status !== 'all' ? params.status : undefined,
department_id: params?.department_id,
});
const response = await fetch(url, { method: 'GET', headers });
if (!response.ok) {
return { success: false, error: `API 오류: ${response.status}` };
}
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
const filename = filenameMatch?.[1] || `근태현황_${params?.date_from || 'all'}.xlsx`;
return { success: true, data: blob, filename };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}