Files
sam-react-prod/src/components/hr/DepartmentManagement/actions.ts
유병철 55e0791e16 refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일)
- sanitize 유틸 추가 (XSS 방지)
- middleware CSP 헤더 추가 및 Open Redirect 방지
- 프록시 라우트 로깅 개발환경 한정으로 변경
- 프로덕션 불필요 console.log 제거

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

247 lines
6.3 KiB
TypeScript

/**
* 부서관리 서버 액션
*
* API Endpoints:
* - GET /api/v1/departments/tree - 부서 트리 조회
* - GET /api/v1/departments - 부서 목록 조회
* - GET /api/v1/departments/{id} - 부서 상세 조회
* - POST /api/v1/departments - 부서 생성 (parent_id 지원)
* - PATCH /api/v1/departments/{id} - 부서 수정 (parent_id 지원)
* - DELETE /api/v1/departments/{id} - 부서 삭제
*/
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
// ============================================
// 타입 정의
// ============================================
/**
* API 응답의 부서 데이터 (snake_case)
*/
export interface ApiDepartment {
id: number;
tenant_id: number;
parent_id: number | null;
code: string | null;
name: string;
description: string | null;
is_active: boolean;
sort_order: number;
created_at: string;
updated_at: string;
created_by: number | null;
updated_by: number | null;
children?: ApiDepartment[];
}
/**
* 프론트엔드 부서 데이터 (camelCase)
*/
export interface DepartmentRecord {
id: number;
tenantId: number;
parentId: number | null;
code: string | null;
name: string;
description: string | null;
isActive: boolean;
sortOrder: number;
depth: number;
createdAt: string;
updatedAt: string;
createdBy: number | null;
updatedBy: number | null;
children: DepartmentRecord[];
}
/**
* 부서 생성 요청 데이터
*/
export interface CreateDepartmentRequest {
code?: string;
name: string;
description?: string;
isActive?: boolean;
sortOrder?: number;
parentId?: number;
}
/**
* 부서 수정 요청 데이터
*/
export interface UpdateDepartmentRequest {
code?: string;
name?: string;
description?: string;
isActive?: boolean;
sortOrder?: number;
parentId?: number | null; // null이면 최상위로 이동
}
// ============================================
// 헬퍼 함수
// ============================================
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
/**
* API 응답을 프론트엔드 형식으로 변환 (재귀)
*/
function transformApiToFrontend(apiData: ApiDepartment, depth: number = 0): DepartmentRecord {
return {
id: apiData.id,
tenantId: apiData.tenant_id,
parentId: apiData.parent_id,
code: apiData.code,
name: apiData.name,
description: apiData.description,
isActive: apiData.is_active,
sortOrder: apiData.sort_order,
depth,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
createdBy: apiData.created_by,
updatedBy: apiData.updated_by,
children: apiData.children
? apiData.children.map((child) => transformApiToFrontend(child, depth + 1))
: [],
};
}
// ============================================
// API 함수
// ============================================
/**
* 부서 트리 조회
* GET /api/v1/departments/tree
*/
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}` : ''}`,
transform: (data: ApiDepartment[]) => data.map((dept) => transformApiToFrontend(dept, 0)),
errorMessage: '부서 트리 조회에 실패했습니다.',
});
}
/**
* 부서 상세 조회
* GET /api/v1/departments/{id}
*/
export async function getDepartmentById(
id: number
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
transform: (data: ApiDepartment) => transformApiToFrontend(data),
errorMessage: '부서 조회에 실패했습니다.',
});
}
/**
* 부서 생성
* POST /api/v1/departments
*/
export async function createDepartment(
data: CreateDepartmentRequest
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments`,
method: 'POST',
body: {
parent_id: data.parentId,
code: data.code,
name: data.name,
description: data.description,
is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined,
sort_order: data.sortOrder,
},
transform: (data: ApiDepartment) => transformApiToFrontend(data),
errorMessage: '부서 생성에 실패했습니다.',
});
}
/**
* 부서 수정
* PATCH /api/v1/departments/{id}
*/
export async function updateDepartment(
id: number,
data: UpdateDepartmentRequest
): Promise<ActionResult<DepartmentRecord>> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
method: 'PATCH',
body: {
parent_id: data.parentId === null ? 0 : data.parentId,
code: data.code,
name: data.name,
description: data.description,
is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined,
sort_order: data.sortOrder,
},
transform: (data: ApiDepartment) => transformApiToFrontend(data),
errorMessage: '부서 수정에 실패했습니다.',
});
}
/**
* 부서 삭제
* DELETE /api/v1/departments/{id}
*/
export async function deleteDepartment(
id: number
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/v1/departments/${id}`,
method: 'DELETE',
errorMessage: '부서 삭제에 실패했습니다.',
});
}
/**
* 부서 일괄 삭제
*/
export async function deleteDepartmentsMany(
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 deleteDepartment(id);
return {
id,
success: result.success,
error: result.error,
};
})
);
const allSuccess = results.every((r) => r.success);
return {
success: allSuccess,
results,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[deleteDepartmentsMany] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '부서 일괄 삭제에 실패했습니다.',
};
}
}