- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일) - sanitize 유틸 추가 (XSS 방지) - middleware CSP 헤더 추가 및 Open Redirect 방지 - 프록시 라우트 로깅 개발환경 한정으로 변경 - 프로덕션 불필요 console.log 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
247 lines
6.3 KiB
TypeScript
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 : '부서 일괄 삭제에 실패했습니다.',
|
|
};
|
|
}
|
|
}
|