Files
sam-react-prod/src/components/hr/VacationManagement/actions.ts
유병철 437d5f6834 refactor(WEB): SearchableSelectionModal 공통화 및 actions lookup 통합
- SearchableSelectionModal<T> 제네릭 컴포넌트 추출 (organisms)
- 검색 모달 5개 리팩토링: SupplierSearch, QuotationSelect, SalesOrderSelect, OrderSelect, ItemSearch
- shared-lookups API 유틸 추가 (거래처/품목/수주 등 공통 조회)
- create-crud-service 확장 (lookup, search 메서드)
- actions.ts 20+개 파일 lookup 패턴 통일
- 공통 페이지 패턴 가이드 문서 추가
- CLAUDE.md Common Component Usage Rules 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 16:01:23 +09:00

635 lines
23 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 { executeServerAction } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
// ============================================
// 타입 정의
// ============================================
/**
* 휴가 유형
*/
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;
}
// 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 함수
// ============================================
/** 휴가 목록 조회 */
export async function getLeaves(params?: GetLeavesParams): Promise<{
success: boolean;
data?: { items: LeaveRecord[]; total: number; currentPage: number; lastPage: number };
error?: string;
}> {
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 result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
url: `${API_URL}/v1/leaves${queryString ? `?${queryString}` : ''}`,
errorMessage: '휴가 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: {
items: result.data.data.map(transformApiToFrontend),
total: result.data.total, currentPage: result.data.current_page, lastPage: result.data.last_page,
},
};
}
/** 휴가 상세 조회 */
export async function getLeaveById(id: number): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/${id}`,
transform: (data: Record<string, unknown>) => transformApiToFrontend(data),
errorMessage: '휴가 조회에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 휴가 신청 */
export async function createLeave(data: CreateLeaveRequest): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves`,
method: 'POST',
body: { user_id: data.userId, leave_type: data.leaveType, start_date: data.startDate, end_date: data.endDate, days: data.days, reason: data.reason },
transform: (d: Record<string, unknown>) => transformApiToFrontend(d),
errorMessage: '휴가 신청에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 휴가 승인 */
export async function approveLeave(id: number, comment?: string): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/${id}/approve`,
method: 'POST',
body: { comment },
transform: (d: Record<string, unknown>) => transformApiToFrontend(d),
errorMessage: '휴가 승인에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 휴가 반려 */
export async function rejectLeave(id: number, reason: string): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/${id}/reject`,
method: 'POST',
body: { reason },
transform: (d: Record<string, unknown>) => transformApiToFrontend(d),
errorMessage: '휴가 반려에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 휴가 취소 */
export async function cancelLeave(id: number, reason?: string): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/${id}/cancel`,
method: 'POST',
body: { reason },
transform: (d: Record<string, unknown>) => transformApiToFrontend(d),
errorMessage: '휴가 취소에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 내 잔여 휴가 조회 */
export async function getMyLeaveBalance(year?: number): Promise<{ success: boolean; data?: LeaveBalance; error?: string }> {
const url = year ? `${API_URL}/v1/leaves/balance?year=${year}` : `${API_URL}/v1/leaves/balance`;
const result = await executeServerAction({
url,
transform: (data: Record<string, unknown>) => transformBalanceToFrontend(data),
errorMessage: '잔여 휴가 조회에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 특정 사용자 잔여 휴가 조회 */
export async function getUserLeaveBalance(userId: number, year?: number): Promise<{ success: boolean; data?: LeaveBalance; error?: string }> {
const url = year ? `${API_URL}/v1/leaves/balance/${userId}?year=${year}` : `${API_URL}/v1/leaves/balance/${userId}`;
const result = await executeServerAction({
url,
transform: (data: Record<string, unknown>) => transformBalanceToFrontend(data),
errorMessage: '잔여 휴가 조회에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 잔여 휴가 설정 (부여) */
export async function setLeaveBalance(data: SetLeaveBalanceRequest): Promise<{ success: boolean; data?: LeaveBalance; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/balance`,
method: 'PUT',
body: { user_id: data.userId, year: data.year, total_days: data.totalDays },
transform: (d: Record<string, unknown>) => transformBalanceToFrontend(d),
errorMessage: '잔여 휴가 설정에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 휴가 삭제 */
export async function deleteLeave(id: number): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/${id}`,
method: 'DELETE',
errorMessage: '휴가 삭제에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
/** 여러 휴가 일괄 승인 */
export async function approveLeavesMany(ids: number[]): Promise<{
success: boolean; results?: { id: number; success: boolean; error?: string }[]; error?: string;
}> {
const results = await Promise.all(
ids.map(async (id) => {
const r = await approveLeave(id);
return { id, success: r.success, error: r.error };
})
);
const allSuccess = results.every((r) => r.success);
return { success: allSuccess, results, error: allSuccess ? undefined : '일부 휴가 승인에 실패했습니다.' };
}
/** 여러 휴가 일괄 반려 */
export async function rejectLeavesMany(ids: number[], reason: string): Promise<{
success: boolean; results?: { id: number; success: boolean; error?: string }[]; error?: string;
}> {
const results = await Promise.all(
ids.map(async (id) => {
const r = await rejectLeave(id, reason);
return { id, success: r.success, error: r.error };
})
);
const allSuccess = results.every((r) => r.success);
return { success: allSuccess, results, error: allSuccess ? undefined : '일부 휴가 반려에 실패했습니다.' };
}
/** 전체 직원 휴가 사용현황 조회 */
export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise<{
success: boolean;
data?: { items: LeaveBalanceRecord[]; total: number; currentPage: number; lastPage: number };
error?: string;
}> {
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 result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
url: `${API_URL}/v1/leaves/balances${queryString ? `?${queryString}` : ''}`,
errorMessage: '휴가 사용현황 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: {
items: result.data.data.map(transformBalanceRecordToFrontend),
total: result.data.total, currentPage: result.data.current_page, lastPage: result.data.last_page,
},
};
}
/**
* 휴가 사용현황 레코드 응답 변환
*/
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;
}
/** 휴가 부여 이력 조회 */
export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{
success: boolean;
data?: { items: LeaveGrantRecord[]; total: number; currentPage: number; lastPage: number };
error?: string;
}> {
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 result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
url: `${API_URL}/v1/leaves/grants${queryString ? `?${queryString}` : ''}`,
errorMessage: '휴가 부여 이력 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return {
success: true,
data: {
items: result.data.data.map(transformGrantRecordToFrontend),
total: result.data.total, currentPage: result.data.current_page, lastPage: result.data.last_page,
},
};
}
/** 휴가 부여 */
export async function createLeaveGrant(data: CreateLeaveGrantRequest): Promise<{ success: boolean; data?: LeaveGrantRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/grants`,
method: 'POST',
body: { user_id: data.userId, grant_type: data.grantType, grant_date: data.grantDate, grant_days: data.grantDays, reason: data.reason },
transform: (d: Record<string, unknown>) => transformGrantRecordToFrontend(d),
errorMessage: '휴가 부여에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
/** 휴가 부여 삭제 */
export async function deleteLeaveGrant(id: number): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/grants/${id}`,
method: 'DELETE',
errorMessage: '휴가 부여 삭제에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
/**
* 휴가 부여 이력 레코드 응답 변환
*/
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;
}
/** 활성 직원 목록 조회 (휴가 신청/부여용) */
export async function getActiveEmployees(): Promise<{ success: boolean; data?: EmployeeOption[]; error?: string }> {
interface EmployeePaginatedApiResponse { data: Record<string, unknown>[]; total: number }
const result = await executeServerAction<EmployeePaginatedApiResponse>({
url: `${API_URL}/v1/employees?status=active&per_page=100`,
errorMessage: '직원 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return { success: false, error: result.error };
const employees: EmployeeOption[] = result.data.data.map((item) => {
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 as number),
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 };
}