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>
436 lines
10 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|