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, } 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 { 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" | "account_status" | "affiliation" | "cohort_ids" >; type NewUserDraft = Required< Pick > & 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"; 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, 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 { 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"], ) => Promise; onUpdateParent: ( ticket: AdminSupportTicket, parentTicketId: string | null, ) => Promise; } function TicketActions({ ticket, duplicateParentId, canLinkDuplicate, onUpdateStatus, onUpdateParent, }: TicketActionsProps) { const [pending, setPending] = useState(false); 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 (
{canLinkDuplicate ? ( ) : null} {ticket.parent_ticket_id ? ( ) : null}
); } 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 userDraftFrom(user: AdminManagedUser): UserDraft { return { 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, }; } function replaceManagedUser( current: AdminUsersResponse | null, updated: AdminManagedUser, ): AdminUsersResponse | null { if (!current) return current; return { ...current, users: current.users.map((user) => user.user_id === updated.user_id ? updated : user, ), }; } function isOnline(seconds?: number | null): boolean { if (!seconds || seconds <= 0) return false; return Date.now() / 1000 - seconds < 15 * 60; } interface PageHeaderProps { /** 아래 제목과 다른 정보를 줄 때만 넣는다. 제목을 되풀이하는 eyebrow 는 생략한다. */ kicker?: string; title: string; description: string; children?: ReactNode; } function PageHeader({ kicker, title, description, children }: PageHeaderProps) { return (
{kicker ? {kicker} : null}

{title}

{description}

{children ?
{children}
: null}
); } function AdminDiagnosticPanel({ title, body, details, }: { title: string; body: string; details: Array<[string, string]>; }) { return (
화면 진단

{title}

{body}

{details.map(([key, value]) => (
{key}
{value}
))}
); } 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 ( ); } return this.props.children; } } function AdminBlankContentProbe({ section }: { section: AdminSection }) { const markerRef = useRef(null); const [diagnostic, setDiagnostic] = useState | null>( null, ); useEffect(() => { setDiagnostic(null); const timer = window.setTimeout(() => { const root = markerRef.current?.closest(".vgops-root") as HTMLElement | null; if (!root) return; const text = root.innerText.replace(/\s+/g, " ").trim(); const rootRect = root.getBoundingClientRect(); const mainRect = root.closest(".vg-main")?.getBoundingClientRect(); const viewportTop = mainRect?.top ?? 0; const viewportBottom = mainRect?.bottom ?? window.innerHeight; const visibleNodes = Array.from( root.querySelectorAll( "h1,h2,p,button,input,select,article,section,.vgops-head,.vgops-panel,.vgops-section,.vgops-users-note,.vgops-status", ), ).filter((element) => { if (element.classList.contains("vgops-diagnostic")) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return ( rect.width > 1 && rect.height > 1 && rect.bottom > viewportTop && rect.top < viewportBottom && 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)], [ "mainScrollTop", String( Math.round( (root.closest(".vg-main") as HTMLElement | null)?.scrollTop ?? 0, ), ), ], ["scrollY", String(Math.round(window.scrollY))], ["asset", runtimeAssetLabel()], ]); } }, 800); return () => window.clearTimeout(timer); }, [section]); return ( <>