- buildApiUrl / executePaginatedAction 패턴으로 전환 (40+ actions 파일) - 직접 URLSearchParams 조립 → buildApiUrl 유틸 사용 - 수동 페이지네이션 메타 변환 → executePaginatedAction 자동 처리 - HandoverReportDocumentModal, OrderDocumentModal 개선 - 급여관리 SalaryManagement 코드 개선 - CLAUDE.md Server Action 공통 유틸 규칙 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
12 KiB
TypeScript
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: '서버 오류가 발생했습니다.' };
|
|
}
|
|
}
|