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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user