vignette/apps/web/src/pages/Admin.tsx

3310 lines
109 KiB
TypeScript

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<AdminUserCreateRequest, "email" | "display_name" | "role">
> &
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<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();
}
interface TicketActionsProps {
ticket: AdminSupportTicket;
duplicateParentId: string | null;
canLinkDuplicate: boolean;
onUpdateStatus: (
ticket: AdminSupportTicket,
status: AdminSupportTicket["status"],
resolutionNote?: string,
) => Promise<void>;
onUpdateParent: (
ticket: AdminSupportTicket,
parentTicketId: string | null,
) => Promise<void>;
}
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<void>) => {
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 (
<div className="vgops-ticket__actions" aria-busy={pending}>
<textarea
className="vgops-ticket__note-input"
rows={2}
value={resolutionNote}
aria-label="해결 노트"
placeholder="해결 시 학습자에게 보일 처리 메모"
maxLength={2000}
disabled={pending}
onChange={(event) => setResolutionNote(event.target.value)}
/>
{canLinkDuplicate ? (
<Button
size="sm"
variant="secondary"
onClick={() => run(() => onUpdateParent(ticket, duplicateParentId))}
disabled={pending}
>
</Button>
) : null}
{ticket.parent_ticket_id ? (
<Button
size="sm"
variant="secondary"
onClick={() => run(() => onUpdateParent(ticket, null))}
disabled={pending}
>
</Button>
) : null}
<Button
size="sm"
variant="secondary"
onClick={() => run(() => onUpdateStatus(ticket, "in_progress"))}
disabled={pending || ticket.status === "in_progress"}
>
</Button>
<Button
size="sm"
variant="primary"
onClick={() =>
run(() =>
onUpdateStatus(ticket, "resolved", resolutionNote.trim() || undefined),
)
}
disabled={pending}
>
</Button>
</div>
);
}
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,
learner_feedback_enabled: user.learner_feedback_enabled ?? true,
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 (
<header className="vgops-head">
<div>
{kicker ? <Kicker>{kicker}</Kicker> : null}
<h1>{title}</h1>
<p>{description}</p>
</div>
{children ? <div className="vgops-head__actions">{children}</div> : null}
</header>
);
}
function AdminDiagnosticPanel({
title,
body,
details,
}: {
title: string;
body: string;
details: Array<[string, string]>;
}) {
return (
<section
className="vgops-diagnostic"
data-vignette-admin-diagnostic="true"
role="alert"
>
<div>
<Kicker> </Kicker>
<h1>{title}</h1>
<p>{body}</p>
</div>
<dl className={surfaceClassName(undefined, { variant: "inset" })}>
{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(".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<HTMLElement>(
"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 (
<>
<span ref={markerRef} className="vgops-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="vgops-root"
data-vignette-admin-root="true"
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 [userSorting, setUserSorting] = useState<SortingState>([
{ id: "last_seen_at", desc: true },
]);
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 [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 [protocols, setProtocols] = useState<AdminProtocol[]>([]);
const [protocolsLoaded, setProtocolsLoaded] = useState(false);
const [protocolsLoading, setProtocolsLoading] = useState(false);
const [protocolsError, setProtocolsError] = useState<string | null>(null);
const [protocolDraft, setProtocolDraft] = useState<ProtocolDraft>(
EMPTY_PROTOCOL_DRAFT,
);
const [protocolSaving, setProtocolSaving] = useState(false);
const [protocolActionId, setProtocolActionId] = useState<string | null>(null);
const [protocolSearch, setProtocolSearch] = useState("");
const [protocolStatusFilter, setProtocolStatusFilter] =
useState<ProtocolStatusFilter>("all");
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] ?? userDraftFrom(user),
]),
),
);
} 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);
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,
]);
const loadProtocols = useCallback(async () => {
setProtocolsLoading(true);
setProtocolsError(null);
try {
const response = await adminProtocolsApi.list();
setProtocols(response.protocols);
setProtocolsLoaded(true);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: "상담 프로토콜 목록을 불러오지 못했습니다.",
);
} finally {
setProtocolsLoading(false);
}
}, []);
useEffect(() => {
void loadHealth();
void loadUsers();
void loadUsage();
void loadUptime();
}, [loadHealth, loadUptime, loadUsage, loadUsers]);
useEffect(() => {
void loadTickets();
}, [loadTickets]);
useEffect(() => {
if (section === "access" && accessTab === "protocols" && !protocolsLoaded) {
void loadProtocols();
}
}, [accessTab, loadProtocols, protocolsLoaded, section]);
const refreshAll = useCallback(async () => {
await Promise.all([
loadHealth(),
loadUsers(),
loadUsage(),
loadUptime(),
loadTickets(),
]);
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
const visibleProtocols = useMemo(() => {
const query = protocolSearch.trim().toLocaleLowerCase("ko-KR");
return protocols.filter((protocol) => {
if (
protocolStatusFilter !== "all" &&
protocol.status !== protocolStatusFilter
) {
return false;
}
if (!query) return true;
return [protocol.title, protocol.source, protocol.source_id]
.join(" ")
.toLocaleLowerCase("ko-KR")
.includes(query);
});
}, [protocolSearch, protocolStatusFilter, protocols]);
const createProtocol = async () => {
if (
!protocolDraft.title.trim() ||
!protocolDraft.source.trim() ||
!protocolDraft.content.trim()
) {
setProtocolsError("제목, 출처, 원문을 모두 입력해야 합니다.");
return;
}
const versionError = protocolVersionError(protocolDraft.version);
if (versionError) {
setProtocolsError(versionError);
return;
}
setProtocolSaving(true);
setProtocolsError(null);
try {
const created = await adminProtocolsApi.create({
...protocolDraft,
title: protocolDraft.title.trim(),
source: protocolDraft.source.trim(),
content: protocolDraft.content.trim(),
external_llm_ok:
protocolAllowsExternalLlm(protocolDraft.license)
? protocolDraft.external_llm_ok
: false,
});
setProtocols((current) => [
created,
...current.filter((item) => item.protocol_id !== created.protocol_id),
]);
setProtocolDraft(EMPTY_PROTOCOL_DRAFT);
setProtocolsLoaded(true);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: "상담 프로토콜 초안을 등록하지 못했습니다.",
);
} finally {
setProtocolSaving(false);
}
};
const activateProtocol = async (protocol: AdminProtocol) => {
setProtocolActionId(protocol.protocol_id);
setProtocolsError(null);
try {
const response = await adminProtocolsApi.activate(protocol.protocol_id);
setProtocols((current) =>
current.map((item) =>
item.protocol_id === protocol.protocol_id ? response.protocol : item,
),
);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: `${protocol.title}” 프로토콜을 활성화하지 못했습니다.`,
);
} finally {
setProtocolActionId(null);
}
};
const retireProtocol = async (protocol: AdminProtocol) => {
setProtocolActionId(protocol.protocol_id);
setProtocolsError(null);
try {
const retired = await adminProtocolsApi.retire(protocol.protocol_id);
setProtocols((current) =>
current.map((item) =>
item.protocol_id === protocol.protocol_id ? retired : item,
),
);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: `${protocol.title}” 프로토콜을 퇴역하지 못했습니다.`,
);
} finally {
setProtocolActionId(null);
}
};
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
setUserDrafts((current) => ({
...current,
[userId]: {
...(current[userId] ?? {
display_name: "",
role: "learner",
admin_access: false,
learner_feedback_enabled: true,
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) => replaceManagedUser(current, updated));
setUserDrafts((current) => ({
...current,
[updated.user_id]: userDraftFrom(updated),
}));
} 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,
account_status: "pending",
});
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) => replaceManagedUser(current, updated));
setUserDrafts((current) => ({
...current,
[updated.user_id]: userDraftFrom(updated),
}));
} 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"],
resolutionNote?: string,
) => {
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, {
status,
...(resolutionNote ? { resolution_note: resolutionNote } : {}),
});
setTicketsError(null);
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 : "티켓 상태를 저장하지 못했습니다.",
);
}
};
const updateTicketParent = async (
ticket: AdminSupportTicket,
parentTicketId: string | null,
) => {
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, {
parent_ticket_id: parentTicketId ?? "",
});
setTicketsError(null);
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 : "중복 연결을 저장하지 못했습니다.",
);
}
};
// 렌더마다 새 배열을 만들면 TanStack core row model이 재계산되며
// _autoResetPageIndex 상태 갱신 루프(무한 동기 렌더 프리즈)를 유발한다 — 반드시 메모화.
const services = useMemo(() => health?.services ?? [], [health]);
const users = useMemo(() => usersData?.users ?? [], [usersData]);
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 visibleActiveTickets = useMemo(
() => activeTickets.slice(0, MAX_RENDERED_ACTIVE_TICKETS),
[activeTickets],
);
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 userColumns: ColumnDef<AdminManagedUser>[] = [
{
id: "identity",
accessorFn: (user) => `${user.display_name} ${user.email}`,
header: "사용자",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? userDraftFrom(user);
return (
<div className="vgops-user-table__identity">
<span className="vgops-user-table__avatar" aria-hidden="true">
{initialOf(user)}
</span>
<div>
<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} 표시 이름`}
/>
<span title={user.email}>{user.email}</span>
</div>
</div>
);
},
},
{
id: "role",
accessorFn: (user) => user.role,
header: "역할",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<div className="vgops-user-table__role">
<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>
<span className="vgops-user-table__badges">
<Badge tone={roleBadgeTone(user.role)}>
{roleText(user.role)}
</Badge>
{user.super_admin ? <Badge tone="crit"></Badge> : null}
</span>
</div>
);
},
},
{
id: "admin_access",
accessorFn: (user) => Number(user.admin_access),
header: "관리 권한",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<label className="vgops-user-table__check">
<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>{draft.admin_access ? "허용" : "없음"}</span>
</label>
);
},
},
{
id: "account_status",
accessorFn: (user) => user.account_status,
header: "승인 상태",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<div className="vgops-user-table__status">
<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>
<Badge tone={accountStatusTone(user.account_status)}>
{accountStatusText(user.account_status)}
</Badge>
</div>
);
},
},
{
id: "learner_feedback_enabled",
accessorFn: (user) => Number(user.learner_feedback_enabled),
header: "AI 피드백",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<label className="vgops-user-table__check">
<input
type="checkbox"
checked={draft.learner_feedback_enabled}
onChange={(event) =>
updateDraft(user.user_id, {
learner_feedback_enabled: event.target.checked,
})
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 학습자 AI 피드백`}
/>
<span>{draft.learner_feedback_enabled ? "켜짐" : "꺼짐"}</span>
</label>
);
},
},
{
id: "affiliation",
accessorFn: (user) => user.affiliation,
header: "소속",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<input
value={draft.affiliation}
onChange={(event) =>
updateDraft(user.user_id, { affiliation: event.target.value })
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 소속`}
/>
);
},
},
{
id: "cohort_ids",
accessorFn: (user) => cohortInputValue(user.cohort_ids),
header: "코호트",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<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} 코호트`}
/>
);
},
},
{
id: "active_sessions",
accessorFn: (user) => user.active_sessions,
header: "활성 회기",
sortDescFirst: false,
cell: ({ row }) => (
<b className="vgops-user-table__number">{row.original.active_sessions}</b>
),
},
{
id: "last_seen_at",
accessorFn: (user) => user.last_seen_at ?? 0,
header: "최근 접속",
cell: ({ row }) => {
const user = row.original;
const online = isOnline(user.last_seen_at);
return (
<div className="vgops-user-table__presence">
<span className={online ? "is-online" : ""}>
{online ? "온라인" : "오프라인"}
</span>
<time>{dateTimeLabel(user.last_seen_at)}</time>
</div>
);
},
},
{
id: "actions",
header: "작업",
enableSorting: false,
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? userDraftFrom(user);
const dirty =
draft.display_name !== user.display_name ||
draft.role !== user.role ||
draft.admin_access !== user.admin_access ||
draft.learner_feedback_enabled !==
(user.learner_feedback_enabled ?? true) ||
draft.account_status !== user.account_status ||
draft.affiliation !== user.affiliation ||
cohortInputValue(draft.cohort_ids) !==
cohortInputValue(user.cohort_ids);
return (
<div className="vgops-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>
);
},
},
];
const userTable = useReactTable({
data: filteredUsers,
columns: userColumns,
state: { sorting: userSorting },
onSortingChange: setUserSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (user) => user.user_id,
// 페이지네이션 미사용(수동 40행 슬라이스). row model 재계산이 pageIndex 리셋
// setState를 큐잉해 무한 렌더 루프를 만들던 경로를 차단한다(결함 로그 #19).
autoResetPageIndex: false,
});
const visibleUserRows = userTable
.getRowModel()
.rows.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={surfaceClassName(
`vgops-status vgops-status--${health?.status ?? "degraded"}`,
)}
role="status"
>
<span className="vgops-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={surfaceClassName("vgops-kpis")} aria-label="운영 요약">
<div className="vgops-kpi">
<span className="vgops-kpi__lab"> </span>
<b>{countLabel(activeSessions)}</b>
<small> </small>
</div>
<div className="vgops-kpi">
<span className="vgops-kpi__lab"> </span>
<b>{countLabel(onlineUsers)}</b>
<small> 15 </small>
</div>
<div className="vgops-kpi">
<span className="vgops-kpi__lab"> </span>
<b>{countLabel(users.length)}</b>
<small>{storeLabel(usersData)}</small>
</div>
<div className="vgops-kpi">
<span className="vgops-kpi__lab"> </span>
<b>{counts.total ? `${counts.ok}/${counts.total}` : "-"}</b>
<small>{incidentCount ? `${incidentCount}개 점검` : "정상"}</small>
</div>
</section>
<section className="vgops-overview-grid">
<div className={surfaceClassName("vgops-panel vgops-panel--wide")}>
<div className="vgops-section__head">
<h2> </h2>
<span> API </span>
</div>
<div className="vgops-flow-grid">
<article
className={surfaceClassName("vgops-flow", {
variant: "inset",
flat: true,
})}
>
<span> </span>
<b>{countLabel(activeSessions)}</b>
<small> </small>
</article>
<article
className={surfaceClassName("vgops-flow", {
variant: "inset",
flat: true,
})}
>
<span> </span>
<b>
{countLabel(
users.filter(
(user) =>
user.role === "teacher" && isOnline(user.last_seen_at),
).length,
)}
</b>
<small>· </small>
</article>
<article
className={surfaceClassName("vgops-flow", {
variant: "inset",
flat: true,
})}
>
<span> </span>
<b>
{countLabel(
users.filter(
(user) =>
user.role === "admin" && isOnline(user.last_seen_at),
).length,
)}
</b>
<small> </small>
</article>
<article
className={surfaceClassName("vgops-flow", {
variant: "inset",
flat: true,
})}
>
<span>AI </span>
<b>{usage ? countLabel(usage.metered_turns) : "-"}</b>
<small>{usage ? `최근 ${usage.window_days}` : "계산 중"}</small>
</article>
</div>
</div>
<div className={surfaceClassName("vgops-panel")}>
<div className="vgops-section__head">
<h2></h2>
<Badge tone={healthTone}>
{health ? statusLabel(health.status) : "확인 중"}
</Badge>
</div>
<div className="vgops-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={surfaceClassName("vgops-panel")}>
<div className="vgops-section__head">
<h2>AI </h2>
<span>{usageSourceLabel(usage)}</span>
</div>
{usageError ? <InlineError message={usageError} /> : null}
<div className="vgops-cost">
<b>{usage ? costLabel(usage.cost_usd) : "-"}</b>
<span>{usage ? usageBudgetLabel(usage) : "계산 중"}</span>
{usage ? <small>{usageBudgetDetail(usage)}</small> : null}
{usage && usageBudgetRemaining(usage) ? (
<small>{usageBudgetRemaining(usage)}</small>
) : null}
</div>
{usage ? (
<ProgressBar
value={Math.min(100, Math.round(usage.budget.used_ratio * 100))}
slim
/>
) : null}
{usage ? (
<div className="vgops-cache">
<span> hit-rate</span>
<b>{evaluatorCacheLabel(usage)}</b>
<small>{evaluatorCacheDetail(usage)}</small>
{evaluatorCacheVolume(usage) ? (
<small>{evaluatorCacheVolume(usage)}</small>
) : null}
</div>
) : null}
{usage && usageDailyCost(usage).length > 0 ? (
<div className="vgops-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={surfaceClassName("vgops-panel")}>
<div className="vgops-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={surfaceClassName("vgops-ticket-mini", {
variant: "inset",
flat: true,
})}
>
<div>
<b>{countLabel(tickets.summary.open_count)} </b>
<span>
{" "}
{countLabel(tickets.summary.high_priority_count)} · 24
{countLabel(tickets.summary.stale_count)}
</span>
</div>
{/* 상태·우선순위·신고자가 한 줄에 가운뎃점 2개로 붙어 있어 신고자를 다음 줄로 내렸다. */}
{activeTickets.slice(0, 2).map((ticket) => (
<div key={ticket.ticket_id}>
<b>{ticket.subject}</b>
<span>
{ticketStatusLabel(ticket.status)} ·{" "}
{ticketPriorityLabel(ticket.priority)}
</span>
<span>{ticket.reporter.email}</span>
</div>
))}
</div>
) : null}
</div>
</section>
{error ? <InlineError message={error} /> : null}
<section className="vgops-section">
<div className="vgops-section__head">
<h2> </h2>
<span>{health ? `${health.services.length}개 항목` : "대기 중"}</span>
</div>
{renderServices()}
</section>
</>
);
const renderServices = () => (
<div className={surfaceClassName("vgops-services")}>
{services.map((service) => (
<article
className={`vgops-service vgops-service--${service.status}`}
key={service.key}
>
<div className="vgops-service__top">
<div className="vgops-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="vgops-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="vgops-service vgops-service--skeleton"
key={index}
aria-hidden="true"
>
<span className="vgops-skel vgops-skel--name" />
<span className="vgops-skel vgops-skel--detail" />
<span className="vgops-skel vgops-skel--meter" />
</article>
))
: null}
{!loading && health && health.services.length === 0 ? (
<div className="vgops-empty"> .</div>
) : null}
</div>
);
const renderUsers = () => (
<>
{/* eyebrow "사용자 관리"는 아래 "가입 승인과 권한 관리"와 같은 말이라 제거 */}
<PageHeader
title="가입 승인과 권한 관리"
description="신규 가입은 승인 큐에서 처리하고, 역할과 코호트 변경은 사용자 목록에서 관리합니다."
/>
{usersError ? <InlineError message={usersError} /> : null}
<Tabs<UserTab>
id="admin-users-tabs"
ariaLabel="사용자 관리 탭"
items={[
{
value: "approval",
label: `가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
},
{ value: "manage", label: "사용자 목록" },
{ value: "register", label: "외부 연구참여자 사전등록" },
{ value: "activity", label: "활동 요약" },
]}
value={userTab}
onValueChange={setUserTab}
listClassName="vgops-tabs"
panelClassName="vgops-tabs__panel"
>
{userTab === "approval" ? renderApprovalQueue() : null}
{userTab === "manage" ? renderUserList() : null}
{userTab === "register" ? renderUserCreate() : null}
{userTab === "activity" ? renderUserActivity() : null}
</Tabs>
</>
);
const renderApprovalQueue = () => (
<section className="vgops-section">
<div className="vgops-approval-head">
<div>
<h2> </h2>
<p> pending .</p>
</div>
<div className="vgops-approval-metrics" aria-label="계정 승인 상태 요약">
<span
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<b>{accountCounts.pending}</b>
</span>
<span
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<b>{accountCounts.approved}</b>
</span>
<span
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<b>{accountCounts.suspended}</b>
</span>
</div>
</div>
{!usersWritable ? (
<div className="vgops-users-note vgops-users-note--warn" role="alert">
DB .
</div>
) : null}
{usersLoading && !usersData ? (
<div className={surfaceClassName("vgops-users")}>
{Array.from({ length: 2 }).map((_, index) => (
<article
className={surfaceClassName("vgops-user vgops-user--skeleton", {
variant: "inset",
})}
key={index}
aria-hidden="true"
>
<span className="vgops-skel vgops-skel--name" />
<span className="vgops-skel vgops-skel--detail" />
<span className="vgops-skel vgops-skel--meter" />
<span className="vgops-skel vgops-skel--action" />
</article>
))}
</div>
) : null}
{!usersLoading && pendingUsers.length === 0 ? (
<EmptyState
title="처리할 가입 요청이 없습니다"
body="새 사용자가 로그인하면 이곳에 승인 대기 항목으로 표시됩니다."
/>
) : null}
{pendingUsers.length > 0 ? (
<div className={surfaceClassName("vgops-approval-list")}>
{pendingUsers.map((user) => (
<article className="vgops-approval" key={user.user_id}>
<div className="vgops-user__id">
<span aria-hidden="true">{initialOf(user)}</span>
<div>
<b>{user.display_name}</b>
<p>{user.email}</p>
</div>
</div>
<div className="vgops-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="vgops-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={surfaceClassName("vgops-panel")}>
<div className="vgops-section__head">
<h2> </h2>
<span>{usersWritable ? "DB 저장 가능" : "읽기 전용"}</span>
</div>
<div className="vgops-users-note">
Google·
. ,
.
</div>
{!usersWritable ? (
<div className="vgops-users-note vgops-users-note--warn" role="alert">
DB .
</div>
) : null}
<form
className="vgops-user-create"
onSubmit={(event) => {
event.preventDefault();
void createUser();
}}
>
<label>
<span></span>
<input
type="email"
value={newUser.email}
onChange={(event) =>
setNewUser((current) => ({
...current,
email: event.target.value,
}))
}
placeholder="participant@example.com"
autoComplete="email"
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="vgops-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 className="vgops-checkline">
<input
type="checkbox"
checked={newUser.learner_feedback_enabled}
onChange={(event) =>
setNewUser((current) => ({
...current,
learner_feedback_enabled: event.target.checked,
}))
}
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 학습자 AI 피드백"
/>
<span> AI </span>
</label>
<label>
<span> </span>
<output
className="vgops-readonly-control"
aria-label="새 사용자 승인 상태"
data-value="pending"
>
</output>
</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="vgops-section">
<div className="vgops-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={surfaceClassName("vgops-users")}>
{usersData ? (
<div
className="vgops-user-table-scroll"
tabIndex={0}
role="region"
aria-label="사용자 관리 표. 작은 화면에서는 가로로 스크롤할 수 있습니다."
>
<table className="vgops-user-table">
<thead>
{userTable.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted();
return (
<th
key={header.id}
scope="col"
aria-sort={
sorted === "asc"
? "ascending"
: sorted === "desc"
? "descending"
: header.column.getCanSort()
? "none"
: undefined
}
>
{header.column.getCanSort() ? (
<button
type="button"
className="vgops-user-sort"
onClick={header.column.getToggleSortingHandler()}
>
<span>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
<span
className="vgops-user-sort__indicator"
aria-hidden="true"
>
{sorted === "asc"
? "↑"
: sorted === "desc"
? "↓"
: "↕"}
</span>
</button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{visibleUserRows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
{usersLoading && !usersData ? (
<div className="vgops-user-table-loading" aria-hidden="true">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index}>
<span className="vgops-skel vgops-skel--name" />
<span className="vgops-skel vgops-skel--detail" />
<span className="vgops-skel vgops-skel--meter" />
<span className="vgops-skel vgops-skel--action" />
</div>
))}
</div>
) : null}
{!usersLoading && !usersData ? (
<div className="vgops-users-empty">
. .
</div>
) : null}
{!usersLoading &&
usersData &&
filteredUsers.length > visibleUserRows.length ? (
<div className="vgops-users-empty">
{" "}
{filteredUsers.length - visibleUserRows.length}
.
</div>
) : null}
{!usersLoading && usersData && filteredUsers.length === 0 ? (
<div className="vgops-users-empty">
{hasUserSearch
? "검색 조건에 맞는 사용자가 없습니다."
: "아직 등록된 사용자가 없습니다."}
</div>
) : null}
</div>
</section>
);
const renderUserActivity = () => (
<section className="vgops-overview-grid">
<div className={surfaceClassName("vgops-panel")}>
<div className="vgops-section__head">
<h2> </h2>
<span>{storeLabel(usersData)}</span>
</div>
<div className="vgops-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={surfaceClassName("vgops-panel vgops-panel--wide")}>
<div className="vgops-section__head">
<h2> </h2>
<span> last_seen </span>
</div>
<div className="vgops-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="vgops-users-empty">
.
</div>
) : null}
</div>
</div>
</section>
);
const renderProtocols = () => (
<section className="vgops-protocols" aria-label="상담 프로토콜 관리">
<section className={surfaceClassName("vgops-panel vgops-protocol-create") }>
<div className="vgops-section__head">
<h2> </h2>
<Badge tone="neutral"> </Badge>
</div>
<p className="vgops-protocol-intro">
RAG에 , C/D LLM에
.
</p>
<form
className="vgops-protocol-form"
onSubmit={(event) => {
event.preventDefault();
void createProtocol();
}}
>
<label>
<span></span>
<input
required
value={protocolDraft.title}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
title: event.target.value,
}))
}
aria-label="프로토콜 제목"
disabled={protocolSaving}
/>
</label>
<label className="vgops-protocol-form__source">
<span></span>
<input
required
value={protocolDraft.source}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
source: event.target.value,
}))
}
placeholder="공식 문서 URL 또는 출처 식별자"
aria-label="프로토콜 출처"
disabled={protocolSaving}
/>
</label>
<label>
<span></span>
<input
type="number"
min={1}
max={1_000_000}
required
value={protocolDraft.version}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
version: Number(event.target.value),
}))
}
aria-label="프로토콜 버전"
aria-invalid={protocolVersionError(protocolDraft.version) !== null}
aria-describedby="protocol-version-error"
disabled={protocolSaving}
/>
{protocolVersionError(protocolDraft.version) ? (
<small
id="protocol-version-error"
className="vgops-protocol-field-error"
role="alert"
>
{protocolVersionError(protocolDraft.version)}
</small>
) : null}
</label>
<label>
<span></span>
<select
value={protocolDraft.license}
onChange={(event) => {
const license = event.target.value as ProtocolDraft["license"];
setProtocolDraft((current) => ({
...current,
license,
external_llm_ok:
protocolAllowsExternalLlm(license)
? current.external_llm_ok
: false,
}));
}}
aria-label="프로토콜 라이선스"
disabled={protocolSaving}
>
{(["A", "B", "C", "D"] as const).map((license) => (
<option value={license} key={license}>
{license}
</option>
))}
</select>
</label>
<label className="vgops-protocol-check">
<input
type="checkbox"
checked={protocolDraft.external_llm_ok}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
external_llm_ok: event.target.checked,
}))
}
aria-label="외부 LLM 사용 허용"
disabled={
protocolSaving ||
protocolDraft.license === "C" ||
protocolDraft.license === "D"
}
/>
<span> LLM </span>
</label>
<p className="vgops-protocol-policy" aria-live="polite">
{protocolDraft.license === "C" || protocolDraft.license === "D"
? "라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."
: "A/B도 명시적으로 허용한 경우에만 외부 LLM에 전달됩니다."}
</p>
<label className="vgops-protocol-form__content">
<span> </span>
<textarea
required
value={protocolDraft.content}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
content: event.target.value,
}))
}
aria-label="프로토콜 원문"
disabled={protocolSaving}
/>
</label>
<Button
className="vgops-protocol-submit"
type="submit"
disabled={protocolSaving}
leading={<Icon name="plus" size={16} />}
>
{protocolSaving ? "초안 등록 중" : "초안 등록"}
</Button>
</form>
</section>
<section className={surfaceClassName("vgops-panel vgops-protocol-list") }>
<div className="vgops-section__head">
<h2> </h2>
<span>{protocols.length}</span>
</div>
<div className="vgops-protocol-toolbar">
<label>
<span></span>
<input
type="search"
value={protocolSearch}
onChange={(event) => setProtocolSearch(event.target.value)}
placeholder="제목 또는 출처"
aria-label="프로토콜 검색"
/>
</label>
<label>
<span></span>
<select
value={protocolStatusFilter}
onChange={(event) =>
setProtocolStatusFilter(event.target.value as ProtocolStatusFilter)
}
aria-label="프로토콜 상태 필터"
>
<option value="all"></option>
<option value="draft"></option>
<option value="active"></option>
<option value="retired"></option>
</select>
</label>
<Button
variant="secondary"
onClick={() => void loadProtocols()}
disabled={protocolsLoading}
>
{protocolsLoading ? "불러오는 중" : "새로고침"}
</Button>
</div>
{protocolsError ? (
<div className="vgops-protocol-error">
<InlineError message={protocolsError} />
<Button
variant="secondary"
onClick={() => void loadProtocols()}
disabled={protocolsLoading}
>
</Button>
</div>
) : null}
{protocolsLoading && !protocolsLoaded ? (
<div className="vgops-users-note" role="status">
.
</div>
) : null}
{!protocolsLoading && protocolsLoaded && visibleProtocols.length === 0 ? (
<EmptyState
title="조건에 맞는 프로토콜이 없습니다"
body="새 초안을 등록하거나 검색·상태 필터를 바꿔 주세요."
/>
) : null}
<div className="vgops-protocol-cards">
{visibleProtocols.map((protocol) => (
<article
className={surfaceClassName("vgops-protocol-card", {
variant: "inset",
})}
key={protocol.protocol_id}
data-protocol-status={protocol.status}
>
<div className="vgops-protocol-card__copy">
<div className="vgops-protocol-card__title">
<h3>{protocol.title}</h3>
<Badge tone={protocolStatusTone(protocol.status)}>
{protocolStatusLabel(protocol.status)}
</Badge>
</div>
<p>{protocol.source}</p>
<div className="vgops-chip-row">
<span>v{protocol.version}</span>
<span> {protocol.license}</span>
<span>
LLM {protocol.external_llm_ok ? "허용" : "차단"}
</span>
</div>
</div>
<div className="vgops-protocol-card__actions">
{protocol.status === "draft" ? (
<Button
onClick={() => void activateProtocol(protocol)}
disabled={protocolActionId === protocol.protocol_id}
aria-label={`${protocol.title} 활성화`}
>
{protocolActionId === protocol.protocol_id
? "활성화 중"
: "활성화"}
</Button>
) : null}
{protocol.status === "active" ? (
<Button
variant="danger"
onClick={() => void retireProtocol(protocol)}
disabled={protocolActionId === protocol.protocol_id}
aria-label={`${protocol.title} 퇴역`}
>
{protocolActionId === protocol.protocol_id ? "퇴역 중" : "퇴역"}
</Button>
) : null}
{protocol.status === "retired" ? (
<span className="vgops-protocol-card__terminal"> </span>
) : null}
</div>
</article>
))}
</div>
</section>
</section>
);
const renderAccess = () => (
<>
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
<PageHeader
title="역할, 그룹, 접근 범위"
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
/>
{accessTab !== "protocols" ? (
<div className="vgops-users-note">
· API는 .
.
</div>
) : null}
<Tabs<AccessTab>
id="admin-access-tabs"
ariaLabel="접근 권한 탭"
items={[
{ value: "roles", label: "역할" },
{ value: "groups", label: "그룹" },
{ value: "matrix", label: "권한 매트릭스" },
{ value: "protocols", label: "상담 프로토콜" },
]}
value={accessTab}
onValueChange={setAccessTab}
listClassName="vgops-tabs"
panelClassName="vgops-tabs__panel"
>
{accessTab === "roles" ? (
<section className="vgops-policy-grid">
{ROLE_POLICIES.map((policy) => (
<article
className={surfaceClassName("vgops-policy", { variant: "inset" })}
key={policy.role}
>
<div className="vgops-section__head">
<h2>{policy.role}</h2>
<Badge tone={policy.role === "관리자" ? "warn" : "neutral"}>
{policy.permissions.length}
</Badge>
</div>
<p>{policy.scope}</p>
<div className="vgops-chip-row">
{policy.permissions.map((permission) => (
<span key={permission}>{permission}</span>
))}
</div>
<small>{policy.risk}</small>
</article>
))}
</section>
) : null}
{accessTab === "groups" ? (
<section className="vgops-policy-grid">
{GROUP_POLICIES.map((group) => (
<article
className={surfaceClassName("vgops-policy", { variant: "inset" })}
key={group.name}
>
<div className="vgops-section__head">
<h2>{group.name}</h2>
<Badge tone="neutral"> </Badge>
</div>
<p>{group.scope}</p>
<div className="vgops-chip-row">
{group.access.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</article>
))}
</section>
) : null}
{accessTab === "matrix" ? (
<section className={surfaceClassName("vgops-panel")}>
<div
className={surfaceClassName("vgops-access-table", {
variant: "inset",
})}
>
<div className="vgops-access-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{PERMISSION_MATRIX.map((row) => (
<div className="vgops-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}
{accessTab === "protocols" ? renderProtocols() : null}
</Tabs>
</>
);
const renderTickets = () => (
<>
{/* eyebrow "운영 티켓"은 아래 "사용자 문제 큐"와 같은 말이라 제거 */}
<PageHeader
title="사용자 문제 큐"
description="미해결 접수를 우선 확인하고 처리 상태를 갱신합니다."
>
<Button
variant="secondary"
leading={<Icon name="settings" size={16} />}
onClick={() => void loadTickets()}
disabled={ticketsLoading}
>
{ticketsLoading ? "확인 중" : "새로고침"}
</Button>
</PageHeader>
<div className="vgops-users-note">
DB .
.
</div>
<section className="vgops-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="vgops-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="vgops-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="vgops-section">
{ticketsError ? <InlineError message={ticketsError} /> : null}
<div className={surfaceClassName("vgops-kpis")} aria-label="티켓 요약">
<div className="vgops-kpi">
<span className="vgops-kpi__lab"> </span>
<b>{tickets ? countLabel(tickets.summary.total) : "-"}</b>
<small>{tickets?.durable ? "DB 티켓" : "저장소 확인 중"}</small>
</div>
<div className="vgops-kpi">
<span className="vgops-kpi__lab"></span>
<b>{tickets ? countLabel(tickets.summary.open_count) : "-"}</b>
<small>open, triaged, in_progress</small>
</div>
<div className="vgops-kpi">
<span className="vgops-kpi__lab"> </span>
<b>
{tickets ? countLabel(tickets.summary.high_priority_count) : "-"}
</b>
<small> high, urgent</small>
</div>
<div className="vgops-kpi">
<span className="vgops-kpi__lab"></span>
<b>{tickets ? countLabel(tickets.summary.stale_count) : "-"}</b>
<small>24 </small>
</div>
</div>
{ticketsLoading && !tickets ? (
<div className={surfaceClassName("vgops-panel")}>
<EmptyState
title="티켓 큐를 불러오는 중입니다"
body="운영 티켓 저장소를 조회하고 있습니다."
/>
</div>
) : null}
{!ticketsLoading && tickets && activeTickets.length === 0 ? (
<div className={surfaceClassName("vgops-panel")}>
<EmptyState
title="미해결 운영 티켓이 없습니다"
body="새 접수가 들어오면 이 큐에 우선순위와 상태가 표시됩니다."
/>
</div>
) : null}
{activeTickets.length > 0 ? (
<div
className={surfaceClassName("vgops-ticket-list")}
aria-label="미해결 운영 티켓 큐"
>
{visibleActiveTickets.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="vgops-ticket" key={ticket.ticket_id}>
<div>
{/* 담당 그룹·처리 이력은 아래 <small> 에서 가운뎃점으로 이어 붙었었다.
칸 구분이 있는 meta 행으로 옮겨 <small> 은 가운뎃점 1개만 남긴다. */}
<div className="vgops-ticket__meta">
<span>{ticketCategoryLabel(ticket.category)}</span>
<span>{ticket.reporter.email}</span>
<span> {dateTimeLabel(ticket.updated_at)}</span>
{ticket.assigned_group ? (
<span> {ticket.assigned_group}</span>
) : null}
{ticket.event_count > 0 ? (
<span> {countLabel(ticket.event_count)}</span>
) : null}
</div>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
{ticket.duplicate_count > 0 ||
ticket.parent_ticket_id ||
ticket.child_ticket_count > 0 ? (
<div className="vgops-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)}
</small>
</div>
<Badge tone={ticketTone(ticket)}>
{ticketPriorityLabel(ticket.priority)}
</Badge>
<Badge tone="accent">
{ticketStatusLabel(ticket.status)}
</Badge>
<TicketActions
ticket={ticket}
duplicateParentId={duplicateParentId ?? null}
canLinkDuplicate={canLinkDuplicate}
onUpdateStatus={updateTicketStatus}
onUpdateParent={updateTicketParent}
/>
</article>
);
})}
</div>
) : null}
{activeTickets.length > visibleActiveTickets.length ? (
<p className="vgops-users-note">
{countLabel(visibleActiveTickets.length)}
. {countLabel(activeTickets.length - visibleActiveTickets.length)}
· .
</p>
) : null}
</section>
{recentResolvedTickets.length > 0 ? (
<section className="vgops-section">
<div className="vgops-section__head">
<h2> </h2>
<span>{countLabel(recentResolvedTickets.length)}</span>
</div>
<div
className={surfaceClassName(
"vgops-ticket-list vgops-ticket-list--history",
)}
>
{recentResolvedTickets.map((ticket) => (
<article
className="vgops-ticket vgops-ticket--history"
key={ticket.ticket_id}
>
<div>
{/* 미해결 큐와 같은 규칙: 담당·처리 이력은 meta 행으로 옮긴다. */}
<div className="vgops-ticket__meta">
<span>{ticketCategoryLabel(ticket.category)}</span>
<span>{ticket.reporter.email}</span>
<span>
{" "}
{dateTimeLabel(ticket.resolved_at ?? ticket.updated_at)}
</span>
{ticket.assigned_group ? (
<span> {ticket.assigned_group}</span>
) : null}
{ticket.event_count > 0 ? (
<span> {countLabel(ticket.event_count)}</span>
) : null}
</div>
<h2>{ticket.subject}</h2>
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {" "}
{dateTimeLabel(ticket.created_at)}
</small>
</div>
<Badge tone="neutral">
{ticketPriorityLabel(ticket.priority)}
</Badge>
<Badge tone="neutral">{ticketStatusLabel(ticket.status)}</Badge>
<div className="vgops-ticket__actions vgops-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="vgops-error" role="alert">
<Icon name="alert" size={18} />
<span>{message}</span>
</section>
);
}
function EmptyState({ title, body }: { title: string; body: string }) {
return (
<UiEmptyState
className="vgops-empty-state"
title={title}
description={body}
descriptionAs="p"
/>
);
}
function RoleMeter({
label,
value,
total,
}: {
label: string;
value: number;
total: number;
}) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="vgops-role-meter">
<div>
<span>{label}</span>
<b>{countLabel(value)}</b>
</div>
<ProgressBar value={pct} slim label={`${label} 비율`} />
</div>
);
}