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>
This commit is contained in:
유병철
2026-02-12 20:59:59 +09:00
parent 31be9d4a25
commit cbb38d48b9
51 changed files with 1050 additions and 1405 deletions

View File

@@ -16,6 +16,7 @@
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';
@@ -31,9 +32,6 @@ import type {
// 헬퍼 함수
// ============================================
// API URL
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
/**
* API 응답 데이터를 프론트엔드 형식으로 변환
*/
@@ -161,7 +159,7 @@ interface EmployeeApiData {
export async function getEmployeesForAttendance(): Promise<EmployeeOption[]> {
const result = await executeServerAction<PaginatedApiResponse<EmployeeApiData>>({
url: `${API_URL}/v1/employees?per_page=100&status=active`,
url: buildApiUrl('/api/v1/employees', { per_page: 100, status: 'active' }),
errorMessage: '사원 목록 조회에 실패했습니다.',
});
@@ -185,20 +183,19 @@ export async function getAttendances(params?: {
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 searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.per_page) searchParams.set('per_page', String(params.per_page));
if (params?.user_id) searchParams.set('user_id', params.user_id);
if (params?.date) searchParams.set('date', params.date);
if (params?.date_from) searchParams.set('date_from', params.date_from);
if (params?.date_to) searchParams.set('date_to', params.date_to);
if (params?.status && params.status !== 'all') searchParams.set('status', params.status);
if (params?.department_id) searchParams.set('department_id', params.department_id);
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedApiResponse<AttendanceApiData>>({
url: `${API_URL}/v1/attendances?${searchParams.toString()}`,
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: '근태 목록 조회에 실패했습니다.',
});
@@ -213,7 +210,7 @@ export async function getAttendances(params?: {
export async function getAttendanceById(id: string): Promise<AttendanceRecord | null> {
const result = await executeServerAction({
url: `${API_URL}/v1/attendances/${id}`,
url: buildApiUrl(`/api/v1/attendances/${id}`),
transform: (data: AttendanceApiData) => transformApiToFrontend(data),
errorMessage: '근태 조회에 실패했습니다.',
});
@@ -225,7 +222,7 @@ export async function createAttendance(
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: `${API_URL}/v1/attendances`,
url: buildApiUrl('/api/v1/attendances'),
method: 'POST',
body: apiData,
transform: (d: AttendanceApiData) => transformApiToFrontend(d),
@@ -240,7 +237,7 @@ export async function updateAttendance(
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: `${API_URL}/v1/attendances/${id}`,
url: buildApiUrl(`/api/v1/attendances/${id}`),
method: 'PATCH',
body: apiData,
transform: (d: AttendanceApiData) => transformApiToFrontend(d),
@@ -251,7 +248,7 @@ export async function updateAttendance(
export async function deleteAttendance(id: string): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/attendances/${id}`,
url: buildApiUrl(`/api/v1/attendances/${id}`),
method: 'DELETE',
errorMessage: '근태 삭제에 실패했습니다.',
});
@@ -260,7 +257,7 @@ export async function deleteAttendance(id: string): Promise<{ success: boolean;
export async function deleteAttendances(ids: string[]): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/attendances/bulk-delete`,
url: buildApiUrl('/api/v1/attendances/bulk-delete'),
method: 'POST',
body: { ids: ids.map(id => parseInt(id, 10)) },
errorMessage: '근태 일괄 삭제에 실패했습니다.',
@@ -271,11 +268,6 @@ export async function deleteAttendances(ids: string[]): Promise<{ success: boole
export async function getMonthlyStats(params: {
year: number; month: number; user_id?: string;
}): Promise<AttendanceStats | null> {
const searchParams = new URLSearchParams();
searchParams.set('year', String(params.year));
searchParams.set('month', String(params.month));
if (params.user_id) searchParams.set('user_id', params.user_id);
interface MonthlyStatsApiData {
year: number; month: number; total_days: number;
by_status: {
@@ -286,7 +278,11 @@ export async function getMonthlyStats(params: {
}
const result = await executeServerAction<MonthlyStatsApiData>({
url: `${API_URL}/v1/attendances/monthly-stats?${searchParams.toString()}`,
url: buildApiUrl('/api/v1/attendances/monthly-stats', {
year: params.year,
month: params.month,
user_id: params.user_id,
}),
errorMessage: '월간 통계 조회에 실패했습니다.',
});
@@ -322,15 +318,13 @@ export async function exportAttendanceExcel(params?: {
'X-API-KEY': process.env.API_KEY || '',
};
const searchParams = new URLSearchParams();
if (params?.date_from) searchParams.set('date_from', params.date_from);
if (params?.date_to) searchParams.set('date_to', params.date_to);
if (params?.user_id) searchParams.set('user_id', params.user_id);
if (params?.status && params.status !== 'all') searchParams.set('status', params.status);
if (params?.department_id) searchParams.set('department_id', params.department_id);
const queryString = searchParams.toString();
const url = `${API_URL}/v1/attendances/export${queryString ? `?${queryString}` : ''}`;
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 });

View File

@@ -2,6 +2,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { Card, CardFormData, CardStatus } from './types';
@@ -43,9 +44,6 @@ interface CardPaginationData {
total: number;
}
// API URL (without double /api)
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
// 상태 매핑: API → Frontend
function mapApiStatusToFrontend(apiStatus: 'active' | 'inactive'): CardStatus {
return apiStatus === 'active' ? 'active' : 'suspended';
@@ -114,15 +112,13 @@ function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
export async function getCards(params?: {
search?: string; status?: string; page?: number; per_page?: number;
}): Promise<{ success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string }> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
if (params?.status && params.status !== 'all') searchParams.set('status', mapFrontendStatusToApi(params.status as CardStatus));
if (params?.page) searchParams.set('page', String(params.page));
if (params?.per_page) searchParams.set('per_page', String(params.per_page));
const queryString = searchParams.toString();
const result = await executeServerAction<CardPaginationData>({
url: `${API_URL}/v1/cards${queryString ? `?${queryString}` : ''}`,
url: buildApiUrl('/api/v1/cards', {
search: params?.search,
status: params?.status && params.status !== 'all' ? mapFrontendStatusToApi(params.status as CardStatus) : undefined,
page: params?.page,
per_page: params?.per_page,
}),
errorMessage: '카드 목록을 불러오는데 실패했습니다.',
});
@@ -144,7 +140,7 @@ export async function getCards(params?: {
// ===== 카드 상세 조회 =====
export async function getCard(id: string): Promise<ActionResult<Card>> {
return executeServerAction({
url: `${API_URL}/v1/cards/${id}`,
url: buildApiUrl(`/api/v1/cards/${id}`),
transform: (data: CardApiData) => transformApiToFrontend(data),
errorMessage: '카드 정보를 불러오는데 실패했습니다.',
});
@@ -153,7 +149,7 @@ export async function getCard(id: string): Promise<ActionResult<Card>> {
// ===== 카드 등록 =====
export async function createCard(data: CardFormData): Promise<ActionResult<Card>> {
return executeServerAction({
url: `${API_URL}/v1/cards`,
url: buildApiUrl('/api/v1/cards'),
method: 'POST',
body: transformFrontendToApi(data),
transform: (d: CardApiData) => transformApiToFrontend(d),
@@ -164,7 +160,7 @@ export async function createCard(data: CardFormData): Promise<ActionResult<Card>
// ===== 카드 수정 =====
export async function updateCard(id: string, data: CardFormData): Promise<ActionResult<Card>> {
return executeServerAction({
url: `${API_URL}/v1/cards/${id}`,
url: buildApiUrl(`/api/v1/cards/${id}`),
method: 'PUT',
body: transformFrontendToApi(data),
transform: (d: CardApiData) => transformApiToFrontend(d),
@@ -175,7 +171,7 @@ export async function updateCard(id: string, data: CardFormData): Promise<Action
// ===== 카드 삭제 =====
export async function deleteCard(id: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/v1/cards/${id}`,
url: buildApiUrl(`/api/v1/cards/${id}`),
method: 'DELETE',
errorMessage: '카드 삭제에 실패했습니다.',
});
@@ -199,7 +195,7 @@ export async function deleteCards(ids: string[]): Promise<{ success: boolean; er
// ===== 카드 상태 토글 =====
export async function toggleCardStatus(id: string): Promise<ActionResult<Card>> {
return executeServerAction({
url: `${API_URL}/v1/cards/${id}/toggle`,
url: buildApiUrl(`/api/v1/cards/${id}/toggle`),
method: 'PATCH',
transform: (data: CardApiData) => transformApiToFrontend(data),
errorMessage: '상태 변경에 실패했습니다.',
@@ -219,7 +215,10 @@ export async function getActiveEmployees(): Promise<{ success: boolean; data?: A
}
const result = await executeServerAction<EmployeePaginationData>({
url: `${API_URL}/v1/employees?status=active&per_page=50`,
url: buildApiUrl('/api/v1/employees', {
status: 'active',
per_page: 50,
}),
errorMessage: '직원 목록을 불러오는데 실패했습니다.',
});
@@ -233,4 +232,4 @@ export async function getActiveEmployees(): Promise<{ success: boolean; data?: A
}));
return { success: true, data: employees };
}
}

View File

@@ -15,6 +15,7 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ============================================
// 타입 정의
@@ -87,8 +88,6 @@ export interface UpdateDepartmentRequest {
// 헬퍼 함수
// ============================================
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
/**
* API 응답을 프론트엔드 형식으로 변환 (재귀)
*/
@@ -124,14 +123,10 @@ function transformApiToFrontend(apiData: ApiDepartment, depth: number = 0): Depa
export async function getDepartmentTree(params?: {
withUsers?: boolean;
}): Promise<ActionResult<DepartmentRecord[]>> {
const queryParams = new URLSearchParams();
if (params?.withUsers) {
queryParams.append('with_users', '1');
}
const queryString = queryParams.toString();
return executeServerAction({
url: `${API_URL}/v1/departments/tree${queryString ? `?${queryString}` : ''}`,
url: buildApiUrl('/api/v1/departments/tree', {
with_users: params?.withUsers ? '1' : undefined,
}),
transform: (data: ApiDepartment[]) => data.map((dept) => transformApiToFrontend(dept, 0)),
errorMessage: '부서 트리 조회에 실패했습니다.',
});
@@ -145,7 +140,7 @@ export async function getDepartmentById(
id: number
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
url: buildApiUrl(`/api/v1/departments/${id}`),
transform: (data: ApiDepartment) => transformApiToFrontend(data),
errorMessage: '부서 조회에 실패했습니다.',
});
@@ -159,7 +154,7 @@ export async function createDepartment(
data: CreateDepartmentRequest
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments`,
url: buildApiUrl('/api/v1/departments'),
method: 'POST',
body: {
parent_id: data.parentId,
@@ -183,7 +178,7 @@ export async function updateDepartment(
data: UpdateDepartmentRequest
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
url: buildApiUrl(`/api/v1/departments/${id}`),
method: 'PATCH',
body: {
parent_id: data.parentId === null ? 0 : data.parentId,
@@ -206,7 +201,7 @@ export async function deleteDepartment(
id: number
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
url: buildApiUrl(`/api/v1/departments/${id}`),
method: 'DELETE',
errorMessage: '부서 삭제에 실패했습니다.',
});

View File

@@ -17,6 +17,7 @@
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';
@@ -44,18 +45,17 @@ export async function getEmployees(params?: {
department_id?: string; has_account?: boolean;
sort_by?: string; sort_dir?: 'asc' | 'desc';
}): Promise<{ data: Employee[]; total: number; lastPage: number; __authError?: boolean }> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.per_page) searchParams.set('per_page', String(params.per_page));
if (params?.q) searchParams.set('q', params.q);
if (params?.status && params.status !== 'all') searchParams.set('status', params.status);
if (params?.department_id) searchParams.set('department_id', params.department_id);
if (params?.has_account !== undefined) searchParams.set('has_account', String(params.has_account));
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedApiResponse<EmployeeApiData>>({
url: `${API_URL}/api/v1/employees?${searchParams.toString()}`,
url: buildApiUrl('/api/v1/employees', {
page: params?.page,
per_page: params?.per_page,
q: params?.q,
status: params?.status && params.status !== 'all' ? params.status : undefined,
department_id: params?.department_id,
has_account: params?.has_account,
sort_by: params?.sort_by,
sort_dir: params?.sort_dir,
}),
errorMessage: '직원 목록 조회에 실패했습니다.',
});
@@ -71,7 +71,7 @@ export async function getEmployees(params?: {
export async function getEmployeeById(id: string): Promise<Employee | null | { __authError: true }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/employees/${id}`,
url: buildApiUrl(`/api/v1/employees/${id}`),
transform: (data: EmployeeApiData) => transformApiToFrontend(data),
errorMessage: '직원 조회에 실패했습니다.',
});
@@ -88,7 +88,7 @@ export async function createEmployee(
}> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: `${API_URL}/api/v1/employees`,
url: buildApiUrl('/api/v1/employees'),
method: 'POST',
body: apiData,
transform: (d: EmployeeApiData) => transformApiToFrontend(d),
@@ -105,7 +105,7 @@ export async function updateEmployee(
): Promise<{ success: boolean; data?: Employee; error?: string; __authError?: boolean }> {
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: `${API_URL}/api/v1/employees/${id}`,
url: buildApiUrl(`/api/v1/employees/${id}`),
method: 'PATCH',
body: apiData,
transform: (d: EmployeeApiData) => transformApiToFrontend(d),
@@ -118,7 +118,7 @@ export async function updateEmployee(
export async function deleteEmployee(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/employees/${id}`,
url: buildApiUrl(`/api/v1/employees/${id}`),
method: 'DELETE',
errorMessage: '직원 삭제에 실패했습니다.',
});
@@ -129,7 +129,7 @@ export async function deleteEmployee(id: string): Promise<{ success: boolean; er
export async function deleteEmployees(ids: string[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/employees/bulk-delete`,
url: buildApiUrl('/api/v1/employees/bulk-delete'),
method: 'POST',
body: { ids: ids.map(id => parseInt(id, 10)) },
errorMessage: '직원 일괄 삭제에 실패했습니다.',
@@ -148,7 +148,7 @@ interface EmployeeStatsApiData {
export async function getEmployeeStats(): Promise<EmployeeStats | null | { __authError: true }> {
const result = await executeServerAction<EmployeeStatsApiData>({
url: `${API_URL}/api/v1/employees/stats`,
url: buildApiUrl('/api/v1/employees/stats'),
errorMessage: '직원 통계 조회에 실패했습니다.',
});
@@ -177,11 +177,10 @@ export interface PositionItem {
}
export async function getPositions(type?: 'rank' | 'title'): Promise<PositionItem[]> {
const searchParams = new URLSearchParams();
if (type) searchParams.set('type', type);
const result = await executeServerAction<PositionItem[]>({
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
url: buildApiUrl('/api/v1/positions', {
type,
}),
errorMessage: '직급/직책 조회에 실패했습니다.',
});
return result.data || [];
@@ -201,7 +200,7 @@ export interface DepartmentItem {
export async function getDepartments(): Promise<DepartmentItem[]> {
const result = await executeServerAction<DepartmentItem[] | { data: DepartmentItem[] }>({
url: `${API_URL}/api/v1/departments`,
url: buildApiUrl('/api/v1/departments'),
errorMessage: '부서 조회에 실패했습니다.',
});
if (!result.data) return [];

View File

@@ -2,6 +2,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { cookies } from 'next/headers';
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
@@ -66,9 +67,6 @@ interface StatisticsApiData {
completed_count: number;
}
// API URL
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
// API → Frontend 변환 (목록용)
function transformApiToFrontend(apiData: SalaryApiData): SalaryRecord {
const profile = apiData.employee_profile;
@@ -144,20 +142,18 @@ export async function getSalaries(params?: {
pagination?: { total: number; currentPage: number; lastPage: number };
error?: string
}> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
if (params?.year) searchParams.set('year', String(params.year));
if (params?.month) searchParams.set('month', String(params.month));
if (params?.status && params.status !== 'all') searchParams.set('status', params.status);
if (params?.employee_id) searchParams.set('employee_id', String(params.employee_id));
if (params?.start_date) searchParams.set('start_date', params.start_date);
if (params?.end_date) searchParams.set('end_date', params.end_date);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.per_page) searchParams.set('per_page', String(params.per_page));
const queryString = searchParams.toString();
const result = await executeServerAction<SalaryPaginationData>({
url: `${API_URL}/v1/salaries${queryString ? `?${queryString}` : ''}`,
url: buildApiUrl('/api/v1/salaries', {
search: params?.search,
year: params?.year,
month: params?.month,
status: params?.status && params.status !== 'all' ? params.status : undefined,
employee_id: params?.employee_id,
start_date: params?.start_date,
end_date: params?.end_date,
page: params?.page,
per_page: params?.per_page,
}),
errorMessage: '급여 목록을 불러오는데 실패했습니다.',
});
@@ -179,7 +175,7 @@ export async function getSalary(id: string): Promise<{
success: boolean; data?: SalaryDetail; error?: string
}> {
const result = await executeServerAction({
url: `${API_URL}/v1/salaries/${id}`,
url: buildApiUrl(`/api/v1/salaries/${id}`),
transform: (data: SalaryApiData) => transformApiToDetail(data),
errorMessage: '급여 정보를 불러오는데 실패했습니다.',
});
@@ -192,7 +188,7 @@ export async function updateSalaryStatus(
status: PaymentStatus
): Promise<{ success: boolean; data?: SalaryRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/salaries/${id}/status`,
url: buildApiUrl(`/api/v1/salaries/${id}/status`),
method: 'PATCH',
body: { status },
transform: (data: SalaryApiData) => transformApiToFrontend(data),
@@ -207,7 +203,7 @@ export async function bulkUpdateSalaryStatus(
status: PaymentStatus
): Promise<{ success: boolean; updatedCount?: number; error?: string }> {
const result = await executeServerAction<{ updated_count: number }>({
url: `${API_URL}/v1/salaries/bulk-update-status`,
url: buildApiUrl('/api/v1/salaries/bulk-update-status'),
method: 'POST',
body: { ids: ids.map(id => parseInt(id, 10)), status },
errorMessage: '일괄 상태 변경에 실패했습니다.',
@@ -228,7 +224,7 @@ export async function updateSalary(
}
): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/salaries/${id}`,
url: buildApiUrl(`/api/v1/salaries/${id}`),
method: 'PUT',
body: data,
transform: (d: SalaryApiData) => transformApiToDetail(d),
@@ -249,15 +245,13 @@ export async function getSalaryStatistics(params?: {
};
error?: string
}> {
const searchParams = new URLSearchParams();
if (params?.year) searchParams.set('year', String(params.year));
if (params?.month) searchParams.set('month', String(params.month));
if (params?.start_date) searchParams.set('start_date', params.start_date);
if (params?.end_date) searchParams.set('end_date', params.end_date);
const queryString = searchParams.toString();
const result = await executeServerAction<StatisticsApiData>({
url: `${API_URL}/v1/salaries/statistics${queryString ? `?${queryString}` : ''}`,
url: buildApiUrl('/api/v1/salaries/statistics', {
year: params?.year,
month: params?.month,
start_date: params?.start_date,
end_date: params?.end_date,
}),
errorMessage: '통계 정보를 불러오는데 실패했습니다.',
});
@@ -303,18 +297,14 @@ export async function exportSalaryExcel(params?: {
'X-API-KEY': process.env.API_KEY || '',
};
const searchParams = new URLSearchParams();
if (params?.year) searchParams.set('year', String(params.year));
if (params?.month) searchParams.set('month', String(params.month));
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
if (params?.employee_id) searchParams.set('employee_id', String(params.employee_id));
if (params?.start_date) searchParams.set('start_date', params.start_date);
if (params?.end_date) searchParams.set('end_date', params.end_date);
const queryString = searchParams.toString();
const url = `${API_URL}/v1/salaries/export${queryString ? `?${queryString}` : ''}`;
const url = buildApiUrl('/api/v1/salaries/export', {
year: params?.year,
month: params?.month,
status: params?.status && params.status !== 'all' ? params.status : undefined,
employee_id: params?.employee_id,
start_date: params?.start_date,
end_date: params?.end_date,
});
const response = await fetch(url, {
method: 'GET',

View File

@@ -2,7 +2,6 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import {
Download,
DollarSign,
Check,
Clock,
@@ -382,6 +381,26 @@ export function SalaryManagement() {
itemsPerPage: itemsPerPage,
// 엑셀 다운로드 설정
excelDownload: {
columns: [
{ header: '부서', key: 'department' },
{ header: '직책', key: 'position' },
{ header: '이름', key: 'employeeName' },
{ header: '직급', key: 'rank' },
{ header: '기본급', key: 'baseSalary' },
{ header: '수당', key: 'allowance' },
{ header: '초과근무', key: 'overtime' },
{ header: '상여', key: 'bonus' },
{ header: '공제', key: 'deduction' },
{ header: '실지급액', key: 'netPayment' },
{ header: '지급일', key: 'paymentDate' },
{ header: '상태', key: 'status', transform: (value: unknown) => value === 'completed' ? '지급완료' : '지급예정' },
],
filename: '급여명세',
sheetName: '급여',
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
@@ -428,16 +447,7 @@ export function SalaryManagement() {
</>
),
headerActions: () => (
<div className="flex items-center gap-2 flex-wrap">
{canExport && (
<Button variant="outline" onClick={() => toast.info('엑셀 다운로드 기능은 준비 중입니다.')}>
<Download className="h-4 w-4 mr-2" />
</Button>
)}
</div>
),
headerActions: () => null,
renderTableRow: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;

View File

@@ -23,6 +23,7 @@
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { PaginatedApiResponse } from '@/lib/api/types';
// ============================================
@@ -165,9 +166,6 @@ interface ApiResponse<T> {
message: string;
}
// API URL
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
/**
* API 응답에서 프론트엔드 형식으로 변환
*/
@@ -237,23 +235,20 @@ export async function getLeaves(params?: GetLeavesParams): Promise<{
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}` : ''}`,
url: buildApiUrl('/api/v1/leaves', {
user_id: params?.userId,
status: params?.status,
leave_type: params?.leaveType,
date_from: params?.dateFrom,
date_to: params?.dateTo,
year: params?.year,
department_id: params?.departmentId,
sort_by: params?.sortBy,
sort_dir: params?.sortDir,
per_page: params?.perPage,
page: params?.page,
}),
errorMessage: '휴가 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
@@ -269,7 +264,7 @@ export async function getLeaves(params?: GetLeavesParams): Promise<{
/** 휴가 상세 조회 */
export async function getLeaveById(id: number): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/${id}`,
url: buildApiUrl(`/api/v1/leaves/${id}`),
transform: (data: Record<string, unknown>) => transformApiToFrontend(data),
errorMessage: '휴가 조회에 실패했습니다.',
});
@@ -279,7 +274,7 @@ export async function getLeaveById(id: number): Promise<{ success: boolean; data
/** 휴가 신청 */
export async function createLeave(data: CreateLeaveRequest): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves`,
url: buildApiUrl('/api/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),
@@ -291,7 +286,7 @@ export async function createLeave(data: CreateLeaveRequest): Promise<{ success:
/** 휴가 승인 */
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`,
url: buildApiUrl(`/api/v1/leaves/${id}/approve`),
method: 'POST',
body: { comment },
transform: (d: Record<string, unknown>) => transformApiToFrontend(d),
@@ -303,7 +298,7 @@ export async function approveLeave(id: number, comment?: string): Promise<{ succ
/** 휴가 반려 */
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`,
url: buildApiUrl(`/api/v1/leaves/${id}/reject`),
method: 'POST',
body: { reason },
transform: (d: Record<string, unknown>) => transformApiToFrontend(d),
@@ -315,7 +310,7 @@ export async function rejectLeave(id: number, reason: string): Promise<{ success
/** 휴가 취소 */
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`,
url: buildApiUrl(`/api/v1/leaves/${id}/cancel`),
method: 'POST',
body: { reason },
transform: (d: Record<string, unknown>) => transformApiToFrontend(d),
@@ -326,9 +321,8 @@ export async function cancelLeave(id: number, reason?: string): Promise<{ succes
/** 내 잔여 휴가 조회 */
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,
url: buildApiUrl('/api/v1/leaves/balance', { year }),
transform: (data: Record<string, unknown>) => transformBalanceToFrontend(data),
errorMessage: '잔여 휴가 조회에 실패했습니다.',
});
@@ -337,9 +331,8 @@ export async function getMyLeaveBalance(year?: number): Promise<{ success: boole
/** 특정 사용자 잔여 휴가 조회 */
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,
url: buildApiUrl(`/api/v1/leaves/balance/${userId}`, { year }),
transform: (data: Record<string, unknown>) => transformBalanceToFrontend(data),
errorMessage: '잔여 휴가 조회에 실패했습니다.',
});
@@ -349,7 +342,7 @@ export async function getUserLeaveBalance(userId: number, year?: number): Promis
/** 잔여 휴가 설정 (부여) */
export async function setLeaveBalance(data: SetLeaveBalanceRequest): Promise<{ success: boolean; data?: LeaveBalance; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/balance`,
url: buildApiUrl('/api/v1/leaves/balance'),
method: 'PUT',
body: { user_id: data.userId, year: data.year, total_days: data.totalDays },
transform: (d: Record<string, unknown>) => transformBalanceToFrontend(d),
@@ -361,7 +354,7 @@ export async function setLeaveBalance(data: SetLeaveBalanceRequest): Promise<{ s
/** 휴가 삭제 */
export async function deleteLeave(id: number): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/${id}`,
url: buildApiUrl(`/api/v1/leaves/${id}`),
method: 'DELETE',
errorMessage: '휴가 삭제에 실패했습니다.',
});
@@ -402,19 +395,16 @@ export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise
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}` : ''}`,
url: buildApiUrl('/api/v1/leaves/balances', {
year: params?.year,
department_id: params?.departmentId,
search: params?.search,
sort_by: params?.sortBy,
sort_dir: params?.sortDir,
per_page: params?.perPage,
page: params?.page,
}),
errorMessage: '휴가 사용현황 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
@@ -517,23 +507,20 @@ export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{
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}` : ''}`,
url: buildApiUrl('/api/v1/leaves/grants', {
user_id: params?.userId,
grant_type: params?.grantType,
date_from: params?.dateFrom,
date_to: params?.dateTo,
year: params?.year,
department_id: params?.departmentId,
search: params?.search,
sort_by: params?.sortBy,
sort_dir: params?.sortDir,
per_page: params?.perPage,
page: params?.page,
}),
errorMessage: '휴가 부여 이력 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
@@ -549,7 +536,7 @@ export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{
/** 휴가 부여 */
export async function createLeaveGrant(data: CreateLeaveGrantRequest): Promise<{ success: boolean; data?: LeaveGrantRecord; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/grants`,
url: buildApiUrl('/api/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),
@@ -561,7 +548,7 @@ export async function createLeaveGrant(data: CreateLeaveGrantRequest): Promise<{
/** 휴가 부여 삭제 */
export async function deleteLeaveGrant(id: number): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/v1/leaves/grants/${id}`,
url: buildApiUrl(`/api/v1/leaves/grants/${id}`),
method: 'DELETE',
errorMessage: '휴가 부여 삭제에 실패했습니다.',
});
@@ -615,7 +602,7 @@ export interface EmployeeOption {
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`,
url: buildApiUrl('/api/v1/employees', { status: 'active', per_page: 100 }),
errorMessage: '직원 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return { success: false, error: result.error };