Files
invyone/frontend/components/common/CrossTenantBanner.tsx
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

72 lines
3.1 KiB
TypeScript

"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>
);
}