Files
invyone/frontend/lib/api/multilang.ts
T
hjjeong cdc55dfd48 feat(cross-tenant): truncated/failed 안내 배너 (READ 트랙 마무리)
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>
2026-04-29 17:14:48 +09:00

436 lines
10 KiB
TypeScript

/**
* 다국어 관리 API 클라이언트
* 카테고리, 키 자동 생성, 오버라이드 등 확장 기능 포함
*/
import { apiClient } from "./client";
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
// =====================================================
// 타입 정의
// =====================================================
export interface Language {
lang_code: string;
lang_name: string;
lang_native: string;
is_active: string;
sort_order?: number;
}
export interface LangCategory {
category_id: number;
category_code: string;
category_name: string;
parent_id?: number | null;
level: number;
key_prefix: string;
description?: string;
sort_order: number;
is_active: string;
children?: LangCategory[];
}
export interface LangKey {
keyId?: number;
companyCode: string;
menuName?: string;
langKey: string;
description?: string;
isActive: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdDate?: Date;
}
export interface LangText {
textId?: number;
keyId: number;
langCode: string;
langText: string;
isActive: string;
}
export interface GenerateKeyRequest {
companyCode: string;
categoryId: number;
keyMeaning: string;
usageNote?: string;
texts: Array<{
langCode: string;
langText: string;
}>;
}
export interface CreateOverrideKeyRequest {
companyCode: string;
baseKeyId: number;
texts: Array<{
langCode: string;
langText: string;
}>;
}
export interface KeyPreview {
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}
export interface ApiResponse<T> {
success: boolean;
message?: string;
data?: T;
error?: {
code: string;
details?: any;
};
}
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
* 카테고리 트리 조회
*/
export async function getCategories(): Promise<ApiResponse<LangCategory[]>> {
try {
const response = await apiClient.get("/multilang/categories");
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
* 카테고리 상세 조회
*/
export async function getCategoryById(categoryId: number): Promise<ApiResponse<LangCategory>> {
try {
const response = await apiClient.get(`/multilang/categories/${categoryId}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
* 카테고리 경로 조회 (부모 포함)
*/
export async function getCategoryPath(categoryId: number): Promise<ApiResponse<LangCategory[]>> {
try {
const response = await apiClient.get(`/multilang/categories/${categoryId}/path`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_PATH_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 언어 관련 API
// =====================================================
/**
* 언어 목록 조회
*/
export async function getLanguages(): Promise<ApiResponse<Language[]>> {
try {
const response = await apiClient.get("/multilang/languages");
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "LANGUAGE_FETCH_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 키 관련 API
// =====================================================
/**
* 다국어 키 목록 조회
*
* 분기:
* - cross-tenant 모드 → /admin/cross-tenant/lang-keys
* 응답 행마다 company_code 박혀있어 화면에서 회사 컬럼/필터 가능.
* 1차 구현은 categoryId 재귀 필터 비지원 (필요해지면 후속 추가).
* - 단일 회사 모드 → /multilang/keys (기존)
*/
export async function getLangKeys(params?: {
company_code?: string;
menuCode?: string;
categoryId?: number;
searchText?: string;
}): Promise<ApiResponse<LangKey[]>> {
try {
if (isCrossTenantMode()) {
const ctParams = new URLSearchParams();
// cross-tenant mapper 의 파라미터명 (snake_case) 으로 매핑
if (params?.menuCode) ctParams.append("menu_code", params.menuCode);
if (params?.searchText) ctParams.append("search", params.searchText);
const url = `/admin/cross-tenant/lang-keys${ctParams.toString() ? `?${ctParams.toString()}` : ""}`;
const ctResponse = await apiClient.get(url);
const ct = ctResponse.data;
if (ct && ct.success && ct.data) {
return {
success: true,
data: (ct.data.rows || []) as LangKey[],
// CrossTenantBanner 메타 — ApiResponse 타입엔 없지만 페이지가 캐스팅으로 접근
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,
},
} as ApiResponse<LangKey[]>;
}
}
const queryParams = new URLSearchParams();
if (params?.company_code) queryParams.append("companyCode", params.company_code);
if (params?.menuCode) queryParams.append("menuCode", params.menuCode);
if (params?.categoryId) queryParams.append("categoryId", params.categoryId.toString());
if (params?.searchText) queryParams.append("searchText", params.searchText);
const url = `/multilang/keys${queryParams.toString() ? `?${queryParams.toString()}` : ""}`;
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEYS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
* 키의 텍스트 조회
*/
export async function getLangTexts(keyId: number): Promise<ApiResponse<LangText[]>> {
try {
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "TEXTS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
* 키 자동 생성
*/
export async function generateKey(data: GenerateKeyRequest): Promise<ApiResponse<number>> {
try {
const response = await apiClient.post("/multilang/keys/generate", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_GENERATE_ERROR",
details: error.response?.data?.error?.details || error.message,
},
};
}
}
/**
* 키 미리보기
*/
export async function previewKey(
categoryId: number,
keyMeaning: string,
companyCode: string
): Promise<ApiResponse<KeyPreview>> {
try {
const response = await apiClient.post("/multilang/keys/preview", {
categoryId,
keyMeaning,
companyCode,
});
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_PREVIEW_ERROR",
details: error.message,
},
};
}
}
/**
* 오버라이드 키 생성
*/
export async function createOverrideKey(
data: CreateOverrideKeyRequest
): Promise<ApiResponse<number>> {
try {
const response = await apiClient.post("/multilang/keys/override", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "OVERRIDE_CREATE_ERROR",
details: error.response?.data?.error?.details || error.message,
},
};
}
}
/**
* 회사별 오버라이드 키 목록 조회
*/
export async function getOverrideKeys(companyCode: string): Promise<ApiResponse<LangKey[]>> {
try {
const response = await apiClient.get(`/multilang/keys/overrides/${companyCode}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "OVERRIDE_KEYS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
* 키 텍스트 저장
*/
export async function saveLangTexts(
keyId: number,
texts: Array<{ langCode: string; langText: string }>
): Promise<ApiResponse<string>> {
try {
const response = await apiClient.post(`/multilang/keys/${keyId}/texts`, { texts });
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "TEXTS_SAVE_ERROR",
details: error.message,
},
};
}
}
/**
* 키 삭제
*/
export async function deleteLangKey(keyId: number): Promise<ApiResponse<string>> {
try {
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_DELETE_ERROR",
details: error.message,
},
};
}
}
/**
* 키 상태 토글
*/
export async function toggleLangKey(keyId: number): Promise<ApiResponse<string>> {
try {
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_TOGGLE_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 화면 라벨 다국어 자동 생성 API
// =====================================================
export interface ScreenLabelKeyResult {
componentId: string;
keyId: number;
langKey: string;
}
export interface GenerateScreenLabelKeysRequest {
screenId: number;
menuObjId?: string;
labels: Array<{
componentId: string;
label: string;
type?: string;
}>;
}
/**
* 화면 라벨 다국어 키 자동 생성
*/
export async function generateScreenLabelKeys(
params: GenerateScreenLabelKeysRequest
): Promise<ApiResponse<ScreenLabelKeyResult[]>> {
try {
const response = await apiClient.post("/multilang/screen-labels", params);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error.message,
},
};
}
}