import { Component, useCallback, useEffect, useMemo, useRef, useState, type ErrorInfo, type ReactNode, } from "react"; import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef, type SortingState, } from "@tanstack/react-table"; import { AppShell } from "../components/shell/AppShell"; import { Badge, Button, Dot, EmptyState as UiEmptyState, Icon, Kicker, ProgressBar, surfaceClassName, Tabs, } from "../components/ui"; import { adminApi, adminProtocolsApi, adminUsersApi, type AdminHealthResponse, type AdminHealthStatus, type AdminManagedUser, type AdminProtocol, type AdminProtocolCreateRequest, type AdminProtocolStatus, type AdminTicketFilters, type AdminSupportTicket, type AdminTicketsResponse, type AdminUsageResponse, type AdminUptimeResponse, type AdminUserCreateRequest, type AdminUsersResponse, } from "../lib/api"; import { useAuth } from "../lib/auth"; import { formatUnixSecondsKo } from "../lib/format"; import { runtimeAssetLabel } from "../lib/runtimeDiagnostics"; import { normalizeAdminTicketsResponse, normalizeAdminUsersResponse, type AdminDataWarning, } from "./admin/dataNormalization"; import "./admin/admin-console.css"; export type AdminSection = "overview" | "users" | "access" | "tickets"; type UserDraft = Pick< AdminManagedUser, | "display_name" | "role" | "admin_access" | "learner_feedback_enabled" | "account_status" | "affiliation" | "cohort_ids" >; type NewUserDraft = Required< Pick > & Pick< UserDraft, | "admin_access" | "learner_feedback_enabled" | "affiliation" | "cohort_ids" > & { account_status: "pending"; }; type UserTab = "approval" | "manage" | "register" | "activity"; type AccessTab = "roles" | "groups" | "matrix" | "protocols"; type ProtocolStatusFilter = AdminProtocolStatus | "all"; type ProtocolDraft = AdminProtocolCreateRequest; type TicketStatusFilter = AdminSupportTicket["status"] | "all"; type TicketCategoryFilter = AdminSupportTicket["category"] | "all"; type TicketPriorityFilter = AdminSupportTicket["priority"] | "all"; const MAX_RENDERED_USERS = 40; const MAX_RENDERED_ACTIVE_TICKETS = 24; 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, learner_feedback_enabled: true, account_status: "pending", affiliation: "", cohort_ids: [], }; const EMPTY_PROTOCOL_DRAFT: ProtocolDraft = { title: "", source: "", version: 1, license: "B", external_llm_ok: false, content: "", }; function protocolStatusLabel(status: AdminProtocolStatus): string { if (status === "draft") return "초안"; if (status === "active") return "활성"; return "퇴역"; } function protocolStatusTone( status: AdminProtocolStatus, ): "warn" | "pos" | "neutral" { if (status === "draft") return "warn"; if (status === "active") return "pos"; return "neutral"; } function protocolAllowsExternalLlm(license: ProtocolDraft["license"]): boolean { return license === "A" || license === "B"; } function protocolVersionError(version: number): string | null { if (!Number.isInteger(version) || version < 1 || version > 1_000_000) { return "버전은 1부터 1,000,000 사이의 정수여야 합니다."; } return null; } 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 { return formatUnixSecondsKo(seconds); } 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 "예산 정상"; } // 한 줄에 가운뎃점을 1개만 둔다. 잔여 예산은 아래 usageBudgetRemaining 으로 분리해 별도 줄에 표시. 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); return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용`; } function usageBudgetRemaining(data: AdminUsageResponse): string { const { budget } = data; if (budget.status === "disabled" || budget.remaining_usd === null) return ""; return `잔여 ${costLabel(budget.remaining_usd)}`; } 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`; } // 히트 비율과 캐시 규모를 두 줄로 나눠 줄당 가운뎃점을 1개 이하로 유지한다. function evaluatorCacheDetail(data: AdminUsageResponse): string { const cache = evaluatorCache(data); if (!cache.enabled) return "EVALUATOR_SEMANTIC_CACHE 설정이 꺼져 있습니다."; return `${countLabel(cache.hits)} / ${countLabel(cache.requests)} hit`; } function evaluatorCacheVolume(data: AdminUsageResponse): string { const cache = evaluatorCache(data); if (!cache.enabled) return ""; return `entry ${countLabel(cache.entries)}개 · 저장 ${countLabel(cache.stores)}회`; } function usageDailyCost( data: AdminUsageResponse, ): NonNullable { 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(); } interface TicketActionsProps { ticket: AdminSupportTicket; duplicateParentId: string | null; canLinkDuplicate: boolean; onUpdateStatus: ( ticket: AdminSupportTicket, status: AdminSupportTicket["status"], resolutionNote?: string, ) => Promise; onUpdateParent: ( ticket: AdminSupportTicket, parentTicketId: string | null, ) => Promise; } function TicketActions({ ticket, duplicateParentId, canLinkDuplicate, onUpdateStatus, onUpdateParent, }: TicketActionsProps) { const [pending, setPending] = useState(false); // 해결 노트 — 학습자 설정 화면(resolution_note)에 그대로 보이는 처리 메모. const [resolutionNote, setResolutionNote] = useState(""); const pendingRef = useRef(false); const run = (action: () => Promise) => { if (pendingRef.current) return; pendingRef.current = true; // 네이티브 입력 dispatch 안에서 Admin 루트 렌더를 동기 flush하지 않는다. // ref가 즉시 중복 요청을 막고, 시각 상태만 다음 태스크에서 반영한다. const pendingTimer = window.setTimeout(() => setPending(true), 0); void action().finally(() => { window.clearTimeout(pendingTimer); pendingRef.current = false; setPending(false); }); }; return (