Files
invyone/frontend/lib/api/user.ts
T
hjjeong 4d19c31440 feat(cross-tenant): 부서 endpoint + UserFormModal 회사-우선 reorder
직전 Phase 1 의 후속 폴리시.

신규 백엔드
- crosstenant/CrossTenantDeptController.java
  GET /api/admin/cross-tenant/departments?company_code=TEST02
  단일 모드 GET /admin/departments 와 응답 형태 동일. company_code query param
  으로 명시된 회사 DB 컨텍스트로 임시 전환해서 부서 트리 반환.
  버그 수정: 메타 DB DEPT_INFO 시드 (qnc/COMPANY_7 등 다른 회사 부서) 가
  TEST02 선택 시에도 dropdown 에 섞여 보이던 문제 해결.

프론트
- lib/api/user.ts — getDepartmentList(companyCode) 가 isCrossTenantMode() 면
  /admin/cross-tenant/departments?company_code= 호출.
  cross-tenant 모드 + companyCode 미지정 → 빈 배열 반환 (회사 안 골랐는데
  메타 부서 보여주는 것 방지).

UserFormModal
- 회사 dropdown 을 폼 가장 위로 이동 — 사용자 ID 중복확인·부서 선택이
  모두 회사에 의존하므로 자연스러운 입력 순서
- SUPER_ADMIN 인데 회사 미선택 상태에선 사용자 ID input + 중복확인 버튼
  disable + placeholder "회사 먼저 선택"
- checkUserIdDuplicate 가드: 회사 미선택이면 "회사를 먼저 선택해주세요"
  (백엔드의 400 "company_code 가 비어있음" 보다 친절)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:38:30 +09:00

408 lines
13 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;
}
}
/**
* 사용자 등록
*
* cross-tenant 모드: body 의 company_code 가 가리키는 회사 DB 에 INSERT.
* 단일 모드: 현재 컨텍스트 (JWT company_code) 의 회사 DB.
*/
export async function createUser(userData: any) {
try {
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/users" : "/admin/users";
const response = await apiClient.post(endpoint, 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;
}
}
// 사용자 수정 기능 제거됨
/**
* 사용자 정보 수정
*
* cross-tenant 모드: body.company_code 가 가리키는 회사 DB 의 USER_INFO 수정.
*/
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 endpoint = isCrossTenantMode()
? `/admin/cross-tenant/users/${userData.user_id}`
: `/admin/users/${userData.user_id}`;
const response = await apiClient.put(endpoint, userData);
return response.data;
}
/**
* 사용자 상태 변경 (부분 수정)
*
* cross-tenant 모드: companyCode 필수 (어느 회사 DB 의 사용자인지 알아야 라우팅).
*/
export async function updateUserStatus(userId: string, status: string, companyCode?: string) {
if (isCrossTenantMode()) {
const response = await apiClient.patch(`/admin/cross-tenant/users/${userId}/status`, {
status,
company_code: companyCode,
});
return response.data;
}
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 || "회사 목록 조회에 실패했습니다.");
}
/**
* 부서 목록 조회
*
* cross-tenant 모드: companyCode 가 가리키는 회사 DB 의 부서. 미선택이면 빈 배열.
* (회사를 안 골랐는데 메타 DB 부서를 보여주면 다른 회사 부서가 섞여 보이는 버그 방지)
* 단일 모드: 기존 /admin/departments — 백엔드가 JWT.company_code 사용.
*/
export async function getDepartmentList(companyCode?: string) {
if (isCrossTenantMode()) {
if (!companyCode) return [];
const response = await apiClient.get(
`/admin/cross-tenant/departments?company_code=${encodeURIComponent(companyCode)}`
);
if (response.data.success && response.data.data) {
return response.data.data.departments || [];
}
throw new Error(response.data.message || "부서 목록 조회에 실패했습니다.");
}
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 중복 체크
*
* cross-tenant 모드: companyCode 필수 — 그 회사 DB 안에서만 중복 체크.
* (회사간 같은 user_id 가 다른 사람이라는 멀티테넌시 전제 — 설계서 §12)
*/
export async function checkDuplicateUserId(userId: string, companyCode?: string) {
if (isCrossTenantMode()) {
const response = await apiClient.post("/admin/cross-tenant/users/check-duplicate", {
user_id: userId,
company_code: companyCode,
});
return response.data;
}
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);
// cross-tenant 모드: data.userInfo 안에 company_code 필수 (회사 DB 라우팅용)
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/users/with-dept" : "/admin/users/with-dept";
const response = await apiClient.post(endpoint, 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,
};