- 전체 모듈 actions.ts redirect 에러 핸들링 추가 - CEODashboard DetailModal 추가 - MonthlyExpenseSection 개선 - fetch-wrapper redirect 에러 처리 - redirect-error 유틸 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1126 lines
32 KiB
TypeScript
1126 lines
32 KiB
TypeScript
/**
|
|
* 휴가관리 서버 액션
|
|
*
|
|
* API Endpoints:
|
|
* - GET /api/v1/leaves - 휴가 목록 조회
|
|
* - GET /api/v1/leaves/{id} - 휴가 상세 조회
|
|
* - POST /api/v1/leaves - 휴가 신청
|
|
* - PATCH /api/v1/leaves/{id} - 휴가 수정
|
|
* - DELETE /api/v1/leaves/{id} - 휴가 삭제
|
|
* - POST /api/v1/leaves/{id}/approve - 휴가 승인
|
|
* - POST /api/v1/leaves/{id}/reject - 휴가 반려
|
|
* - POST /api/v1/leaves/{id}/cancel - 휴가 취소
|
|
* - GET /api/v1/leaves/balances - 전체 직원 휴가 사용현황 조회
|
|
* - GET /api/v1/leaves/balance - 내 잔여 휴가 조회
|
|
* - GET /api/v1/leaves/balance/{userId} - 특정 사용자 잔여 휴가 조회
|
|
* - PUT /api/v1/leaves/balance - 잔여 휴가 설정
|
|
* - GET /api/v1/leaves/grants - 휴가 부여 이력 조회
|
|
* - POST /api/v1/leaves/grants - 휴가 부여
|
|
* - DELETE /api/v1/leaves/grants/{id} - 휴가 부여 삭제
|
|
*/
|
|
|
|
'use server';
|
|
|
|
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
|
|
|
// ============================================
|
|
// 타입 정의
|
|
// ============================================
|
|
|
|
/**
|
|
* 휴가 유형
|
|
*/
|
|
export type LeaveType = 'annual' | 'half_am' | 'half_pm' | 'sick' | 'family' | 'maternity' | 'parental';
|
|
|
|
/**
|
|
* 휴가 상태
|
|
*/
|
|
export type LeaveStatus = 'pending' | 'approved' | 'rejected' | 'cancelled';
|
|
|
|
/**
|
|
* 휴가 기록 응답 타입
|
|
*/
|
|
export interface LeaveRecord {
|
|
id: number;
|
|
userId: number;
|
|
leaveType: LeaveType;
|
|
startDate: string;
|
|
endDate: string;
|
|
days: number;
|
|
reason: string | null;
|
|
status: LeaveStatus;
|
|
approvedBy: number | null;
|
|
approvedAt: string | null;
|
|
rejectedReason: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
// 관계 데이터
|
|
user?: {
|
|
id: number;
|
|
name: string;
|
|
email: string;
|
|
};
|
|
// 사용자 프로필 (부서/직책/직급)
|
|
userProfile?: {
|
|
id: number;
|
|
department_id: number | null;
|
|
position_key: string | null;
|
|
job_title_key: string | null;
|
|
position_label: string | null;
|
|
job_title_label: string | null;
|
|
rank: string | null;
|
|
department?: {
|
|
id: number;
|
|
name: string;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* 휴가 목록 조회 파라미터
|
|
*/
|
|
export interface GetLeavesParams {
|
|
userId?: number;
|
|
status?: LeaveStatus;
|
|
leaveType?: LeaveType;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
year?: number;
|
|
departmentId?: number;
|
|
sortBy?: 'created_at' | 'start_date' | 'end_date' | 'days' | 'status';
|
|
sortDir?: 'asc' | 'desc';
|
|
perPage?: number;
|
|
page?: number;
|
|
}
|
|
|
|
/**
|
|
* 휴가 신청 요청 데이터
|
|
*/
|
|
export interface CreateLeaveRequest {
|
|
userId?: number;
|
|
leaveType: LeaveType;
|
|
startDate: string;
|
|
endDate: string;
|
|
days: number;
|
|
reason?: string;
|
|
}
|
|
|
|
/**
|
|
* 잔여 휴가 정보
|
|
*/
|
|
export interface LeaveBalance {
|
|
userId: number;
|
|
year: number;
|
|
totalDays: number;
|
|
usedDays: number;
|
|
remainingDays: number;
|
|
}
|
|
|
|
/**
|
|
* 잔여 휴가 설정 요청 데이터
|
|
*/
|
|
export interface SetLeaveBalanceRequest {
|
|
userId: number;
|
|
year: number;
|
|
totalDays: number;
|
|
}
|
|
|
|
/**
|
|
* 전체 직원 휴가 사용현황 조회 파라미터
|
|
*/
|
|
export interface GetLeaveBalancesParams {
|
|
year?: number;
|
|
departmentId?: number;
|
|
search?: string;
|
|
sortBy?: 'user_id' | 'department';
|
|
sortDir?: 'asc' | 'desc';
|
|
perPage?: number;
|
|
page?: number;
|
|
}
|
|
|
|
/**
|
|
* 전체 직원 휴가 사용현황 레코드
|
|
*/
|
|
export interface LeaveBalanceRecord {
|
|
id: number;
|
|
userId: number;
|
|
employeeName: string;
|
|
email: string;
|
|
department: string;
|
|
position: string; // position_label (부장, 과장 등)
|
|
jobTitle: string; // job_title_label (팀장, 팀원 등) → 직책으로 사용
|
|
rank: string | null; // json_extra.rank → 직급으로 사용
|
|
hireDate: string | null;
|
|
displayName: string | null;
|
|
totalDays: number;
|
|
usedDays: number;
|
|
remainingDays: number;
|
|
}
|
|
|
|
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 URL
|
|
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
|
|
|
|
/**
|
|
* API 응답에서 프론트엔드 형식으로 변환
|
|
*/
|
|
function transformApiToFrontend(apiData: Record<string, unknown>): LeaveRecord {
|
|
const user = apiData.user as Record<string, unknown> | undefined;
|
|
const userProfile = apiData.user_profile as Record<string, unknown> | undefined;
|
|
const department = userProfile?.department as Record<string, unknown> | undefined;
|
|
|
|
return {
|
|
id: apiData.id as number,
|
|
userId: apiData.user_id as number,
|
|
leaveType: apiData.leave_type as LeaveType,
|
|
startDate: apiData.start_date as string,
|
|
endDate: apiData.end_date as string,
|
|
days: apiData.days as number,
|
|
reason: apiData.reason as string | null,
|
|
status: apiData.status as LeaveStatus,
|
|
approvedBy: apiData.approved_by as number | null,
|
|
approvedAt: apiData.approved_at as string | null,
|
|
rejectedReason: apiData.rejected_reason as string | null,
|
|
createdAt: apiData.created_at as string,
|
|
updatedAt: apiData.updated_at as string,
|
|
user: user
|
|
? {
|
|
id: user.id as number,
|
|
name: user.name as string,
|
|
email: user.email as string,
|
|
}
|
|
: undefined,
|
|
userProfile: userProfile
|
|
? {
|
|
id: userProfile.id as number,
|
|
department_id: userProfile.department_id as number | null,
|
|
position_key: userProfile.position_key as string | null,
|
|
job_title_key: userProfile.job_title_key as string | null,
|
|
position_label: userProfile.position_label as string | null,
|
|
job_title_label: userProfile.job_title_label as string | null,
|
|
rank: userProfile.rank as string | null,
|
|
department: department
|
|
? { id: department.id as number, name: department.name as string }
|
|
: null,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 잔여 휴가 응답 변환
|
|
*/
|
|
function transformBalanceToFrontend(apiData: Record<string, unknown>): LeaveBalance {
|
|
return {
|
|
userId: apiData.user_id as number,
|
|
year: apiData.year as number,
|
|
totalDays: apiData.total_days as number,
|
|
usedDays: apiData.used_days as number,
|
|
remainingDays: apiData.remaining_days as number,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// API 함수
|
|
// ============================================
|
|
|
|
/**
|
|
* 휴가 목록 조회
|
|
* GET /v1/leaves
|
|
*/
|
|
export async function getLeaves(params?: GetLeavesParams): Promise<{
|
|
success: boolean;
|
|
data?: { items: LeaveRecord[]; total: number; currentPage: number; lastPage: number };
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
|
|
if (params) {
|
|
if (params.userId) searchParams.append('user_id', params.userId.toString());
|
|
if (params.status) searchParams.append('status', params.status);
|
|
if (params.leaveType) searchParams.append('leave_type', params.leaveType);
|
|
if (params.dateFrom) searchParams.append('date_from', params.dateFrom);
|
|
if (params.dateTo) searchParams.append('date_to', params.dateTo);
|
|
if (params.year) searchParams.append('year', params.year.toString());
|
|
if (params.departmentId) searchParams.append('department_id', params.departmentId.toString());
|
|
if (params.sortBy) searchParams.append('sort_by', params.sortBy);
|
|
if (params.sortDir) searchParams.append('sort_dir', params.sortDir);
|
|
if (params.perPage) searchParams.append('per_page', params.perPage.toString());
|
|
if (params.page) searchParams.append('page', params.page.toString());
|
|
}
|
|
|
|
const queryString = searchParams.toString();
|
|
const url = `${API_URL}/v1/leaves${queryString ? `?${queryString}` : ''}`;
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 목록 조회에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<PaginatedResponse<Record<string, unknown>>> = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: result.data.data.map(transformApiToFrontend),
|
|
total: result.data.total,
|
|
currentPage: result.data.current_page,
|
|
lastPage: result.data.last_page,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '휴가 목록 조회에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[getLeaves] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 목록 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 상세 조회
|
|
* GET /v1/leaves/{id}
|
|
*/
|
|
export async function getLeaveById(id: number): Promise<{
|
|
success: boolean;
|
|
data?: LeaveRecord;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}`, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 조회에 실패했습니다.' };
|
|
}
|
|
|
|
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 (isNextRedirectError(error)) throw error;
|
|
console.error('[getLeaveById] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 신청
|
|
* POST /v1/leaves
|
|
*/
|
|
export async function createLeave(
|
|
data: CreateLeaveRequest
|
|
): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
user_id: data.userId,
|
|
leave_type: data.leaveType,
|
|
start_date: data.startDate,
|
|
end_date: data.endDate,
|
|
days: data.days,
|
|
reason: data.reason,
|
|
}),
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 신청에 실패했습니다.' };
|
|
}
|
|
|
|
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 (isNextRedirectError(error)) throw error;
|
|
console.error('[createLeave] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 신청에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 승인
|
|
* POST /v1/leaves/{id}/approve
|
|
*/
|
|
export async function approveLeave(
|
|
id: number,
|
|
comment?: string
|
|
): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}/approve`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ comment }),
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 승인에 실패했습니다.' };
|
|
}
|
|
|
|
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 (isNextRedirectError(error)) throw error;
|
|
console.error('[approveLeave] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 승인에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 반려
|
|
* POST /v1/leaves/{id}/reject
|
|
*/
|
|
export async function rejectLeave(
|
|
id: number,
|
|
reason: string
|
|
): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}/reject`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 반려에 실패했습니다.' };
|
|
}
|
|
|
|
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 (isNextRedirectError(error)) throw error;
|
|
console.error('[rejectLeave] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 반려에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 취소
|
|
* POST /v1/leaves/{id}/cancel
|
|
*/
|
|
export async function cancelLeave(
|
|
id: number,
|
|
reason?: string
|
|
): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}/cancel`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 취소에 실패했습니다.' };
|
|
}
|
|
|
|
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 (isNextRedirectError(error)) throw error;
|
|
console.error('[cancelLeave] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 취소에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 내 잔여 휴가 조회
|
|
* GET /v1/leaves/balance
|
|
*/
|
|
export async function getMyLeaveBalance(year?: number): Promise<{
|
|
success: boolean;
|
|
data?: LeaveBalance;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const url = year
|
|
? `${API_URL}/v1/leaves/balance?year=${year}`
|
|
: `${API_URL}/v1/leaves/balance`;
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '잔여 휴가 조회에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<Record<string, unknown>> = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: transformBalanceToFrontend(result.data),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '잔여 휴가 조회에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[getMyLeaveBalance] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '잔여 휴가 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 사용자 잔여 휴가 조회
|
|
* GET /v1/leaves/balance/{userId}
|
|
*/
|
|
export async function getUserLeaveBalance(
|
|
userId: number,
|
|
year?: number
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: LeaveBalance;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const url = year
|
|
? `${API_URL}/v1/leaves/balance/${userId}?year=${year}`
|
|
: `${API_URL}/v1/leaves/balance/${userId}`;
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '잔여 휴가 조회에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<Record<string, unknown>> = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: transformBalanceToFrontend(result.data),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '잔여 휴가 조회에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[getUserLeaveBalance] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '잔여 휴가 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 잔여 휴가 설정 (부여)
|
|
* PUT /v1/leaves/balance
|
|
*/
|
|
export async function setLeaveBalance(
|
|
data: SetLeaveBalanceRequest
|
|
): Promise<{ success: boolean; data?: LeaveBalance; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/balance`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
user_id: data.userId,
|
|
year: data.year,
|
|
total_days: data.totalDays,
|
|
}),
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '잔여 휴가 설정에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<Record<string, unknown>> = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: transformBalanceToFrontend(result.data),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '잔여 휴가 설정에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[setLeaveBalance] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '잔여 휴가 설정에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 삭제
|
|
* DELETE /v1/leaves/{id}
|
|
*/
|
|
export async function deleteLeave(id: number): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 삭제에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<null> = await response.json();
|
|
|
|
if (result.success) {
|
|
return { success: true };
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '휴가 삭제에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[deleteLeave] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 삭제에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 여러 휴가 일괄 승인
|
|
*/
|
|
export async function approveLeavesMany(ids: number[]): Promise<{
|
|
success: boolean;
|
|
results?: { id: number; success: boolean; error?: string }[];
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const results = await Promise.all(
|
|
ids.map(async (id) => {
|
|
const result = await approveLeave(id);
|
|
return { id, success: result.success, error: result.error };
|
|
})
|
|
);
|
|
|
|
const allSuccess = results.every((r) => r.success);
|
|
return {
|
|
success: allSuccess,
|
|
results,
|
|
error: allSuccess ? undefined : '일부 휴가 승인에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[approveLeavesMany] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 일괄 승인에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 여러 휴가 일괄 반려
|
|
*/
|
|
export async function rejectLeavesMany(
|
|
ids: number[],
|
|
reason: string
|
|
): Promise<{
|
|
success: boolean;
|
|
results?: { id: number; success: boolean; error?: string }[];
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const results = await Promise.all(
|
|
ids.map(async (id) => {
|
|
const result = await rejectLeave(id, reason);
|
|
return { id, success: result.success, error: result.error };
|
|
})
|
|
);
|
|
|
|
const allSuccess = results.every((r) => r.success);
|
|
return {
|
|
success: allSuccess,
|
|
results,
|
|
error: allSuccess ? undefined : '일부 휴가 반려에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[rejectLeavesMany] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 일괄 반려에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전체 직원 휴가 사용현황 조회
|
|
* GET /v1/leaves/balances
|
|
*/
|
|
export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise<{
|
|
success: boolean;
|
|
data?: { items: LeaveBalanceRecord[]; total: number; currentPage: number; lastPage: number };
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
|
|
if (params) {
|
|
if (params.year) searchParams.append('year', params.year.toString());
|
|
if (params.departmentId) searchParams.append('department_id', params.departmentId.toString());
|
|
if (params.search) searchParams.append('search', params.search);
|
|
if (params.sortBy) searchParams.append('sort_by', params.sortBy);
|
|
if (params.sortDir) searchParams.append('sort_dir', params.sortDir);
|
|
if (params.perPage) searchParams.append('per_page', params.perPage.toString());
|
|
if (params.page) searchParams.append('page', params.page.toString());
|
|
}
|
|
|
|
const queryString = searchParams.toString();
|
|
const url = `${API_URL}/v1/leaves/balances${queryString ? `?${queryString}` : ''}`;
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 사용현황 조회에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<PaginatedResponse<Record<string, unknown>>> = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: result.data.data.map(transformBalanceRecordToFrontend),
|
|
total: result.data.total,
|
|
currentPage: result.data.current_page,
|
|
lastPage: result.data.last_page,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '휴가 사용현황 조회에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[getLeaveBalances] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 사용현황 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 사용현황 레코드 응답 변환
|
|
*/
|
|
function transformBalanceRecordToFrontend(apiData: Record<string, unknown>): LeaveBalanceRecord {
|
|
const user = apiData.user as Record<string, unknown> | undefined;
|
|
const department = apiData.department as Record<string, unknown> | undefined;
|
|
const totalDays = (apiData.leave_balance_total as number | null) ?? 15; // 기본 15일
|
|
const usedDays = (apiData.leave_balance_used as number | null) ?? 0;
|
|
|
|
return {
|
|
id: apiData.id as number,
|
|
userId: apiData.user_id as number,
|
|
employeeName: (user?.name as string) ?? '',
|
|
email: (user?.email as string) ?? '',
|
|
department: (department?.name as string) ?? '-',
|
|
position: (apiData.position_label as string) ?? '-',
|
|
jobTitle: (apiData.job_title_label as string) ?? '-',
|
|
rank: (apiData.rank as string | null) ?? null, // json_extra.rank
|
|
hireDate: (apiData.hire_date as string | null) ?? null,
|
|
displayName: (apiData.display_name as string | null) ?? null,
|
|
totalDays,
|
|
usedDays,
|
|
remainingDays: totalDays - usedDays,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// 휴가 부여 관련 타입 및 API
|
|
// ============================================
|
|
|
|
/**
|
|
* 휴가 부여 유형
|
|
*/
|
|
export type LeaveGrantType = 'annual' | 'monthly' | 'reward' | 'condolence' | 'other';
|
|
|
|
/**
|
|
* 휴가 부여 이력 조회 파라미터
|
|
*/
|
|
export interface GetLeaveGrantsParams {
|
|
userId?: number;
|
|
grantType?: LeaveGrantType;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
year?: number;
|
|
departmentId?: number;
|
|
search?: string;
|
|
sortBy?: 'grant_date' | 'grant_days' | 'created_at';
|
|
sortDir?: 'asc' | 'desc';
|
|
perPage?: number;
|
|
page?: number;
|
|
}
|
|
|
|
/**
|
|
* 휴가 부여 레코드
|
|
*/
|
|
export interface LeaveGrantRecord {
|
|
id: number;
|
|
userId: number;
|
|
employeeName: string;
|
|
email: string;
|
|
department: string;
|
|
position: string; // position_label
|
|
jobTitle: string; // job_title_label → 직책
|
|
rank: string | null; // json_extra.rank → 직급
|
|
grantType: LeaveGrantType;
|
|
grantDate: string;
|
|
grantDays: number;
|
|
reason: string | null;
|
|
createdBy: number | null;
|
|
creatorName: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
/**
|
|
* 휴가 부여 요청 데이터
|
|
*/
|
|
export interface CreateLeaveGrantRequest {
|
|
userId: number;
|
|
grantType: LeaveGrantType;
|
|
grantDate: string;
|
|
grantDays: number;
|
|
reason?: string;
|
|
}
|
|
|
|
/**
|
|
* 휴가 부여 이력 조회
|
|
* GET /v1/leaves/grants
|
|
*/
|
|
export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{
|
|
success: boolean;
|
|
data?: { items: LeaveGrantRecord[]; total: number; currentPage: number; lastPage: number };
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
|
|
if (params) {
|
|
if (params.userId) searchParams.append('user_id', params.userId.toString());
|
|
if (params.grantType) searchParams.append('grant_type', params.grantType);
|
|
if (params.dateFrom) searchParams.append('date_from', params.dateFrom);
|
|
if (params.dateTo) searchParams.append('date_to', params.dateTo);
|
|
if (params.year) searchParams.append('year', params.year.toString());
|
|
if (params.departmentId) searchParams.append('department_id', params.departmentId.toString());
|
|
if (params.search) searchParams.append('search', params.search);
|
|
if (params.sortBy) searchParams.append('sort_by', params.sortBy);
|
|
if (params.sortDir) searchParams.append('sort_dir', params.sortDir);
|
|
if (params.perPage) searchParams.append('per_page', params.perPage.toString());
|
|
if (params.page) searchParams.append('page', params.page.toString());
|
|
}
|
|
|
|
const queryString = searchParams.toString();
|
|
const url = `${API_URL}/v1/leaves/grants${queryString ? `?${queryString}` : ''}`;
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 부여 이력 조회에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<PaginatedResponse<Record<string, unknown>>> = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: result.data.data.map(transformGrantRecordToFrontend),
|
|
total: result.data.total,
|
|
currentPage: result.data.current_page,
|
|
lastPage: result.data.last_page,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '휴가 부여 이력 조회에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[getLeaveGrants] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 부여 이력 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 부여
|
|
* POST /v1/leaves/grants
|
|
*/
|
|
export async function createLeaveGrant(
|
|
data: CreateLeaveGrantRequest
|
|
): Promise<{ success: boolean; data?: LeaveGrantRecord; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/grants`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
user_id: data.userId,
|
|
grant_type: data.grantType,
|
|
grant_date: data.grantDate,
|
|
grant_days: data.grantDays,
|
|
reason: data.reason,
|
|
}),
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 부여에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<Record<string, unknown>> = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: transformGrantRecordToFrontend(result.data),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '휴가 부여에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[createLeaveGrant] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 부여에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 부여 삭제
|
|
* DELETE /v1/leaves/grants/{id}
|
|
*/
|
|
export async function deleteLeaveGrant(id: number): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const { response, error } = await serverFetch(`${API_URL}/v1/leaves/grants/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '휴가 부여 삭제에 실패했습니다.' };
|
|
}
|
|
|
|
const result: ApiResponse<null> = await response.json();
|
|
|
|
if (result.success) {
|
|
return { success: true };
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '휴가 부여 삭제에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[deleteLeaveGrant] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '휴가 부여 삭제에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 휴가 부여 이력 레코드 응답 변환
|
|
*/
|
|
function transformGrantRecordToFrontend(apiData: Record<string, unknown>): LeaveGrantRecord {
|
|
const user = apiData.user as Record<string, unknown> | undefined;
|
|
const tenantProfile = user?.tenant_profile as Record<string, unknown> | undefined;
|
|
const department = tenantProfile?.department as Record<string, unknown> | undefined;
|
|
const creator = apiData.creator as Record<string, unknown> | undefined;
|
|
|
|
return {
|
|
id: apiData.id as number,
|
|
userId: apiData.user_id as number,
|
|
employeeName: (user?.name as string) ?? '',
|
|
email: (user?.email as string) ?? '',
|
|
department: (department?.name as string) ?? '-',
|
|
position: (tenantProfile?.position_label as string) ?? '-',
|
|
jobTitle: (tenantProfile?.job_title_label as string) ?? '-',
|
|
rank: (tenantProfile?.rank as string | null) ?? null, // json_extra.rank
|
|
grantType: apiData.grant_type as LeaveGrantType,
|
|
grantDate: apiData.grant_date as string,
|
|
grantDays: apiData.grant_days as number,
|
|
reason: apiData.reason as string | null,
|
|
createdBy: apiData.created_by as number | null,
|
|
creatorName: (creator?.name as string) ?? null,
|
|
createdAt: apiData.created_at as string,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// 직원 목록 조회 (다이얼로그용)
|
|
// ============================================
|
|
|
|
/**
|
|
* 직원 선택 옵션 타입
|
|
*/
|
|
export interface EmployeeOption {
|
|
id: string;
|
|
userId: number;
|
|
name: string;
|
|
department: string;
|
|
position: string;
|
|
}
|
|
|
|
/**
|
|
* 활성 직원 목록 조회 (휴가 신청/부여용)
|
|
* GET /v1/employees
|
|
*/
|
|
export async function getActiveEmployees(): Promise<{
|
|
success: boolean;
|
|
data?: EmployeeOption[];
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const url = `${API_URL}/v1/employees?status=active&per_page=100`;
|
|
|
|
const { response, error } = await serverFetch(url, { method: 'GET' });
|
|
|
|
if (error || !response) {
|
|
return { success: false, error: error?.message || '직원 목록 조회에 실패했습니다.' };
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data?.data) {
|
|
const employees: EmployeeOption[] = result.data.data.map((item: Record<string, unknown>) => {
|
|
const user = item.user as Record<string, unknown> | undefined;
|
|
const department = item.department as Record<string, unknown> | undefined;
|
|
|
|
return {
|
|
id: String(item.id),
|
|
userId: (item.user_id as number) ?? (user?.id as number) ?? item.id,
|
|
name: (user?.name as string) ?? (item.name as string) ?? '',
|
|
department: (department?.name as string) ?? '-',
|
|
position: (item.position_label as string) ?? (item.position_key as string) ?? '-',
|
|
};
|
|
});
|
|
|
|
return { success: true, data: employees };
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.message || '직원 목록 조회에 실패했습니다.',
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[getActiveEmployees] Error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : '직원 목록 조회에 실패했습니다.',
|
|
};
|
|
}
|
|
} |