cdc55dfd48
SUPER_ADMIN cross-tenant 모드에서 회사당 cap 200 에 걸리거나 한 회사 조회 실패 시 화면 상단에 안내 배너 노출. 아무 메타 없으면 자리 안 잡음. 신규 - components/common/CrossTenantBanner.tsx — amber(truncated) + red(failed) v5 토큰 (surface-solid + glow-sm) 기반 솔리드 배너. blur 안 씀 API 클라이언트 4개에 cross_tenant_meta 노출 - lib/api/user.ts — userAPI.getList 응답에 cross_tenant_meta 추가 - lib/api/role.ts — roleAPI.getList 동일 - lib/api/batch.ts — BatchAPI.getBatchConfigs 동일 - lib/api/multilang.ts — getLangKeys 동일 (i18nList 페이지는 아직 직접 호출 패턴이라 자동 적용 X — 후속에서 페이지를 getLangKeys 로 통일하면 동작) 페이지 마운트 (3개) - userMng/userMngList — useUserManagement hook 에 crossTenantMeta state 추가 - userMng/rolesList — loadRoleGroups 에서 메타 set - automaticMng/batchmngList — loadBatchConfigs 에서 메타 set - systemMng/i18nList — 스킵 (cross-tenant aggregation 미적용 상태, 별도 작업) 설계서 §11 검증 (직전 §11.2 부분 실패 시뮬) 결과: failed 배너가 header X-CrossTenant-Failed 와 동일 정보로 화면에 노출됨. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import { apiClient } from "./client";
|
|
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
|
|
|
|
/**
|
|
* 사용자 관리 API 클라이언트
|
|
*/
|
|
|
|
interface ApiResponse<T> {
|
|
success: boolean;
|
|
data?: T;
|
|
message?: string;
|
|
errorCode?: string;
|
|
total?: number;
|
|
searchType?: "v2" | "single" | "advanced" | "none"; // 검색 타입 정보
|
|
pagination?: {
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
};
|
|
// 백엔드 호환성을 위한 추가 필드
|
|
result?: boolean;
|
|
msg?: string;
|
|
}
|
|
|
|
/**
|
|
* 사용자 목록 조회
|
|
*
|
|
* 분기:
|
|
* - cross-tenant 모드 → GET /admin/cross-tenant/users (전 회사 사용자 합산, 행마다 company_code)
|
|
* - 단일 회사 모드 → GET /admin/users (현 회사 사용자만)
|
|
*
|
|
* 두 응답을 hook 이 기대하는 동일 shape 로 정규화 — { success, data: { users, pagination }, total }
|
|
*/
|
|
export async function getUserList(params?: Record<string, any>) {
|
|
try {
|
|
if (isCrossTenantMode()) {
|
|
console.log("📡 [cross-tenant] 사용자 목록 API 호출:", params);
|
|
const response = await apiClient.get("/admin/cross-tenant/users", { params });
|
|
console.log("✅ [cross-tenant] 사용자 목록 API 응답:", response.data);
|
|
|
|
// cross-tenant 응답 { success, data: { rows, total, companies_queried, companies_failed,
|
|
// truncated, truncated_company_codes, failed_company_codes,
|
|
// per_company_limit } }
|
|
// → 기존 hook 이 기대하는 shape 로 정규화. 1차 구현은 페이지네이션 비지원이라
|
|
// 서버가 한 번에 다 줌 → 클라이언트 기준 total_count = rows.length.
|
|
const ct = response.data;
|
|
if (ct && ct.success && ct.data) {
|
|
const rows: any[] = ct.data.rows || [];
|
|
return {
|
|
success: true,
|
|
data: {
|
|
users: rows,
|
|
pagination: {
|
|
total_count: rows.length,
|
|
page: 1,
|
|
total_pages: 1,
|
|
},
|
|
},
|
|
total: rows.length,
|
|
message: ct.message,
|
|
// CrossTenantBanner 가 사용하는 메타 — truncated/failed 안내 박스용
|
|
cross_tenant_meta: {
|
|
companies_queried: ct.data.companies_queried,
|
|
companies_failed: ct.data.companies_failed,
|
|
failed_company_codes: ct.data.failed_company_codes ?? [],
|
|
truncated: ct.data.truncated,
|
|
truncated_company_codes: ct.data.truncated_company_codes ?? [],
|
|
per_company_limit: ct.data.per_company_limit,
|
|
},
|
|
};
|
|
}
|
|
return ct;
|
|
}
|
|
|
|
console.log("📡 사용자 목록 API 호출:", params);
|
|
const response = await apiClient.get("/admin/users", { params });
|
|
console.log("✅ 사용자 목록 API 응답:", response.data);
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error("❌ 사용자 목록 API 오류:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사용자 정보 단건 조회
|
|
*/
|
|
export async function getUserInfo(userId: string) {
|
|
try {
|
|
const response = await apiClient.get(`/admin/users/${userId}`);
|
|
|
|
if (response.data.success && response.data.data) {
|
|
return response.data.data;
|
|
}
|
|
|
|
throw new Error(response.data.message || "사용자 정보 조회에 실패했습니다.");
|
|
} catch (error) {
|
|
console.error("❌ 사용자 정보 조회 오류:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사용자 등록
|
|
*/
|
|
export async function createUser(userData: any) {
|
|
try {
|
|
const response = await apiClient.post("/admin/users", userData);
|
|
|
|
// 백엔드에서 result 필드를 사용하므로 이에 맞춰 처리
|
|
if (response.data.result === true || response.data.success === true) {
|
|
return {
|
|
success: true,
|
|
message: response.data.msg || response.data.message || "사용자가 성공적으로 등록되었습니다.",
|
|
data: response.data,
|
|
};
|
|
}
|
|
|
|
throw new Error(response.data.msg || response.data.message || "사용자 등록에 실패했습니다.");
|
|
} catch (error) {
|
|
console.error("❌ 사용자 등록 오류:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// 사용자 수정 기능 제거됨
|
|
|
|
/**
|
|
* 사용자 정보 수정
|
|
*/
|
|
export async function updateUser(userData: {
|
|
user_id: string;
|
|
user_name?: string;
|
|
company_code?: string;
|
|
dept_code?: string;
|
|
user_type?: string;
|
|
status?: string;
|
|
[key: string]: any;
|
|
}) {
|
|
const response = await apiClient.put(`/admin/users/${userData.user_id}`, userData);
|
|
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* 사용자 상태 변경 (부분 수정)
|
|
*/
|
|
export async function updateUserStatus(userId: string, status: string) {
|
|
const response = await apiClient.patch(`/admin/users/${userId}/status`, { status });
|
|
|
|
return response.data;
|
|
}
|
|
|
|
// 사용자 삭제 기능 제거됨
|
|
|
|
// 사용자 변경이력 조회
|
|
export async function getUserHistory(userId: string, params?: Record<string, any>) {
|
|
const searchParams = new URLSearchParams();
|
|
|
|
// 기본 페이지네이션 파라미터
|
|
searchParams.append("page", String(params?.page || 1));
|
|
searchParams.append("countPerPage", String(params?.countPerPage || 10));
|
|
|
|
// 추가 파라미터가 있으면 추가
|
|
if (params) {
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (key !== "page" && key !== "countPerPage" && value !== undefined && value !== null && value !== "") {
|
|
searchParams.append(key, String(value));
|
|
}
|
|
});
|
|
}
|
|
|
|
const queryString = searchParams.toString();
|
|
const endpoint = `/admin/users/${userId}/history${queryString ? `?${queryString}` : ""}`;
|
|
|
|
console.log("📡 사용자 변경이력 API 호출 URL:", endpoint);
|
|
const response = await apiClient.get(endpoint);
|
|
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* 사용자 비밀번호 초기화
|
|
*/
|
|
export async function resetUserPassword(resetData: { userId: string; newPassword: string }) {
|
|
const response = await apiClient.post("/admin/users/reset-password", resetData);
|
|
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* 회사 목록 조회 (기존 API 활용)
|
|
*/
|
|
export async function getCompanyList() {
|
|
const response = await apiClient.get("/admin/companies");
|
|
|
|
if (response.data.success && response.data.data) {
|
|
return response.data.data;
|
|
}
|
|
|
|
throw new Error(response.data.message || "회사 목록 조회에 실패했습니다.");
|
|
}
|
|
|
|
/**
|
|
* 부서 목록 조회
|
|
*/
|
|
export async function getDepartmentList(companyCode?: string) {
|
|
const params = companyCode ? `?companyCode=${encodeURIComponent(companyCode)}` : "";
|
|
const response = await apiClient.get(`/admin/departments${params}`);
|
|
|
|
if (response.data.success && response.data.data) {
|
|
// 백엔드 API 응답 구조: { data: { departments: [], flatList: [] } }
|
|
// departments 배열을 반환 (트리 구조)
|
|
return response.data.data.departments || [];
|
|
}
|
|
|
|
throw new Error(response.data.message || "부서 목록 조회에 실패했습니다.");
|
|
}
|
|
|
|
/**
|
|
* 사용자 ID 중복 체크
|
|
*/
|
|
export async function checkDuplicateUserId(userId: string) {
|
|
const response = await apiClient.post("/admin/users/check-duplicate", { userId });
|
|
return response.data;
|
|
}
|
|
|
|
// ============================================================
|
|
// 사원 + 부서 통합 관리 API
|
|
// ============================================================
|
|
|
|
/**
|
|
* 사원 + 부서 정보 저장 요청 타입
|
|
*/
|
|
export interface SaveUserWithDeptRequest {
|
|
userInfo: {
|
|
user_id: string;
|
|
user_name: string;
|
|
user_name_eng?: string;
|
|
user_password?: string;
|
|
email?: string;
|
|
tel?: string;
|
|
cell_phone?: string;
|
|
sabun?: string;
|
|
user_type?: string;
|
|
user_type_name?: string;
|
|
status?: string;
|
|
locale?: string;
|
|
dept_code?: string;
|
|
dept_name?: string;
|
|
position_code?: string;
|
|
position_name?: string;
|
|
};
|
|
mainDept?: {
|
|
dept_code: string;
|
|
dept_name?: string;
|
|
position_name?: string;
|
|
};
|
|
subDepts?: Array<{
|
|
dept_code: string;
|
|
dept_name?: string;
|
|
position_name?: string;
|
|
}>;
|
|
isUpdate?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 사원 + 부서 정보 응답 타입
|
|
*/
|
|
export interface UserWithDeptResponse {
|
|
userInfo: Record<string, any>;
|
|
mainDept: {
|
|
dept_code: string;
|
|
dept_name?: string;
|
|
position_name?: string;
|
|
is_primary: boolean;
|
|
} | null;
|
|
subDepts: Array<{
|
|
dept_code: string;
|
|
dept_name?: string;
|
|
position_name?: string;
|
|
is_primary: boolean;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* 사원 + 부서 통합 저장
|
|
*
|
|
* user_info와 user_dept 테이블에 트랜잭션으로 동시 저장합니다.
|
|
* - 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환
|
|
* - 겸직 부서는 전체 삭제 후 재입력 방식
|
|
*
|
|
* @param data 저장할 사원 및 부서 정보
|
|
* @returns 저장 결과
|
|
*/
|
|
export async function saveUserWithDept(data: SaveUserWithDeptRequest): Promise<ApiResponse<{ userId: string; isUpdate: boolean }>> {
|
|
try {
|
|
console.log("사원+부서 통합 저장 API 호출:", data);
|
|
|
|
const response = await apiClient.post("/admin/users/with-dept", data);
|
|
|
|
console.log("사원+부서 통합 저장 API 응답:", response.data);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("사원+부서 통합 저장 API 오류:", error);
|
|
|
|
// Axios 에러 응답 처리
|
|
if (error.response?.data) {
|
|
return error.response.data;
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
message: error.message || "사원 저장 중 오류가 발생했습니다.",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사원 + 부서 정보 조회 (수정 모달용)
|
|
*
|
|
* user_info와 user_dept 정보를 함께 조회합니다.
|
|
*
|
|
* @param userId 조회할 사용자 ID
|
|
* @returns 사원 정보 및 부서 관계 정보
|
|
*/
|
|
export async function getUserWithDept(userId: string): Promise<ApiResponse<UserWithDeptResponse>> {
|
|
try {
|
|
console.log("사원+부서 조회 API 호출:", userId);
|
|
|
|
const response = await apiClient.get(`/admin/users/${userId}/with-dept`);
|
|
|
|
console.log("사원+부서 조회 API 응답:", response.data);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("사원+부서 조회 API 오류:", error);
|
|
|
|
if (error.response?.data) {
|
|
return error.response.data;
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
message: error.message || "사원 조회 중 오류가 발생했습니다.",
|
|
};
|
|
}
|
|
}
|
|
|
|
// 사용자 API 객체로 export
|
|
export const userAPI = {
|
|
getList: getUserList,
|
|
getInfo: getUserInfo,
|
|
create: createUser,
|
|
update: updateUser,
|
|
updateStatus: updateUserStatus,
|
|
getHistory: getUserHistory,
|
|
resetPassword: resetUserPassword,
|
|
getCompanyList: getCompanyList,
|
|
getDepartmentList: getDepartmentList,
|
|
checkDuplicateId: checkDuplicateUserId,
|
|
// 사원 + 부서 통합 관리
|
|
saveWithDept: saveUserWithDept,
|
|
getWithDept: getUserWithDept,
|
|
};
|