824a3100ce
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를 4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다. # 보안 (plane 격리) PR #A — controller/CompanyManagementController 인증 누락 패치 /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제 + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용. PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그 CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러 (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두 테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종 (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가. SuperAdminGuard.isTenantHost 가시성 public static 으로 승격. PR #B — 프론트 솔루션 전용 admin 페이지 가드 admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별: subdomainList / companyList / audit-log. 각 페이지에 isManagementHost useEffect 가드 + redirect 추가. 사이드바도 같이 숨김. PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터 V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹. admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus 가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS 하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지. StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용. # 부서관리 후속 (이전 PR #18/#19 follow-up) DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분. 이번 격리 작업과 무관하지만 같이 정리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
278 lines
8.4 KiB
TypeScript
278 lines
8.4 KiB
TypeScript
/**
|
|
* 부서 관리 API 클라이언트
|
|
*/
|
|
|
|
import { apiClient } from "./client";
|
|
import { Department, DepartmentMember, DepartmentFormData } from "@/types/department";
|
|
|
|
/**
|
|
* 부서 목록 조회 (회사별).
|
|
* options.includeDeleted=true 시 soft-delete 된 부서도 포함.
|
|
* options.baseDate (YYYY-MM-DD) 가 있으면 해당 시점에 active 한 부서만 반환.
|
|
*/
|
|
export async function getDepartments(
|
|
companyCode: string,
|
|
options?: { includeDeleted?: boolean; baseDate?: string },
|
|
) {
|
|
try {
|
|
const url = `/departments/companies/${companyCode}/departments`;
|
|
const params: Record<string, any> = {};
|
|
if (options?.includeDeleted) params.include_deleted = true;
|
|
if (options?.baseDate) params.base_date = options.baseDate;
|
|
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url, {
|
|
params: Object.keys(params).length > 0 ? params : undefined,
|
|
});
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서 목록 조회 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서 상세 조회
|
|
*/
|
|
export async function getDepartment(deptCode: string) {
|
|
try {
|
|
const response = await apiClient.get<{ success: boolean; data: Department }>(`/departments/${deptCode}`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서 상세 조회 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서 생성
|
|
*/
|
|
export async function createDepartment(companyCode: string, data: DepartmentFormData) {
|
|
try {
|
|
const response = await apiClient.post<{ success: boolean; data: Department }>(
|
|
`/departments/companies/${companyCode}/departments`,
|
|
data,
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서 생성 실패:", error);
|
|
const isDuplicate = error.response?.status === 409;
|
|
return {
|
|
success: false,
|
|
error: error.response?.data?.message || error.message,
|
|
isDuplicate,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서 수정
|
|
*/
|
|
export async function updateDepartment(deptCode: string, data: DepartmentFormData) {
|
|
try {
|
|
const response = await apiClient.put<{ success: boolean; data: Department }>(`/departments/${deptCode}`, data);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서 수정 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서 삭제 (V1: soft-delete).
|
|
* 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가.
|
|
*/
|
|
export async function deleteDepartment(deptCode: string) {
|
|
try {
|
|
const response = await apiClient.delete<{
|
|
success: boolean;
|
|
message?: string;
|
|
data?: { soft_deleted?: boolean; dept_code?: string };
|
|
}>(`/departments/${deptCode}`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서 삭제 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서 복구 (V1 신규 — soft-delete 된 부서 되살리기).
|
|
* 부모가 deleted 면 차단 (400) → "상위 부서를 먼저 복구해주세요" 메시지.
|
|
*/
|
|
export async function restoreDepartment(deptCode: string) {
|
|
try {
|
|
const response = await apiClient.post<{
|
|
success: boolean;
|
|
message?: string;
|
|
data?: { dept_code?: string; restored?: boolean };
|
|
}>(`/departments/${deptCode}/restore`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서 복구 실패:", error);
|
|
return {
|
|
success: false,
|
|
error: error.response?.data?.message || error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서원 목록 조회
|
|
*/
|
|
export async function getDepartmentMembers(deptCode: string) {
|
|
try {
|
|
const response = await apiClient.get<{ success: boolean; data: DepartmentMember[] }>(
|
|
`/departments/${deptCode}/members`,
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서원 목록 조회 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사용자 검색 (부서원 추가용)
|
|
*/
|
|
export async function searchUsers(companyCode: string, search: string) {
|
|
try {
|
|
const response = await apiClient.get<{ success: boolean; data: any[] }>(
|
|
`/departments/companies/${companyCode}/users/search`,
|
|
{ params: { search } },
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("사용자 검색 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서원 추가
|
|
*/
|
|
export async function addDepartmentMember(deptCode: string, userId: string) {
|
|
try {
|
|
const response = await apiClient.post<{ success: boolean; message?: string }>(`/departments/${deptCode}/members`, {
|
|
user_id: userId,
|
|
});
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서원 추가 실패:", error);
|
|
const isDuplicate = error.response?.status === 409;
|
|
return {
|
|
success: false,
|
|
error: error.response?.data?.message || error.message,
|
|
isDuplicate,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부서원 제거
|
|
*/
|
|
export async function removeDepartmentMember(deptCode: string, userId: string) {
|
|
try {
|
|
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}/members/${userId}`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("부서원 제거 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 주 부서 설정
|
|
*/
|
|
export async function setPrimaryDepartment(deptCode: string, userId: string) {
|
|
try {
|
|
const response = await apiClient.put<{ success: boolean }>(`/departments/${deptCode}/members/${userId}/primary`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("주 부서 설정 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 일괄등록 / 일괄업데이트
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export type BulkAction = "create" | "update_department" | "update_manager";
|
|
|
|
export interface BulkPreviewRow extends Record<string, any> {
|
|
row_index: number;
|
|
result: "ok" | "error";
|
|
error_detail: string | null;
|
|
}
|
|
|
|
/**
|
|
* 일괄 미리보기 — read-only validation, write 없음.
|
|
* action 에 따라 create/update_department/update_manager 로 검증.
|
|
* 응답 rows 각 element 에 result(ok|error), error_detail 채워짐.
|
|
*/
|
|
export async function bulkPreviewDepartments(
|
|
companyCode: string,
|
|
action: BulkAction,
|
|
rows: Record<string, any>[],
|
|
) {
|
|
try {
|
|
const response = await apiClient.post<{
|
|
success: boolean;
|
|
data?: { rows: BulkPreviewRow[]; ok_count: number; error_count: number };
|
|
message?: string;
|
|
}>(`/departments/companies/${companyCode}/departments/bulk/preview`, { action, rows });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("일괄 미리보기 실패:", error);
|
|
return {
|
|
success: false,
|
|
error: error.response?.data?.message || error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 일괄등록 적용 (트랜잭션, all-or-nothing).
|
|
* rows 는 보통 미리보기에서 ok 인 row 만 보냄.
|
|
*/
|
|
export async function bulkCreateDepartments(companyCode: string, rows: Record<string, any>[]) {
|
|
try {
|
|
const response = await apiClient.post<{
|
|
success: boolean;
|
|
data?: { inserted: number };
|
|
message?: string;
|
|
}>(`/departments/companies/${companyCode}/departments/bulk/create`, { rows });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("일괄등록 실패:", error);
|
|
return {
|
|
success: false,
|
|
error: error.response?.data?.message || error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 일괄업데이트 적용 (트랜잭션). mode = department | manager.
|
|
* 각 row 에 dept_code 필수.
|
|
*/
|
|
export async function bulkUpdateDepartments(
|
|
companyCode: string,
|
|
mode: "department" | "manager",
|
|
rows: Record<string, any>[],
|
|
) {
|
|
try {
|
|
const response = await apiClient.post<{
|
|
success: boolean;
|
|
data?: { updated: number };
|
|
message?: string;
|
|
}>(`/departments/companies/${companyCode}/departments/bulk/update`, { mode, rows });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("일괄업데이트 실패:", error);
|
|
return {
|
|
success: false,
|
|
error: error.response?.data?.message || error.message,
|
|
};
|
|
}
|
|
}
|