vignette/apps/web/src/pages/Admin.tsx
2026-07-03 22:50:14 +09:00

2466 lines
88 KiB
TypeScript

import {
Component,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ErrorInfo,
type ReactNode,
} from "react";
import { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Dot, Icon, Kicker, ProgressBar } from "../components/ui";
import {
adminApi,
adminUsersApi,
type AdminHealthResponse,
type AdminHealthStatus,
type AdminManagedUser,
type AdminTicketFilters,
type AdminSupportTicket,
type AdminTicketsResponse,
type AdminUsageResponse,
type AdminUptimeResponse,
type AdminUserCreateRequest,
type AdminUsersResponse,
} from "../lib/api";
import { useAuth } from "../lib/auth";
import "./admin/admin-console.css";
export type AdminSection = "overview" | "users" | "access" | "tickets";
type UserDraft = Pick<
AdminManagedUser,
"display_name" | "role" | "admin_access" | "account_status" | "affiliation" | "cohort_ids"
>;
type NewUserDraft = Required<Pick<AdminUserCreateRequest, "email" | "display_name" | "role">> &
Pick<UserDraft, "admin_access" | "account_status" | "affiliation" | "cohort_ids">;
type UserTab = "approval" | "manage" | "register" | "activity";
type AccessTab = "roles" | "groups" | "matrix";
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
type AdminDataWarning = {
key: string;
message: string;
detail: string;
};
const MAX_RENDERED_USERS = 40;
const MAX_RESOLVED_TICKET_HISTORY = 6;
const TICKET_STATUS_OPTIONS: Array<{ value: TicketStatusFilter; label: string }> = [
{ value: "all", label: "전체 상태" },
{ value: "open", label: "미해결" },
{ value: "triaged", label: "분류됨" },
{ value: "in_progress", label: "처리 중" },
{ value: "resolved", label: "해결" },
{ value: "closed", label: "종결" },
];
const TICKET_CATEGORY_OPTIONS: Array<{ value: TicketCategoryFilter; label: string }> = [
{ value: "all", label: "전체 카테고리" },
{ value: "account_access", label: "계정/권한" },
{ value: "session_review", label: "세션/리뷰" },
{ value: "voice_browser", label: "음성/브라우저" },
{ value: "content_scenario", label: "콘텐츠/시나리오" },
{ value: "safety", label: "안전" },
{ value: "other", label: "기타" },
];
const TICKET_PRIORITY_OPTIONS: Array<{ value: TicketPriorityFilter; label: string }> = [
{ value: "all", label: "전체 우선순위" },
{ value: "urgent", label: "긴급" },
{ value: "high", label: "높음" },
{ value: "normal", label: "보통" },
{ value: "low", label: "낮음" },
];
const EMPTY_NEW_USER: NewUserDraft = {
email: "",
display_name: "",
role: "learner",
admin_access: false,
account_status: "approved",
affiliation: "",
cohort_ids: [],
};
const ROLE_POLICIES = [
{
role: "관리자",
scope: "운영 상태, 사용자, 권한 정책, 티켓 큐",
permissions: ["서비스 헬스", "사용자 등록", "권한 정책", "운영 티켓"],
risk: "학습자 상담 내용은 기본 운영 화면에서 제외",
},
{
role: "교수자",
scope: "담당 코호트 수업 운영과 회기 리뷰",
permissions: ["담당 학습자 현황", "회기 리뷰", "안전 알림"],
risk: "담당 범위 밖 사용자/시스템 설정 접근 불가",
},
{
role: "학습자",
scope: "본인 회기, 리뷰, 계정 설정",
permissions: ["상담 세션", "본인 리뷰", "개인 설정"],
risk: "타 사용자 데이터와 운영 리소스 접근 불가",
},
];
const GROUP_POLICIES = [
{
name: "서비스 운영자",
scope: "서비스 상태와 장애 티켓 1차 대응",
access: ["운영 콘솔", "티켓 큐", "사용자 읽기"],
},
{
name: "학과 관리자",
scope: "사용자 등록과 코호트 배정",
access: ["사용자 관리", "그룹 배정", "코호트 관리"],
},
{
name: "슈퍼바이저",
scope: "교수자 회기 리뷰 품질 점검",
access: ["교수 콘솔", "안전 알림", "리뷰 읽기"],
},
];
const PERMISSION_MATRIX = [
{ resource: "운영 콘솔", admin: "전체", teacher: "없음", learner: "없음" },
{ resource: "사용자 관리", admin: "등록/수정", teacher: "담당자 읽기", learner: "본인만" },
{ resource: "회기 리뷰", admin: "감사 로그", teacher: "담당 코호트", learner: "본인 회기" },
{ resource: "AI 운영 설정", admin: "수정", teacher: "없음", learner: "없음" },
{ resource: "안전 알림", admin: "전체", teacher: "담당 코호트", learner: "없음" },
];
function toneOf(status: AdminHealthStatus): "pos" | "warn" | "crit" {
if (status === "ok") return "pos";
if (status === "degraded") return "warn";
return "crit";
}
function statusLabel(status: AdminHealthStatus): string {
if (status === "ok") return "정상";
if (status === "degraded") return "제한 운영";
return "중단";
}
function statusMessage(data: AdminHealthResponse | null): string {
if (!data) return "운영 상태를 확인하는 중입니다.";
if (data.status === "ok") return "핵심 서비스가 정상 응답 중입니다.";
if (data.status === "degraded") return "일부 서비스가 제한된 상태입니다.";
return "운영 개입이 필요한 서비스가 있습니다.";
}
function environmentLabel(value: string): string {
if (value === "prod") return "운영";
if (value === "staging") return "스테이징";
if (value === "dev") return "개발";
return value;
}
function engineModeLabel(value: string): string {
if (value === "claude_cli") return "Claude CLI 게이트웨이";
if (value === "claude_api" || value === "messages_api") return "Anthropic API";
if (value === "openai") return "OpenAI 호환";
if (value === "solar") return "Solar";
return value;
}
function roleText(role: AdminManagedUser["role"]): string {
if (role === "admin") return "관리자";
if (role === "teacher") return "교수자";
return "학습자";
}
function roleBadgeTone(role: AdminManagedUser["role"]): "accent" | "neutral" | "warn" {
if (role === "admin") return "warn";
if (role === "teacher") return "accent";
return "neutral";
}
function accountStatusText(status: AdminManagedUser["account_status"]): string {
if (status === "approved") return "승인됨";
if (status === "suspended") return "보류";
return "승인 대기";
}
function accountStatusTone(status: AdminManagedUser["account_status"]): "accent" | "neutral" | "warn" | "crit" {
if (status === "approved") return "accent";
if (status === "suspended") return "crit";
return "warn";
}
function storeLabel(data: AdminUsersResponse | null): string {
if (!data) return "대기 중";
return data.durable ? "DB 사용자" : "비영구 런타임 저장소";
}
function dateTimeLabel(seconds?: number | null): string {
if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return "-";
return new Date(seconds * 1000).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function countLabel(value: number): string {
if (!Number.isFinite(value)) return "0";
return Math.round(value).toLocaleString("ko-KR");
}
function costLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
function usageSourceLabel(data: AdminUsageResponse | null): string {
if (!data) return "대기 중";
return data.durable ? "DB 계량" : "비영구 런타임 계량";
}
function usageBudgetLabel(data: AdminUsageResponse): string {
const { budget } = data;
if (budget.status === "disabled") return "예산 경고 비활성";
if (budget.status === "exceeded") return "예산 초과";
if (budget.status === "warn") return "예산 주의";
return "예산 정상";
}
function usageBudgetDetail(data: AdminUsageResponse): string {
const { budget } = data;
if (budget.status === "disabled") return "ADMIN_USAGE_BUDGET_USD가 설정되지 않았습니다.";
const pct = Math.round(budget.used_ratio * 100);
const remaining =
budget.remaining_usd === null ? "" : ` · 잔여 ${costLabel(budget.remaining_usd)}`;
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
}
function rateLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${Math.round(value * 1000) / 10}%`;
}
function evaluatorCache(data: AdminUsageResponse): AdminUsageResponse["evaluator_cache"] {
return (
data.evaluator_cache ?? {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
}
);
}
function evaluatorCacheLabel(data: AdminUsageResponse): string {
const cache = evaluatorCache(data);
if (!cache.enabled) return "캐시 비활성";
return `${rateLabel(cache.hit_rate)} hit`;
}
function evaluatorCacheDetail(data: AdminUsageResponse): string {
const cache = evaluatorCache(data);
if (!cache.enabled) return "EVALUATOR_SEMANTIC_CACHE 설정이 꺼져 있습니다.";
return `${countLabel(cache.hits)} / ${countLabel(cache.requests)} hit · entry ${countLabel(cache.entries)}개 · 저장 ${countLabel(cache.stores)}`;
}
function usageDailyCost(data: AdminUsageResponse): NonNullable<AdminUsageResponse["daily_cost"]> {
return data.daily_cost ?? [];
}
function usageDayLabel(day: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
if (!match) return day;
return `${match[2]}.${match[3]}`;
}
function uptimeLabel(data: AdminUptimeResponse | null): string {
if (!data) return "-";
if (!data.durable || data.sample_count === 0) return "샘플 없음";
return `${Math.round(data.ok_ratio * 1000) / 10}%`;
}
function lastDownLabel(data: AdminUptimeResponse | null): string {
if (!data) return "-";
if (!data.durable) return "저장소 미연결";
return data.last_down_at ? dateTimeLabel(data.last_down_at) : "기록 없음";
}
function ticketStatusLabel(status: AdminSupportTicket["status"]): string {
if (status === "open") return "미해결";
if (status === "triaged") return "분류됨";
if (status === "in_progress") return "처리 중";
if (status === "resolved") return "해결";
return "종결";
}
function ticketIsActive(ticket: AdminSupportTicket): boolean {
return ticket.status !== "resolved" && ticket.status !== "closed";
}
function ticketPriorityLabel(priority: AdminSupportTicket["priority"]): string {
if (priority === "urgent") return "긴급";
if (priority === "high") return "높음";
if (priority === "normal") return "보통";
return "낮음";
}
function ticketCategoryLabel(category: AdminSupportTicket["category"]): string {
if (category === "account_access") return "계정/권한";
if (category === "session_review") return "세션/리뷰";
if (category === "voice_browser") return "음성/브라우저";
if (category === "content_scenario") return "콘텐츠/시나리오";
if (category === "safety") return "안전";
return "기타";
}
function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" | "crit" {
if (!ticketIsActive(ticket)) return "neutral";
if (ticket.priority === "urgent") return "crit";
if (ticket.priority === "high") return "warn";
return "accent";
}
function shortTicketId(ticketId: string): string {
return ticketId.replace(/-/g, "").slice(0, 8).toUpperCase();
}
function initialOf(user: AdminManagedUser): string {
const label = user.display_name.trim() || user.email;
return Array.from(label)[0]?.toUpperCase() ?? "?";
}
function cohortInputValue(cohortIds: string[]): string {
return cohortIds.join(", ");
}
function parseCohorts(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function stringField(
source: Record<string, unknown>,
field: string,
fallback: string,
warnings: AdminDataWarning[],
label: string,
): string {
const value = source[field];
if (typeof value === "string") return value;
if (value == null) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`,
detail: `${field}=null`,
});
return fallback;
}
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`,
detail: `${field}=${typeof value}`,
});
return String(value);
}
function booleanField(
source: Record<string, unknown>,
field: string,
fallback: boolean,
warnings: AdminDataWarning[],
label: string,
): boolean {
const value = source[field];
if (typeof value === "boolean") return value;
if (value == null) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`,
detail: `${field}=null`,
});
return fallback;
}
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`,
detail: `${field}=${typeof value}`,
});
return fallback;
}
function numberField(
source: Record<string, unknown>,
field: string,
fallback: number,
warnings: AdminDataWarning[],
label: string,
): number {
const value = source[field];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`,
detail: `${field}=string`,
});
return parsed;
}
}
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`,
detail: `${field}=${value == null ? "null" : typeof value}`,
});
return fallback;
}
function cohortIdsField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): string[] {
const value = source.cohort_ids;
if (!Array.isArray(value)) {
warnings.push({
key: `${label}.cohort_ids`,
message: `${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`,
detail: `cohort_ids=${value == null ? "null" : typeof value}`,
});
return [];
}
const normalized = value.filter((item): item is string => typeof item === "string");
if (normalized.length !== value.length) {
warnings.push({
key: `${label}.cohort_ids.items`,
message: `${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`,
detail: `kept=${normalized.length}, raw=${value.length}`,
});
}
return normalized;
}
function numberRecordField(
source: Record<string, unknown>,
field: string,
warnings: AdminDataWarning[],
label: string,
): Record<string, number> {
const value = source[field];
if (value == null) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`,
detail: `${field}=null`,
});
return {};
}
if (typeof value !== "object" || Array.isArray(value)) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`,
detail: `${field}=${typeof value}`,
});
return {};
}
const entries = Object.entries(value as Record<string, unknown>);
const normalized: Record<string, number> = {};
for (const [key, raw] of entries) {
if (typeof raw === "number" && Number.isFinite(raw)) {
normalized[key] = raw;
continue;
}
const parsed = typeof raw === "string" ? Number(raw) : Number.NaN;
if (Number.isFinite(parsed)) {
normalized[key] = parsed;
warnings.push({
key: `${label}.${field}.${key}`,
message: `${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`,
detail: `${field}.${key}=string`,
});
continue;
}
warnings.push({
key: `${label}.${field}.${key}`,
message: `${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`,
detail: `${field}.${key}=${raw == null ? "null" : typeof raw}`,
});
}
return normalized;
}
function roleField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): AdminManagedUser["role"] {
const value = source.role;
if (value === "learner" || value === "teacher" || value === "admin") return value;
warnings.push({
key: `${label}.role`,
message: `${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`,
detail: `role=${String(value)}`,
});
return "learner";
}
function accountStatusField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): AdminManagedUser["account_status"] {
const value = source.account_status;
if (value === "pending" || value === "approved" || value === "suspended") return value;
warnings.push({
key: `${label}.account_status`,
message: `${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`,
detail: `account_status=${String(value)}`,
});
return "pending";
}
function userSourceField(
source: Record<string, unknown>,
parentSource: AdminUsersResponse["source"],
): AdminManagedUser["source"] {
const value = source.source;
return value === "server_session_registry" || value === "database" ? value : parentSource;
}
function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
response: AdminUsersResponse;
warnings: AdminDataWarning[];
} {
const warnings: AdminDataWarning[] = [];
const root = asRecord(rawResponse);
const source: AdminUsersResponse["source"] =
root.source === "server_session_registry" ? "server_session_registry" : "database";
const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
const rawUsers = Array.isArray(root.users) ? root.users : [];
if (!Array.isArray(root.users)) {
warnings.push({
key: "admin.users",
message: "/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.",
detail: `users=${root.users == null ? "null" : typeof root.users}`,
});
}
const users = rawUsers.map((item, index): AdminManagedUser => {
const sourceRecord = asRecord(item);
const label = `users[${index}]`;
const userId = stringField(sourceRecord, "user_id", `unknown-${index + 1}`, warnings, label);
return {
user_id: userId,
email: stringField(sourceRecord, "email", userId, warnings, label),
display_name: stringField(sourceRecord, "display_name", userId, warnings, label),
role: roleField(sourceRecord, warnings, label),
admin_access: booleanField(sourceRecord, "admin_access", false, warnings, label),
super_admin: booleanField(sourceRecord, "super_admin", false, warnings, label),
account_status: accountStatusField(sourceRecord, warnings, label),
cohort_ids: cohortIdsField(sourceRecord, warnings, label),
affiliation: stringField(sourceRecord, "affiliation", "", warnings, label),
active_sessions: numberField(sourceRecord, "active_sessions", 0, warnings, label),
created_at: numberField(sourceRecord, "created_at", 0, warnings, label),
last_seen_at: numberField(sourceRecord, "last_seen_at", 0, warnings, label),
source: userSourceField(sourceRecord, source),
};
});
return {
response: {
source,
durable,
users,
},
warnings,
};
}
function normalizeAdminTicketsResponse(rawResponse: AdminTicketsResponse): {
response: AdminTicketsResponse;
warnings: AdminDataWarning[];
} {
const warnings: AdminDataWarning[] = [];
const root = asRecord(rawResponse);
const source: AdminTicketsResponse["source"] = root.source === "database" ? "database" : "unavailable";
const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
const rawTickets = Array.isArray(root.tickets) ? root.tickets : [];
if (!Array.isArray(root.tickets)) {
warnings.push({
key: "admin.tickets",
message: "/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.",
detail: `tickets=${root.tickets == null ? "null" : typeof root.tickets}`,
});
}
const summaryRoot = asRecord(root.summary);
if (!root.summary || typeof root.summary !== "object" || Array.isArray(root.summary)) {
warnings.push({
key: "admin.tickets.summary",
message: "/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.",
detail: `summary=${root.summary == null ? "null" : typeof root.summary}`,
});
}
return {
response: {
source,
durable,
generated_at: numberField(root, "generated_at", 0, warnings, "admin.tickets"),
tickets: rawTickets as AdminSupportTicket[],
summary: {
total: numberField(summaryRoot, "total", rawTickets.length, warnings, "admin.tickets.summary"),
open_count: numberField(summaryRoot, "open_count", 0, warnings, "admin.tickets.summary"),
high_priority_count: numberField(summaryRoot, "high_priority_count", 0, warnings, "admin.tickets.summary"),
stale_count: numberField(summaryRoot, "stale_count", 0, warnings, "admin.tickets.summary"),
by_category: numberRecordField(summaryRoot, "by_category", warnings, "admin.tickets.summary"),
by_priority: numberRecordField(summaryRoot, "by_priority", warnings, "admin.tickets.summary"),
by_status: numberRecordField(summaryRoot, "by_status", warnings, "admin.tickets.summary"),
},
},
warnings,
};
}
function isOnline(seconds?: number | null): boolean {
if (!seconds || seconds <= 0) return false;
return Date.now() / 1000 - seconds < 15 * 60;
}
interface PageHeaderProps {
kicker: string;
title: string;
description: string;
children?: ReactNode;
}
function PageHeader({ kicker, title, description, children }: PageHeaderProps) {
return (
<header className="ad-head">
<div>
<Kicker>{kicker}</Kicker>
<h1>{title}</h1>
<p>{description}</p>
</div>
{children ? <div className="ad-head__actions">{children}</div> : null}
</header>
);
}
function runtimeAssetLabel(): string {
if (typeof document === "undefined") return "unknown";
const script = Array.from(document.scripts)
.map((item) => item.getAttribute("src") ?? "")
.find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
if (!script) return "unknown";
return script.split("/").pop() ?? script;
}
function AdminDiagnosticPanel({
title,
body,
details,
}: {
title: string;
body: string;
details: Array<[string, string]>;
}) {
return (
<section className="ad-diagnostic" role="alert">
<div>
<Kicker> </Kicker>
<h1>{title}</h1>
<p>{body}</p>
</div>
<dl>
{details.map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</section>
);
}
interface AdminErrorBoundaryProps {
section: AdminSection;
children: ReactNode;
}
interface AdminErrorBoundaryState {
error: Error | null;
errorInfo: ErrorInfo | null;
}
class AdminErrorBoundary extends Component<AdminErrorBoundaryProps, AdminErrorBoundaryState> {
state: AdminErrorBoundaryState = { error: null, errorInfo: null };
static getDerivedStateFromError(error: Error): AdminErrorBoundaryState {
return { error, errorInfo: null };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ errorInfo });
console.error("[admin-render-error]", error, errorInfo.componentStack);
}
componentDidUpdate(prevProps: AdminErrorBoundaryProps) {
if (prevProps.section !== this.props.section && this.state.error) {
this.setState({ error: null, errorInfo: null });
}
}
render() {
if (this.state.error) {
return (
<AdminDiagnosticPanel
title="관리자 화면을 표시하지 못했습니다"
body="렌더 중 예외가 발생했습니다. 빈 화면 대신 아래 진단값을 남깁니다."
details={[
["section", this.props.section],
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
["error", this.state.error.message || this.state.error.name],
["asset", runtimeAssetLabel()],
["componentStack", this.state.errorInfo?.componentStack?.trim() || "not captured"],
]}
/>
);
}
return this.props.children;
}
}
function AdminBlankContentProbe({ section }: { section: AdminSection }) {
const markerRef = useRef<HTMLSpanElement | null>(null);
const [diagnostic, setDiagnostic] = useState<Array<[string, string]> | null>(null);
useEffect(() => {
setDiagnostic(null);
const timer = window.setTimeout(() => {
const root = markerRef.current?.closest(".ad-root") as HTMLElement | null;
if (!root) return;
const text = root.innerText.replace(/\s+/g, " ").trim();
const rootRect = root.getBoundingClientRect();
const visibleNodes = Array.from(
root.querySelectorAll<HTMLElement>(
"h1,h2,p,button,input,select,article,section,.ad-head,.ad-panel,.ad-section,.ad-users-note,.ad-status",
),
).filter((element) => {
if (element.classList.contains("ad-diagnostic")) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 1 &&
rect.height > 1 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
});
if (text.length === 0 || rootRect.height < 12 || visibleNodes.length === 0) {
setDiagnostic([
["section", section],
["path", window.location.pathname],
["rootHeight", `${Math.round(rootRect.height)}px`],
["visibleNodes", String(visibleNodes.length)],
["scrollY", String(Math.round(window.scrollY))],
["asset", runtimeAssetLabel()],
]);
}
}, 800);
return () => window.clearTimeout(timer);
}, [section]);
return (
<>
<span ref={markerRef} className="ad-blank-probe" aria-hidden="true" />
{diagnostic ? (
<AdminDiagnosticPanel
title="관리자 본문이 비어 있습니다"
body="라우트는 열렸지만 본문 DOM이 표시 가능한 콘텐츠를 만들지 못했습니다."
details={diagnostic}
/>
) : null}
</>
);
}
function AdminSectionRenderer({
section,
diagnostics,
renderOverview,
renderUsers,
renderAccess,
renderTickets,
}: {
section: AdminSection;
diagnostics: ReactNode;
renderOverview: () => ReactNode;
renderUsers: () => ReactNode;
renderAccess: () => ReactNode;
renderTickets: () => ReactNode;
}) {
let content: ReactNode = null;
if (section === "overview") content = renderOverview();
if (section === "users") content = renderUsers();
if (section === "access") content = renderAccess();
if (section === "tickets") content = renderTickets();
return (
<div className="ad-root" data-admin-section={section}>
{diagnostics}
{content ?? (
<AdminDiagnosticPanel
title="관리자 라우트 섹션을 찾지 못했습니다"
body="라우터가 알 수 없는 관리자 섹션을 전달했습니다."
details={[
["section", section],
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
["asset", runtimeAssetLabel()],
]}
/>
)}
<AdminBlankContentProbe section={section} />
</div>
);
}
interface AdminProps {
section?: AdminSection;
}
export default function Admin({ section = "overview" }: AdminProps) {
const { user: currentUser } = useAuth();
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [usersData, setUsersData] = useState<AdminUsersResponse | null>(null);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState<string | null>(null);
const [usersDiagnostics, setUsersDiagnostics] = useState<AdminDataWarning[]>([]);
const [userDrafts, setUserDrafts] = useState<Record<string, UserDraft>>({});
const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [newUser, setNewUser] = useState<NewUserDraft>(EMPTY_NEW_USER);
const [creatingUser, setCreatingUser] = useState(false);
const [deactivatingUserId, setDeactivatingUserId] = useState<string | null>(null);
const [userSearch, setUserSearch] = useState("");
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
const [usageLoading, setUsageLoading] = useState(true);
const [usageError, setUsageError] = useState<string | null>(null);
const [uptime, setUptime] = useState<AdminUptimeResponse | null>(null);
const [uptimeLoading, setUptimeLoading] = useState(true);
const [uptimeError, setUptimeError] = useState<string | null>(null);
const [tickets, setTickets] = useState<AdminTicketsResponse | null>(null);
const [ticketsLoading, setTicketsLoading] = useState(true);
const [ticketsError, setTicketsError] = useState<string | null>(null);
const [ticketsDiagnostics, setTicketsDiagnostics] = useState<AdminDataWarning[]>([]);
const [updatingTicketId, setUpdatingTicketId] = useState<string | null>(null);
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>("all");
const [ticketCategoryFilter, setTicketCategoryFilter] = useState<TicketCategoryFilter>("all");
const [ticketPriorityFilter, setTicketPriorityFilter] = useState<TicketPriorityFilter>("all");
const [ticketSearch, setTicketSearch] = useState("");
const [ticketAssignedGroup, setTicketAssignedGroup] = useState("");
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
const [userTab, setUserTab] = useState<UserTab>("approval");
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
const canGrantAdminAccess = currentUser?.superAdmin === true;
const loadHealth = useCallback(async () => {
setLoading(true);
setError(null);
try {
const next = await adminApi.health();
setHealth(next);
setUpdatedAt(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "운영 상태를 불러오지 못했습니다.");
} finally {
setLoading(false);
}
}, []);
const loadUsers = useCallback(async () => {
setUsersLoading(true);
setUsersError(null);
try {
const next = await adminUsersApi.list();
const { response: normalized, warnings } = normalizeAdminUsersResponse(next);
setUsersData(normalized);
setUsersDiagnostics(warnings);
setUserDrafts((current) =>
Object.fromEntries(
normalized.users.map((user) => [
user.user_id,
current[user.user_id] ?? {
display_name: user.display_name,
role: user.role,
admin_access: user.admin_access,
account_status: user.account_status,
affiliation: user.affiliation,
cohort_ids: user.cohort_ids,
},
]),
),
);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "사용자 목록을 불러오지 못했습니다.");
setUsersDiagnostics([]);
} finally {
setUsersLoading(false);
}
}, []);
const loadUsage = useCallback(async () => {
setUsageLoading(true);
setUsageError(null);
try {
setUsage(await adminApi.usage(7));
} catch (err) {
setUsageError(err instanceof Error ? err.message : "비용 사용량을 불러오지 못했습니다.");
} finally {
setUsageLoading(false);
}
}, []);
const loadUptime = useCallback(async () => {
setUptimeLoading(true);
setUptimeError(null);
try {
setUptime(await adminApi.uptime(24));
} catch (err) {
setUptimeError(err instanceof Error ? err.message : "업타임 이력을 불러오지 못했습니다.");
} finally {
setUptimeLoading(false);
}
}, []);
const loadTickets = useCallback(async () => {
setTicketsLoading(true);
setTicketsError(null);
try {
const filters: AdminTicketFilters = {
status: ticketStatusFilter === "all" ? "" : ticketStatusFilter,
category: ticketCategoryFilter === "all" ? "" : ticketCategoryFilter,
priority: ticketPriorityFilter === "all" ? "" : ticketPriorityFilter,
assignedGroup: ticketAssignedGroup,
staleOnly: ticketStaleOnly,
search: ticketSearch,
windowDays: 30,
};
const next = await adminApi.tickets(filters);
const { response: normalized, warnings } = normalizeAdminTicketsResponse(next);
setTickets(normalized);
setTicketsDiagnostics(warnings);
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
setTicketsDiagnostics([]);
} finally {
setTicketsLoading(false);
}
}, [
ticketAssignedGroup,
ticketCategoryFilter,
ticketPriorityFilter,
ticketSearch,
ticketStaleOnly,
ticketStatusFilter,
]);
useEffect(() => {
void loadHealth();
void loadUsers();
void loadUsage();
void loadUptime();
void loadTickets();
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
const refreshAll = useCallback(async () => {
await Promise.all([loadHealth(), loadUsers(), loadUsage(), loadUptime(), loadTickets()]);
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
setUserDrafts((current) => ({
...current,
[userId]: {
...(current[userId] ?? {
display_name: "",
role: "learner",
admin_access: false,
account_status: "approved",
affiliation: "",
cohort_ids: [],
}),
...patch,
},
}));
};
const saveUser = async (user: AdminManagedUser) => {
const draft = userDrafts[user.user_id];
if (!draft) return;
setSavingUserId(user.user_id);
setUsersError(null);
try {
const updated = await adminUsersApi.update(user.user_id, draft);
setUsersData((current) =>
current
? {
...current,
users: current.users.map((item) =>
item.user_id === updated.user_id ? updated : item,
),
}
: current,
);
setUserDrafts((current) => ({
...current,
[updated.user_id]: {
display_name: updated.display_name,
role: updated.role,
admin_access: updated.admin_access,
account_status: updated.account_status,
affiliation: updated.affiliation,
cohort_ids: updated.cohort_ids,
},
}));
} catch (err) {
setUsersError(err instanceof Error ? err.message : "사용자 정보를 저장하지 못했습니다.");
} finally {
setSavingUserId(null);
}
};
const createUser = async () => {
const email = newUser.email.trim().toLowerCase();
const displayName = newUser.display_name.trim();
if (!email || !displayName) {
setUsersError("이메일과 표시 이름을 입력하세요.");
return;
}
setCreatingUser(true);
setUsersError(null);
try {
await adminUsersApi.create({
...newUser,
email,
display_name: displayName,
affiliation: newUser.affiliation.trim(),
cohort_ids: newUser.cohort_ids,
});
setNewUser(EMPTY_NEW_USER);
await loadUsers();
setUserTab("manage");
} catch (err) {
setUsersError(err instanceof Error ? err.message : "사용자를 등록하지 못했습니다.");
} finally {
setCreatingUser(false);
}
};
const updateUserAccountStatus = async (
user: AdminManagedUser,
accountStatus: AdminManagedUser["account_status"],
) => {
setSavingUserId(user.user_id);
setUsersError(null);
try {
const updated = await adminUsersApi.update(user.user_id, {
account_status: accountStatus,
});
setUsersData((current) =>
current
? {
...current,
users: current.users.map((item) =>
item.user_id === updated.user_id ? updated : item,
),
}
: current,
);
setUserDrafts((current) => ({
...current,
[updated.user_id]: {
display_name: updated.display_name,
role: updated.role,
admin_access: updated.admin_access,
account_status: updated.account_status,
affiliation: updated.affiliation,
cohort_ids: updated.cohort_ids,
},
}));
} catch (err) {
setUsersError(err instanceof Error ? err.message : "계정 승인 상태를 저장하지 못했습니다.");
} finally {
setSavingUserId(null);
}
};
const deactivateUser = async (user: AdminManagedUser) => {
setDeactivatingUserId(user.user_id);
setUsersError(null);
try {
await adminUsersApi.deactivate(user.user_id);
setUsersData((current) =>
current
? {
...current,
users: current.users.filter((item) => item.user_id !== user.user_id),
}
: current,
);
setUserDrafts((current) => {
const next = { ...current };
delete next[user.user_id];
return next;
});
} catch (err) {
setUsersError(err instanceof Error ? err.message : "사용자를 비활성화하지 못했습니다.");
} finally {
setDeactivatingUserId(null);
}
};
const updateTicketStatus = async (
ticket: AdminSupportTicket,
status: AdminSupportTicket["status"],
) => {
setUpdatingTicketId(ticket.ticket_id);
setTicketsError(null);
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, { status });
setTickets((current) =>
current
? {
...current,
tickets: current.tickets.map((item) =>
item.ticket_id === updated.ticket_id ? updated : item,
),
}
: current,
);
await loadTickets();
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "티켓 상태를 저장하지 못했습니다.");
} finally {
setUpdatingTicketId(null);
}
};
const updateTicketParent = async (
ticket: AdminSupportTicket,
parentTicketId: string | null,
) => {
setUpdatingTicketId(ticket.ticket_id);
setTicketsError(null);
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, {
parent_ticket_id: parentTicketId ?? "",
});
setTickets((current) =>
current
? {
...current,
tickets: current.tickets.map((item) =>
item.ticket_id === updated.ticket_id ? updated : item,
),
}
: current,
);
await loadTickets();
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "중복 연결을 저장하지 못했습니다.");
} finally {
setUpdatingTicketId(null);
}
};
const services = health?.services ?? [];
const users = usersData?.users ?? [];
const counts = useMemo(
() => ({
ok: services.filter((service) => service.status === "ok").length,
degraded: services.filter((service) => service.status === "degraded").length,
down: services.filter((service) => service.status === "down").length,
total: services.length,
}),
[services],
);
const roleCounts = useMemo(
() =>
users.reduce<Record<AdminManagedUser["role"], number>>(
(acc, user) => {
acc[user.role] += 1;
return acc;
},
{ learner: 0, teacher: 0, admin: 0 },
),
[users],
);
const activeSessions = useMemo(
() => users.reduce((sum, user) => sum + Math.max(0, user.active_sessions), 0),
[users],
);
const accountCounts = useMemo(
() =>
users.reduce<Record<AdminManagedUser["account_status"], number>>(
(acc, user) => {
acc[user.account_status] += 1;
return acc;
},
{ pending: 0, approved: 0, suspended: 0 },
),
[users],
);
const pendingUsers = useMemo(
() =>
users
.filter((user) => user.account_status === "pending")
.sort((a, b) => b.created_at - a.created_at),
[users],
);
const onlineUsers = useMemo(
() => users.filter((user) => isOnline(user.last_seen_at)).length,
[users],
);
const incidentCount = counts.degraded + counts.down;
const healthTone = health ? toneOf(health.status) : "warn";
const usersWritable = usersData?.durable === true;
const ticketSummary = tickets?.summary;
const openTickets = ticketSummary?.open_count ?? 0;
const activeTickets = useMemo(
() => (tickets?.tickets ?? []).filter(ticketIsActive),
[tickets],
);
const recentResolvedTickets = useMemo(
() =>
(tickets?.tickets ?? [])
.filter((ticket) => !ticketIsActive(ticket))
.slice(0, MAX_RESOLVED_TICKET_HISTORY),
[tickets],
);
const categoryQueues = useMemo(
() =>
Object.entries(tickets?.summary.by_category ?? {})
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]),
[tickets],
);
const hasTicketFilters =
ticketStatusFilter !== "all" ||
ticketCategoryFilter !== "all" ||
ticketPriorityFilter !== "all" ||
ticketStaleOnly ||
ticketAssignedGroup.trim().length > 0 ||
ticketSearch.trim().length > 0;
const clearTicketFilters = () => {
setTicketStatusFilter("all");
setTicketCategoryFilter("all");
setTicketPriorityFilter("all");
setTicketStaleOnly(false);
setTicketAssignedGroup("");
setTicketSearch("");
};
const filteredUsers = useMemo(() => {
const query = userSearch.trim().toLowerCase();
if (!query) return users;
return users.filter((user) =>
[
user.email,
user.display_name,
user.role,
roleText(user.role),
user.admin_access ? "관리자 권한" : "",
user.super_admin ? "슈퍼 관리자" : "",
accountStatusText(user.account_status),
user.affiliation,
cohortInputValue(user.cohort_ids),
]
.join(" ")
.toLowerCase()
.includes(query),
);
}, [userSearch, users]);
const hasUserSearch = userSearch.trim().length > 0;
const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS);
const renderAdminDiagnostics = () =>
[...usersDiagnostics, ...ticketsDiagnostics].length > 0 ? (
<AdminDiagnosticPanel
title="관리자 데이터 진단"
body="관리자 API 응답 일부가 화면 계약과 달라 기본값으로 보정했습니다. 빈 화면 대신 어떤 필드가 문제인지 표시합니다."
details={[
["section", section],
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
["asset", runtimeAssetLabel()],
...[...usersDiagnostics, ...ticketsDiagnostics].slice(0, 8).map((warning): [string, string] => [
warning.key,
`${warning.message} (${warning.detail})`,
]),
...(usersDiagnostics.length + ticketsDiagnostics.length > 8
? [["more", `${usersDiagnostics.length + ticketsDiagnostics.length - 8}개 추가 진단 생략`] as [
string,
string,
]]
: []),
]}
/>
) : null;
const renderOverview = () => (
<>
<PageHeader
kicker="운영 콘솔"
title="현재 서비스 상태"
description="교육 진행, 접속자, 서비스 헬스, 비용, 운영 이슈를 한 화면에서 판단합니다."
>
<Button
variant="secondary"
leading={<Icon name="settings" size={16} />}
onClick={() => void refreshAll()}
disabled={loading || usersLoading || usageLoading}
>
{loading || usersLoading || usageLoading ? "확인 중" : "새로고침"}
</Button>
</PageHeader>
<section className={`ad-status ad-status--${health?.status ?? "degraded"}`} role="status">
<span className="ad-status__dot" aria-hidden="true" />
<div>
<b>{statusMessage(health)}</b>
<span>
{health
? `${environmentLabel(health.environment)} · ${engineModeLabel(health.engine_mode)}`
: "연결 확인 중"}
</span>
</div>
<time>{updatedAt ? updatedAt.toLocaleTimeString("ko-KR") : ""}</time>
</section>
<section className="ad-kpis" aria-label="운영 요약">
<div className="ad-kpi">
<span className="ad-kpi__lab"> </span>
<b>{countLabel(activeSessions)}</b>
<small> </small>
</div>
<div className="ad-kpi">
<span className="ad-kpi__lab"> </span>
<b>{countLabel(onlineUsers)}</b>
<small> 15 </small>
</div>
<div className="ad-kpi">
<span className="ad-kpi__lab"> </span>
<b>{countLabel(users.length)}</b>
<small>{storeLabel(usersData)}</small>
</div>
<div className="ad-kpi">
<span className="ad-kpi__lab"> </span>
<b>{counts.total ? `${counts.ok}/${counts.total}` : "-"}</b>
<small>{incidentCount ? `${incidentCount}개 점검` : "정상"}</small>
</div>
</section>
<section className="ad-overview-grid">
<div className="ad-panel ad-panel--wide">
<div className="ad-section__head">
<h2> </h2>
<span> API </span>
</div>
<div className="ad-flow-grid">
<article className="ad-flow">
<span> </span>
<b>{countLabel(activeSessions)}</b>
<small> </small>
</article>
<article className="ad-flow">
<span> </span>
<b>{countLabel(users.filter((user) => user.role === "teacher" && isOnline(user.last_seen_at)).length)}</b>
<small>· </small>
</article>
<article className="ad-flow">
<span> </span>
<b>{countLabel(users.filter((user) => user.role === "admin" && isOnline(user.last_seen_at)).length)}</b>
<small> </small>
</article>
<article className="ad-flow">
<span>AI </span>
<b>{usage ? countLabel(usage.metered_turns) : "-"}</b>
<small>{usage ? `최근 ${usage.window_days}` : "계산 중"}</small>
</article>
</div>
</div>
<div className="ad-panel">
<div className="ad-section__head">
<h2></h2>
<Badge tone={healthTone}>{health ? statusLabel(health.status) : "확인 중"}</Badge>
</div>
<div className="ad-availability">
<div>
<span> </span>
<b>{health ? statusLabel(health.status) : "-"}</b>
</div>
<div>
<span> </span>
<b>{uptimeLoading ? "확인 중" : uptimeLabel(uptime)}</b>
</div>
<div>
<span> </span>
<b>{lastDownLabel(uptime)}</b>
</div>
</div>
{uptimeError ? <InlineError message={uptimeError} /> : null}
</div>
<div className="ad-panel">
<div className="ad-section__head">
<h2>AI </h2>
<span>{usageSourceLabel(usage)}</span>
</div>
{usageError ? <InlineError message={usageError} /> : null}
<div className="ad-cost">
<b>{usage ? costLabel(usage.cost_usd) : "-"}</b>
<span>{usage ? usageBudgetLabel(usage) : "계산 중"}</span>
{usage ? <small>{usageBudgetDetail(usage)}</small> : null}
</div>
{usage ? <ProgressBar value={Math.min(100, Math.round(usage.budget.used_ratio * 100))} slim /> : null}
{usage ? (
<div className="ad-cache">
<span> hit-rate</span>
<b>{evaluatorCacheLabel(usage)}</b>
<small>{evaluatorCacheDetail(usage)}</small>
</div>
) : null}
{usage && usageDailyCost(usage).length > 0 ? (
<div className="ad-cost-trend">
<span> </span>
{usageDailyCost(usage)
.slice(-7)
.map((day) => (
<div key={day.day}>
<b>{usageDayLabel(day.day)}</b>
<span>
{costLabel(day.cost_usd)} · {countLabel(day.turns)}
</span>
</div>
))}
</div>
) : null}
</div>
<div className="ad-panel">
<div className="ad-section__head">
<h2> </h2>
<Badge tone={openTickets ? "warn" : "neutral"}>{tickets?.durable ? "DB 큐" : "대기 중"}</Badge>
</div>
{ticketsError ? <InlineError message={ticketsError} /> : null}
{ticketsLoading && !tickets ? (
<EmptyState title="티켓 큐를 불러오는 중입니다" body="사용자 문제 접수 모델에서 미해결 건수를 확인합니다." />
) : null}
{!ticketsLoading && tickets && activeTickets.length === 0 ? (
<EmptyState title="미해결 티켓이 없습니다" body="사용자 문의나 장애 접수가 들어오면 이곳에 표시됩니다." />
) : null}
{tickets && activeTickets.length > 0 ? (
<div className="ad-ticket-mini">
<div>
<b>{countLabel(tickets.summary.open_count)} </b>
<span>
{countLabel(tickets.summary.high_priority_count)} · 24 {" "}
{countLabel(tickets.summary.stale_count)}
</span>
</div>
{activeTickets.slice(0, 2).map((ticket) => (
<div key={ticket.ticket_id}>
<b>{ticket.subject}</b>
<span>
{ticketStatusLabel(ticket.status)} · {ticketPriorityLabel(ticket.priority)} ·{" "}
{ticket.reporter.email}
</span>
</div>
))}
</div>
) : null}
</div>
</section>
{error ? <InlineError message={error} /> : null}
<section className="ad-section">
<div className="ad-section__head">
<h2> </h2>
<span>{health ? `${health.services.length}개 항목` : "대기 중"}</span>
</div>
{renderServices()}
</section>
</>
);
const renderServices = () => (
<div className="ad-services">
{services.map((service) => (
<article className={`ad-service ad-service--${service.status}`} key={service.key}>
<div className="ad-service__top">
<div className="ad-service__name">
<Dot tone={toneOf(service.status)} />
<b>{service.name}</b>
</div>
<Badge tone={toneOf(service.status)}>{statusLabel(service.status)}</Badge>
</div>
<p>{service.detail}</p>
<div className="ad-service__meter">
<ProgressBar
value={service.load}
tone={
service.status === "down"
? "crit"
: service.status === "degraded"
? "warn"
: "accent"
}
slim
label={`${service.name} 상태`}
/>
<span>{service.metric}</span>
</div>
</article>
))}
{loading && !health
? Array.from({ length: 3 }).map((_, index) => (
<article className="ad-service ad-service--skeleton" key={index} aria-hidden="true">
<span className="ad-skel ad-skel--name" />
<span className="ad-skel ad-skel--detail" />
<span className="ad-skel ad-skel--meter" />
</article>
))
: null}
{!loading && health && health.services.length === 0 ? (
<div className="ad-empty"> .</div>
) : null}
</div>
);
const renderUsers = () => (
<>
<PageHeader
kicker="사용자 관리"
title="가입 승인과 권한 관리"
description="신규 가입은 승인 큐에서 처리하고, 역할과 코호트 변경은 사용자 목록에서 관리합니다."
/>
{usersError ? <InlineError message={usersError} /> : null}
<TabBar
ariaLabel="사용자 관리 탭"
items={[
["approval", `가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`],
["manage", "사용자 목록"],
["register", "사용자 등록"],
["activity", "활동 요약"],
]}
value={userTab}
onChange={setUserTab}
/>
{userTab === "approval" ? renderApprovalQueue() : null}
{userTab === "manage" ? renderUserList() : null}
{userTab === "register" ? renderUserCreate() : null}
{userTab === "activity" ? renderUserActivity() : null}
</>
);
const renderApprovalQueue = () => (
<section className="ad-section">
<div className="ad-approval-head">
<div>
<h2> </h2>
<p> pending .</p>
</div>
<div className="ad-approval-metrics" aria-label="계정 승인 상태 요약">
<span>
<b>{accountCounts.pending}</b>
</span>
<span>
<b>{accountCounts.approved}</b>
</span>
<span>
<b>{accountCounts.suspended}</b>
</span>
</div>
</div>
{!usersWritable ? (
<div className="ad-users-note ad-users-note--warn" role="alert">
DB .
</div>
) : null}
{usersLoading && !usersData ? (
<div className="ad-users">
{Array.from({ length: 2 }).map((_, index) => (
<article className="ad-user ad-user--skeleton" key={index} aria-hidden="true">
<span className="ad-skel ad-skel--name" />
<span className="ad-skel ad-skel--detail" />
<span className="ad-skel ad-skel--meter" />
<span className="ad-skel ad-skel--action" />
</article>
))}
</div>
) : null}
{!usersLoading && pendingUsers.length === 0 ? (
<EmptyState
title="처리할 가입 요청이 없습니다"
body="새 사용자가 로그인하면 이곳에 승인 대기 항목으로 표시됩니다."
/>
) : null}
{pendingUsers.length > 0 ? (
<div className="ad-approval-list">
{pendingUsers.map((user) => (
<article className="ad-approval" key={user.user_id}>
<div className="ad-user__id">
<span aria-hidden="true">{initialOf(user)}</span>
<div>
<b>{user.display_name}</b>
<p>{user.email}</p>
</div>
</div>
<div className="ad-approval__meta">
<Badge tone={accountStatusTone(user.account_status)}>
{accountStatusText(user.account_status)}
</Badge>
<Badge tone={roleBadgeTone(user.role)}>{roleText(user.role)}</Badge>
<span>{user.affiliation || "소속 미입력"}</span>
<span>{user.cohort_ids.length ? cohortInputValue(user.cohort_ids) : "코호트 없음"}</span>
<span> {dateTimeLabel(user.created_at)}</span>
</div>
<div className="ad-approval__actions">
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void updateUserAccountStatus(user, "approved")}
disabled={!usersWritable || savingUserId === user.user_id}
>
{savingUserId === user.user_id ? "처리 중" : "승인"}
</Button>
<Button
size="sm"
variant="secondary"
leading={<Icon name="x" size={14} />}
onClick={() => void updateUserAccountStatus(user, "suspended")}
disabled={!usersWritable || savingUserId === user.user_id}
>
</Button>
</div>
</article>
))}
</div>
) : null}
</section>
);
const renderUserCreate = () => (
<section className="ad-panel">
<div className="ad-section__head">
<h2> </h2>
<span>{usersWritable ? "DB 저장 가능" : "읽기 전용"}</span>
</div>
<div className="ad-users-note">
. .
</div>
{!usersWritable ? (
<div className="ad-users-note ad-users-note--warn" role="alert">
DB .
</div>
) : null}
<form
className="ad-user-create"
onSubmit={(event) => {
event.preventDefault();
void createUser();
}}
>
<label>
<span></span>
<input
value={newUser.email}
onChange={(event) => setNewUser((current) => ({ ...current, email: event.target.value }))}
placeholder="name@example.com"
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 이메일"
/>
</label>
<label>
<span> </span>
<input
value={newUser.display_name}
onChange={(event) =>
setNewUser((current) => ({ ...current, display_name: event.target.value }))
}
placeholder="홍길동"
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 표시 이름"
/>
</label>
<label>
<span></span>
<select
value={newUser.role}
onChange={(event) =>
setNewUser((current) => ({
...current,
role: event.target.value as AdminManagedUser["role"],
}))
}
disabled={!usersWritable || creatingUser || (newUser.role === "admin" && !canGrantAdminAccess)}
aria-label="새 사용자 역할"
>
<option value="learner"></option>
<option value="teacher"></option>
<option value="admin" disabled={!canGrantAdminAccess}>
</option>
</select>
</label>
<label className="ad-checkline">
<input
type="checkbox"
checked={newUser.admin_access}
onChange={(event) =>
setNewUser((current) => ({ ...current, admin_access: event.target.checked }))
}
disabled={!usersWritable || creatingUser || !canGrantAdminAccess}
aria-label="새 사용자 관리자 페이지 권한"
/>
<span> </span>
</label>
<label>
<span> </span>
<select
value={newUser.account_status}
onChange={(event) =>
setNewUser((current) => ({
...current,
account_status: event.target.value as AdminManagedUser["account_status"],
}))
}
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 승인 상태"
>
<option value="approved"></option>
<option value="pending"> </option>
<option value="suspended"></option>
</select>
</label>
<label>
<span></span>
<input
value={cohortInputValue(newUser.cohort_ids)}
onChange={(event) =>
setNewUser((current) => ({ ...current, cohort_ids: parseCohorts(event.target.value) }))
}
placeholder="cohort-a, cohort-b"
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 코호트"
/>
</label>
<label>
<span></span>
<input
value={newUser.affiliation}
onChange={(event) =>
setNewUser((current) => ({ ...current, affiliation: event.target.value }))
}
placeholder="한신대학교 상담학과"
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 소속"
/>
</label>
<Button
type="submit"
leading={<Icon name="users" size={15} />}
disabled={!usersWritable || creatingUser}
>
{creatingUser ? "등록 중" : "사용자 등록"}
</Button>
</form>
</section>
);
const renderUserList = () => (
<section className="ad-section">
<div className="ad-users-toolbar">
<label>
<span> </span>
<input
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder="이름, 이메일, 역할, 코호트"
aria-label="사용자 검색"
/>
</label>
<span>
{usersData
? `${filteredUsers.length.toLocaleString("ko-KR")} / ${usersData.users.length.toLocaleString("ko-KR")}`
: "사용자 확인 중"}
</span>
</div>
<div className="ad-users">
{visibleUsers.map((user) => {
const draft =
userDrafts[user.user_id] ?? {
display_name: user.display_name,
role: user.role,
admin_access: user.admin_access,
account_status: user.account_status,
affiliation: user.affiliation,
cohort_ids: user.cohort_ids,
};
const dirty =
draft.display_name !== user.display_name ||
draft.role !== user.role ||
draft.admin_access !== user.admin_access ||
draft.account_status !== user.account_status ||
draft.affiliation !== user.affiliation ||
cohortInputValue(draft.cohort_ids) !== cohortInputValue(user.cohort_ids);
return (
<article className="ad-user" key={user.user_id}>
<div className="ad-user__top">
<div className="ad-user__id">
<span aria-hidden="true">{initialOf(user)}</span>
<div>
<b>{user.display_name}</b>
<p>{user.email}</p>
</div>
</div>
<div className="ad-user__badges">
<Badge tone={roleBadgeTone(user.role)}>{roleText(user.role)}</Badge>
{user.admin_access ? <Badge tone="warn"> </Badge> : null}
{user.super_admin ? <Badge tone="crit"> </Badge> : null}
<Badge tone={accountStatusTone(user.account_status)}>
{accountStatusText(user.account_status)}
</Badge>
</div>
</div>
<div className="ad-user__fields">
<label>
<span></span>
<input
value={draft.display_name}
onChange={(event) =>
updateDraft(user.user_id, { display_name: event.target.value })
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 표시 이름`}
/>
</label>
<label>
<span></span>
<select
value={draft.role}
onChange={(event) =>
updateDraft(user.user_id, {
role: event.target.value as AdminManagedUser["role"],
})
}
disabled={
!usersWritable ||
savingUserId === user.user_id ||
(user.role === "admin" && !canGrantAdminAccess)
}
aria-label={`${user.email} 역할`}
>
<option value="learner"></option>
<option value="teacher"></option>
<option value="admin" disabled={!canGrantAdminAccess}>
</option>
</select>
</label>
<label className="ad-checkline">
<input
type="checkbox"
checked={draft.admin_access}
onChange={(event) =>
updateDraft(user.user_id, { admin_access: event.target.checked })
}
disabled={
!usersWritable ||
savingUserId === user.user_id ||
!canGrantAdminAccess ||
user.super_admin
}
aria-label={`${user.email} 관리자 페이지 권한`}
/>
<span> </span>
</label>
<label>
<span> </span>
<select
value={draft.account_status}
onChange={(event) =>
updateDraft(user.user_id, {
account_status: event.target.value as AdminManagedUser["account_status"],
})
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 승인 상태`}
>
<option value="pending"> </option>
<option value="approved"></option>
<option value="suspended"></option>
</select>
</label>
<label>
<span></span>
<input
value={draft.affiliation}
onChange={(event) =>
updateDraft(user.user_id, { affiliation: event.target.value })
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 소속`}
/>
</label>
<label>
<span></span>
<input
value={cohortInputValue(draft.cohort_ids)}
onChange={(event) =>
updateDraft(user.user_id, { cohort_ids: parseCohorts(event.target.value) })
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 코호트`}
/>
</label>
</div>
<div className="ad-user__meta">
<span> {user.active_sessions}</span>
<span>{accountStatusText(user.account_status)}</span>
<span>{isOnline(user.last_seen_at) ? "온라인" : "오프라인"}</span>
<span> {dateTimeLabel(user.last_seen_at)}</span>
</div>
<div className="ad-user__actions">
<Button
size="sm"
variant="ghost"
leading={<Icon name="x" size={14} />}
onClick={() => void deactivateUser(user)}
disabled={!usersWritable || deactivatingUserId === user.user_id}
>
{deactivatingUserId === user.user_id ? "처리 중" : "비활성화"}
</Button>
<Button
size="sm"
onClick={() => void saveUser(user)}
disabled={!usersWritable || !dirty || savingUserId === user.user_id}
>
{savingUserId === user.user_id ? "저장 중" : "저장"}
</Button>
</div>
</article>
);
})}
{usersLoading && !usersData
? Array.from({ length: 2 }).map((_, index) => (
<article className="ad-user ad-user--skeleton" key={index} aria-hidden="true">
<span className="ad-skel ad-skel--name" />
<span className="ad-skel ad-skel--detail" />
<span className="ad-skel ad-skel--meter" />
<span className="ad-skel ad-skel--action" />
</article>
))
: null}
{!usersLoading && !usersData ? (
<div className="ad-users-empty">
. .
</div>
) : null}
{!usersLoading && usersData && filteredUsers.length > visibleUsers.length ? (
<div className="ad-users-empty">
{filteredUsers.length - visibleUsers.length} .
</div>
) : null}
{!usersLoading && usersData && filteredUsers.length === 0 ? (
<div className="ad-users-empty">
{hasUserSearch ? "검색 조건에 맞는 사용자가 없습니다." : "아직 등록된 사용자가 없습니다."}
</div>
) : null}
</div>
</section>
);
const renderUserActivity = () => (
<section className="ad-overview-grid">
<div className="ad-panel">
<div className="ad-section__head">
<h2> </h2>
<span>{storeLabel(usersData)}</span>
</div>
<div className="ad-role-stack">
<RoleMeter label="학습자" value={roleCounts.learner} total={users.length} />
<RoleMeter label="교수자" value={roleCounts.teacher} total={users.length} />
<RoleMeter label="관리자" value={roleCounts.admin} total={users.length} />
</div>
</div>
<div className="ad-panel ad-panel--wide">
<div className="ad-section__head">
<h2> </h2>
<span> last_seen </span>
</div>
<div className="ad-activity-table">
{users
.slice()
.sort((a, b) => b.last_seen_at - a.last_seen_at)
.slice(0, 8)
.map((user) => (
<div key={user.user_id}>
<span>
<b>{user.display_name}</b>
<small>{user.email}</small>
</span>
<Badge tone={roleBadgeTone(user.role)}>{roleText(user.role)}</Badge>
<span>{user.active_sessions}</span>
<span>{dateTimeLabel(user.last_seen_at)}</span>
</div>
))}
{!usersLoading && users.length === 0 ? (
<div className="ad-users-empty"> .</div>
) : null}
</div>
</div>
</section>
);
const renderAccess = () => (
<>
<PageHeader
kicker="접근 권한"
title="역할, 그룹, 접근 범위"
description="운영자가 누구에게 어떤 작업 권한을 줄지 판단하는 정책 화면입니다."
/>
<div className="ad-users-note">
· API는 . .
</div>
<TabBar
ariaLabel="접근 권한 탭"
items={[
["roles", "역할"],
["groups", "그룹"],
["matrix", "권한 매트릭스"],
]}
value={accessTab}
onChange={setAccessTab}
/>
{accessTab === "roles" ? (
<section className="ad-policy-grid">
{ROLE_POLICIES.map((policy) => (
<article className="ad-policy" key={policy.role}>
<div className="ad-section__head">
<h2>{policy.role}</h2>
<Badge tone={policy.role === "관리자" ? "warn" : "neutral"}>{policy.permissions.length} </Badge>
</div>
<p>{policy.scope}</p>
<div className="ad-chip-row">
{policy.permissions.map((permission) => (
<span key={permission}>{permission}</span>
))}
</div>
<small>{policy.risk}</small>
</article>
))}
</section>
) : null}
{accessTab === "groups" ? (
<section className="ad-policy-grid">
{GROUP_POLICIES.map((group) => (
<article className="ad-policy" key={group.name}>
<div className="ad-section__head">
<h2>{group.name}</h2>
<Badge tone="neutral"> </Badge>
</div>
<p>{group.scope}</p>
<div className="ad-chip-row">
{group.access.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</article>
))}
</section>
) : null}
{accessTab === "matrix" ? (
<section className="ad-panel">
<div className="ad-access-table">
<div className="ad-access-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{PERMISSION_MATRIX.map((row) => (
<div className="ad-access-row" key={row.resource}>
<b>{row.resource}</b>
<span>{row.admin}</span>
<span>{row.teacher}</span>
<span>{row.learner}</span>
</div>
))}
</div>
</section>
) : null}
</>
);
const renderTickets = () => (
<>
<PageHeader
kicker="운영 티켓"
title="사용자 문제 큐"
description="미해결 접수를 우선 확인하고 처리 상태를 갱신합니다."
>
<Button
variant="secondary"
leading={<Icon name="settings" size={16} />}
onClick={() => void loadTickets()}
disabled={ticketsLoading}
>
{ticketsLoading ? "확인 중" : "새로고침"}
</Button>
</PageHeader>
<div className="ad-users-note">
DB . .
</div>
<section className="ad-ticket-filter" aria-label="운영 티켓 필터">
<input
value={ticketSearch}
onChange={(event) => setTicketSearch(event.target.value)}
placeholder="제목, 본문, 신고자, 경로 검색"
aria-label="티켓 검색"
/>
<select
value={ticketStatusFilter}
onChange={(event) => setTicketStatusFilter(event.target.value as TicketStatusFilter)}
aria-label="상태 필터"
>
{TICKET_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketCategoryFilter}
onChange={(event) => setTicketCategoryFilter(event.target.value as TicketCategoryFilter)}
aria-label="카테고리 필터"
>
{TICKET_CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketPriorityFilter}
onChange={(event) => setTicketPriorityFilter(event.target.value as TicketPriorityFilter)}
aria-label="우선순위 필터"
>
{TICKET_PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
value={ticketAssignedGroup}
onChange={(event) => setTicketAssignedGroup(event.target.value)}
placeholder="담당 그룹"
aria-label="담당 그룹 필터"
/>
<label className="ad-ticket-filter__check">
<input
type="checkbox"
checked={ticketStaleOnly}
onChange={(event) => setTicketStaleOnly(event.target.checked)}
/>
<span></span>
</label>
<Button variant="secondary" onClick={clearTicketFilters} disabled={!hasTicketFilters}>
</Button>
</section>
{categoryQueues.length > 0 ? (
<div className="ad-ticket-queues" aria-label="카테고리 큐">
{categoryQueues.map(([category, count]) => (
<button
key={category}
type="button"
className={ticketCategoryFilter === category ? "is-active" : ""}
onClick={() => setTicketCategoryFilter(category as TicketCategoryFilter)}
>
<span>{ticketCategoryLabel(category as AdminSupportTicket["category"])}</span>
<b>{countLabel(count)}</b>
</button>
))}
</div>
) : null}
<section className="ad-section">
{ticketsError ? <InlineError message={ticketsError} /> : null}
<div className="ad-kpis" aria-label="티켓 요약">
<div className="ad-kpi">
<span className="ad-kpi__lab"> </span>
<b>{tickets ? countLabel(tickets.summary.total) : "-"}</b>
<small>{tickets?.durable ? "DB 티켓" : "저장소 확인 중"}</small>
</div>
<div className="ad-kpi">
<span className="ad-kpi__lab"></span>
<b>{tickets ? countLabel(tickets.summary.open_count) : "-"}</b>
<small>open, triaged, in_progress</small>
</div>
<div className="ad-kpi">
<span className="ad-kpi__lab"> </span>
<b>{tickets ? countLabel(tickets.summary.high_priority_count) : "-"}</b>
<small> high, urgent</small>
</div>
<div className="ad-kpi">
<span className="ad-kpi__lab"></span>
<b>{tickets ? countLabel(tickets.summary.stale_count) : "-"}</b>
<small>24 </small>
</div>
</div>
{ticketsLoading && !tickets ? (
<div className="ad-panel">
<EmptyState title="티켓 큐를 불러오는 중입니다" body="운영 티켓 저장소를 조회하고 있습니다." />
</div>
) : null}
{!ticketsLoading && tickets && activeTickets.length === 0 ? (
<div className="ad-panel">
<EmptyState title="미해결 운영 티켓이 없습니다" body="새 접수가 들어오면 이 큐에 우선순위와 상태가 표시됩니다." />
</div>
) : null}
{activeTickets.length > 0 ? (
<div className="ad-ticket-list" aria-label="미해결 운영 티켓 큐">
{activeTickets.map((ticket) => {
const duplicateParentId = ticket.duplicate_parent_candidate_id;
const canLinkDuplicate =
ticket.duplicate_count > 0 &&
Boolean(duplicateParentId) &&
duplicateParentId !== ticket.ticket_id &&
duplicateParentId !== ticket.parent_ticket_id;
return (
<article className="ad-ticket" key={ticket.ticket_id}>
<div>
<div className="ad-ticket__meta">
<span>{ticketCategoryLabel(ticket.category)}</span>
<span>{ticket.reporter.email}</span>
<span> {dateTimeLabel(ticket.updated_at)}</span>
</div>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
{(ticket.duplicate_count > 0 || ticket.parent_ticket_id || ticket.child_ticket_count > 0) ? (
<div className="ad-ticket__dupes">
{ticket.parent_ticket_id ? (
<span> #{shortTicketId(ticket.parent_ticket_id)}</span>
) : ticket.duplicate_count > 0 ? (
<span> {countLabel(ticket.duplicate_count)}</span>
) : null}
{ticket.child_ticket_count > 0 ? (
<span> {countLabel(ticket.child_ticket_count)}</span>
) : null}
</div>
) : null}
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
<Badge tone="accent">{ticketStatusLabel(ticket.status)}</Badge>
<div className="ad-ticket__actions">
{canLinkDuplicate ? (
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketParent(ticket, duplicateParentId ?? null)}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
) : null}
{ticket.parent_ticket_id ? (
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketParent(ticket, null)}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
) : null}
<Button
size="sm"
variant="secondary"
onClick={() => void updateTicketStatus(ticket, "in_progress")}
disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
>
</Button>
<Button
size="sm"
variant="primary"
onClick={() => void updateTicketStatus(ticket, "resolved")}
disabled={updatingTicketId === ticket.ticket_id}
>
</Button>
</div>
</article>
);
})}
</div>
) : null}
</section>
{recentResolvedTickets.length > 0 ? (
<section className="ad-section">
<div className="ad-section__head">
<h2> </h2>
<span>{countLabel(recentResolvedTickets.length)}</span>
</div>
<div className="ad-ticket-list ad-ticket-list--history">
{recentResolvedTickets.map((ticket) => (
<article className="ad-ticket ad-ticket--history" key={ticket.ticket_id}>
<div>
<div className="ad-ticket__meta">
<span>{ticketCategoryLabel(ticket.category)}</span>
<span>{ticket.reporter.email}</span>
<span> {dateTimeLabel(ticket.resolved_at ?? ticket.updated_at)}</span>
</div>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone="neutral">{ticketPriorityLabel(ticket.priority)}</Badge>
<Badge tone="neutral">{ticketStatusLabel(ticket.status)}</Badge>
<div className="ad-ticket__actions ad-ticket__actions--history">
<span></span>
</div>
</article>
))}
</div>
</section>
) : null}
</>
);
return (
<AppShell contextLabel="운영 콘솔" navRole="admin" wide>
<AdminErrorBoundary section={section}>
<AdminSectionRenderer
section={section}
diagnostics={renderAdminDiagnostics()}
renderOverview={renderOverview}
renderUsers={renderUsers}
renderAccess={renderAccess}
renderTickets={renderTickets}
/>
</AdminErrorBoundary>
</AppShell>
);
}
function InlineError({ message }: { message: string }) {
return (
<section className="ad-error" role="alert">
<Icon name="alert" size={18} />
<span>{message}</span>
</section>
);
}
function EmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="ad-empty-state">
<b>{title}</b>
<p>{body}</p>
</div>
);
}
function TabBar<T extends string>({
ariaLabel,
items,
value,
onChange,
}: {
ariaLabel: string;
items: Array<[T, string]>;
value: T;
onChange: (value: T) => void;
}) {
return (
<div className="ad-tabs" role="tablist" aria-label={ariaLabel}>
{items.map(([id, label]) => (
<button
type="button"
key={id}
role="tab"
aria-selected={value === id}
className={value === id ? "is-active" : ""}
onClick={() => onChange(id)}
>
{label}
</button>
))}
</div>
);
}
function RoleMeter({ label, value, total }: { label: string; value: number; total: number }) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="ad-role-meter">
<div>
<span>{label}</span>
<b>{countLabel(value)}</b>
</div>
<ProgressBar value={pct} slim label={`${label} 비율`} />
</div>
);
}