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>
This commit is contained in:
hjjeong
2026-04-29 17:14:48 +09:00
parent 280e25a4df
commit cdc55dfd48
9 changed files with 136 additions and 6 deletions
@@ -34,6 +34,7 @@ import {
type RecentLog,
} from "@/lib/api/batch";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
import { useTabStore } from "@/stores/tabStore";
function cronToKorean(cron: string): string {
@@ -318,6 +319,7 @@ export default function BatchManagementPage() {
const { openTab } = useTabStore();
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [crossTenantMeta, setCrossTenantMeta] = useState<Record<string, any> | null>(null);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
@@ -336,6 +338,8 @@ export default function BatchManagementPage() {
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
BatchAPI.getBatchStats(),
]);
// cross-tenant 메타 (단일 모드면 undefined → null)
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
if (configsResponse.success && configsResponse.data) {
setBatchConfigs(configsResponse.data);
// 각 배치의 스파크라인을 백그라운드로 로드
@@ -465,6 +469,8 @@ export default function BatchManagementPage() {
</div>
</div>
<CrossTenantBanner meta={crossTenantMeta} />
{/* 통계 요약 스트립 */}
{stats && (
<div className="flex items-center gap-0 rounded-lg border bg-card">
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/select";
import { companyAPI } from "@/lib/api/company";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
import { useMenu } from "@/contexts/MenuContext";
import { cn } from "@/lib/utils";
@@ -88,6 +89,7 @@ export default function RolesPage() {
// 권한 그룹 목록
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
const [crossTenantMeta, setCrossTenantMeta] = useState<Record<string, any> | null>(null);
const [selectedRole, setSelectedRole] = useState<RoleGroup | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
@@ -151,6 +153,8 @@ export default function RolesPage() {
: currentUser?.company_code;
const response = await roleAPI.getList({ companyCode: companyFilter });
// cross-tenant 메타 (단일 모드면 undefined → null)
setCrossTenantMeta((response as any)?.cross_tenant_meta ?? null);
if (response.success && response.data) {
setRoleGroups(response.data);
} else {
@@ -587,6 +591,8 @@ export default function RolesPage() {
</p>
</div>
<CrossTenantBanner meta={crossTenantMeta} />
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-3">
<div className="flex items-center justify-between">
@@ -8,6 +8,7 @@ import { Pagination } from "@/components/common/Pagination";
import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
import { UserFormModal } from "@/components/admin/UserFormModal";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
/**
* 사용자관리 페이지
@@ -26,6 +27,7 @@ export default function UserMngPage() {
isSearching,
error,
paginationInfo,
crossTenantMeta,
// 검색 기능
updateSearchFilter,
@@ -130,6 +132,9 @@ export default function UserMngPage() {
onCreateClick={handleCreateUser}
/>
{/* SUPER_ADMIN cross-tenant 모드의 truncated/failed 안내 */}
<CrossTenantBanner meta={crossTenantMeta} />
{/* 에러 메시지 */}
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-3">
@@ -0,0 +1,71 @@
"use client";
import { AlertTriangle, Info } from "lucide-react";
/**
* SUPER_ADMIN cross-tenant 응답에 truncated/failed 정보가 있을 때 보여주는 안내 배너.
*
* 동작:
* - meta 가 없거나 truncated/failed 둘 다 0 이면 아무것도 렌더링 안 함 (자리 안 잡음)
* - truncated_company_codes 가 있으면 amber 톤 안내 ("N개 회사가 cap 에 걸려 일부만 표시")
* - failed_company_codes 가 있으면 red 톤 경고 ("N개 회사 조회 실패")
* - 두 가지 동시에 있을 수 있어 각각 독립 배너로 노출
*
* v5 디자인 토큰만 사용 — primary-color glow + amber/red 액센트, 솔리드 배경 (blur 금지).
*
* @see notes/hjjeong/2026-04-28-cross-tenant-execution-log.md §3.5
*/
export function CrossTenantBanner({ meta }: { meta?: Record<string, any> | null }) {
if (!meta) return null;
const truncated: string[] = Array.isArray(meta.truncated_company_codes) ? meta.truncated_company_codes : [];
const failed: string[] = Array.isArray(meta.failed_company_codes) ? meta.failed_company_codes : [];
const cap: number | undefined = meta.per_company_limit;
if (truncated.length === 0 && failed.length === 0) return null;
return (
<div className="flex flex-col gap-1.5 mb-2">
{truncated.length > 0 && (
<div
className="flex items-start gap-2 px-3 py-2 rounded-[10px] border text-[0.8125rem]"
style={{
background: "var(--v5-surface-solid)",
borderColor: "rgba(var(--v5-amber-rgb),0.4)",
boxShadow: "0 0 16px rgba(var(--v5-amber-rgb),0.15)",
color: "var(--v5-text)",
}}
>
<Info size={14} style={{ color: "rgb(var(--v5-amber-rgb))", marginTop: 2, flexShrink: 0 }} />
<div className="flex-1 leading-snug">
<span style={{ fontWeight: 600 }}>
{truncated.length} {cap ?? 200} cap
</span>
<span style={{ color: "var(--v5-text-sec)", marginLeft: 6 }}>
({truncated.join(", ")})
</span>
</div>
</div>
)}
{failed.length > 0 && (
<div
className="flex items-start gap-2 px-3 py-2 rounded-[10px] border text-[0.8125rem]"
style={{
background: "var(--v5-surface-solid)",
borderColor: "rgba(var(--v5-red-rgb),0.4)",
boxShadow: "var(--v5-glow-danger)",
color: "var(--v5-text)",
}}
>
<AlertTriangle size={14} style={{ color: "rgb(var(--v5-red-rgb))", marginTop: 2, flexShrink: 0 }} />
<div className="flex-1 leading-snug">
<span style={{ fontWeight: 600 }}>{failed.length} </span>
<span style={{ color: "var(--v5-text-sec)", marginLeft: 6 }}>
({failed.join(", ")}) .
</span>
</div>
</div>
)}
</div>
);
}
+7
View File
@@ -12,6 +12,9 @@ export const useUserManagement = () => {
// 사용자 목록 상태
const [users, setUsers] = useState<User[]>([]);
// SUPER_ADMIN cross-tenant 응답 메타 (truncated/failed 안내 배너용)
const [crossTenantMeta, setCrossTenantMeta] = useState<Record<string, any> | null>(null);
// 검색 필터 상태
const [searchFilter, setSearchFilter] = useState<UserSearchFilter>({});
@@ -159,6 +162,9 @@ export const useUserManagement = () => {
console.log("🔍 검색 파라미터:", searchParams);
const response = await userAPI.getList(searchParams);
// cross-tenant 메타 (truncated/failed 안내 배너에서 사용). 단일 모드면 null.
setCrossTenantMeta((response as any)?.cross_tenant_meta ?? null);
// 백엔드 응답 구조에 맞게 처리 { success, data, total }
if (response && response.success && response.data) {
// 새로운 API 응답 구조: { success, data: { users, pagination } }
@@ -312,6 +318,7 @@ export const useUserManagement = () => {
isSearching,
error,
paginationInfo,
crossTenantMeta,
// 검색 기능
updateSearchFilter,
+10 -1
View File
@@ -201,7 +201,16 @@ export class BatchAPI {
totalPages: 1,
},
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,
},
} as any;
}
}
+9
View File
@@ -203,6 +203,15 @@ export async function getLangKeys(params?: {
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[]>;
}
}
+10 -1
View File
@@ -72,7 +72,16 @@ export const roleAPI = {
success: true,
data: (ct.data.rows || []) as RoleGroup[],
message: ct.message,
};
// 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<RoleGroup[]>;
}
return ct;
}
+12 -4
View File
@@ -38,7 +38,9 @@ export async function getUserList(params?: Record<string, any>) {
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 } }
// 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;
@@ -55,10 +57,16 @@ export async function getUserList(params?: Record<string, any>) {
},
},
total: rows.length,
// 디버깅/UX 용 부가 정보 — 필요 시 화면에서 사용
companies_queried: ct.data.companies_queried,
companies_failed: ct.data.companies_failed,
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;