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