Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -1,105 +0,0 @@
/* =====================================================================
Admin inline SVG .
Icon(ui/Icon.tsx) .
( Icon .)
철칙: 이모지 . stroke=currentColor, fill=none.
===================================================================== */
import type { ReactNode, SVGProps } from "react";
type LocalIconName =
| "search"
| "download"
| "database"
| "persona"
| "megaphone"
| "shield-check"
| "sliders";
interface AdminIconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: LocalIconName;
size?: number;
strokeWidth?: number;
}
const PATHS: Record<LocalIconName, ReactNode> = {
search: (
<>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</>
),
download: (
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</>
),
database: (
<>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
<path d="M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3" />
</>
),
persona: (
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4 3.6-6 8-6s8 2 8 6" />
</>
),
megaphone: (
<>
<path d="M3 11l18-5v12L3 14v-3z" />
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
</>
),
"shield-check": (
<>
<path d="M12 2l8 3v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V5l8-3z" />
<path d="M9 12l2 2 4-4" />
</>
),
sliders: (
<>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</>
),
};
/** Admin 로컬 아이콘 — stroke 기반, currentColor. */
export function AdminIcon({
name,
size = 18,
strokeWidth = 1.8,
...rest
}: AdminIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{PATHS[name]}
</svg>
);
}
export type { LocalIconName };

View file

@ -1,203 +0,0 @@
/* =====================================================================
audit log + + (geo) + .
§6.6: 시간순 , ··. (). warn .
(Admin) state / .
===================================================================== */
import { Badge, Dot } from "../../components/ui";
import { AdminIcon } from "./AdminIcons";
import {
ACTION_META,
scopeTone,
type AuditAction,
type AuditEntry,
} from "./data";
export interface AuditFilter {
key: AuditAction | "all";
label: string;
}
export const AUDIT_FILTERS: AuditFilter[] = [
{ key: "all", label: "전체" },
{ key: "view", label: "세션 열람" },
{ key: "audio", label: "오디오 재생" },
{ key: "export", label: "내보내기" },
{ key: "grade", label: "평가 작성" },
{ key: "edit", label: "평가 수정" },
];
export interface AuditTableProps {
/** 현재 페이지에 보일 행 */
rows: AuditEntry[];
/** 활성 필터 */
filter: AuditAction | "all";
onFilter: (key: AuditAction | "all") => void;
/** 검색어 */
query: string;
onQuery: (q: string) => void;
/** 페이지네이션 */
page: number;
pageCount: number;
onPage: (p: number) => void;
/** 전체 매칭 건수(푸터 표기) */
total: number;
/** 현재 페이지 표시 범위 */
rangeFrom: number;
rangeTo: number;
}
function GeoTag({ geo }: { geo: "kr" | "us" }) {
const isKr = geo === "kr";
return (
<span
className="vgad-geo"
title={isKr ? "국내(KR) 추론 — 데이터 주권 준수" : "해외(US) 추론 경유"}
>
<span className={"vgad-geo__dot " + (isKr ? "vgad-geo__dot--kr" : "vgad-geo__dot--us")} />
{isKr ? "KR" : "US"}
</span>
);
}
export function AuditTable(props: AuditTableProps) {
const {
rows,
filter,
onFilter,
query,
onQuery,
page,
pageCount,
onPage,
total,
rangeFrom,
rangeTo,
} = props;
return (
<div className="vgad-tablewrap">
{/* 툴바: 검색 + 행위 필터 칩 */}
<div className="vgad-toolbar">
<label className="vgad-search">
<span className="vgad-search__ic">
<AdminIcon name="search" size={15} strokeWidth={2} />
</span>
<input
value={query}
onChange={(e) => onQuery(e.target.value)}
placeholder="교수자·학습자·세션 ID로 검색"
aria-label="감사 로그 검색"
/>
</label>
{AUDIT_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={"vgad-chip" + (filter === f.key ? " is-on" : "")}
aria-pressed={filter === f.key}
onClick={() => onFilter(f.key)}
>
{f.label}
</button>
))}
<span className="vgad-toolbar__spacer" />
</div>
<table className="vgad-table">
<thead>
<tr>
<th style={{ width: 96 }}></th>
<th style={{ width: 190 }}></th>
<th style={{ width: 170 }} className="vgad-hide-sm"></th>
<th></th>
<th style={{ width: 74 }} className="vgad-hide-sm"> </th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr className="vgad-empty-row">
<td colSpan={6}> .</td>
</tr>
) : (
rows.map((r) => {
const meta = ACTION_META[r.action];
return (
<tr key={r.id}>
<td className="vgad-td-time">{r.time}</td>
<td>
<div className="vgad-actor">
<span className="vgad-actor__av" aria-hidden="true">
{r.actorInitial}
</span>
<span>
<span className="vgad-actor__nm">{r.actorName}</span>
<span className="vgad-actor__sub">{r.actorRole}</span>
</span>
</div>
</td>
<td className="vgad-hide-sm">
<span className="vgad-act">
<Dot tone={meta.tone} size={7} />
{r.actionLabel}
</span>
</td>
<td>
<span className="vgad-target">
{r.target}
<span className="vgad-target__meta">{r.targetMeta}</span>
</span>
</td>
<td className="vgad-hide-sm">
<GeoTag geo={r.geo} />
</td>
<td>
<Badge tone={scopeTone(r.sensitive)}>{r.scope}</Badge>
</td>
</tr>
);
})
)}
</tbody>
</table>
<div className="vgad-tfoot">
<span className="vgad-tfoot__cnt tabular">
{total > 0
? `최근 24시간 ${total}건 중 ${rangeFrom}${rangeTo} 표시 · 모든 열람은 위·변조 불가 로그로 보존됩니다`
: "표시할 감사 기록이 없습니다"}
</span>
<div className="vgad-pg">
<button
type="button"
disabled={page <= 1}
onClick={() => onPage(page - 1)}
aria-label="이전 페이지"
>
</button>
{Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => (
<button
key={p}
type="button"
className={p === page ? "is-on" : ""}
aria-current={p === page ? "page" : undefined}
onClick={() => onPage(p)}
>
{p}
</button>
))}
<button
type="button"
disabled={page >= pageCount}
onClick={() => onPage(page + 1)}
aria-label="다음 페이지"
>
</button>
</div>
</div>
</div>
);
}

View file

@ -1,127 +0,0 @@
/* =====================================================================
(§6.7 ).
/ / .
y축 , (--ink-2), 1 accent .
draw admin.css (prefers-reduced-motion ).
===================================================================== */
import { useMemo, type CSSProperties } from "react";
/** CSS 커스텀 프로퍼티(--vgad-len)를 style 에 안전하게 싣기 위한 확장 타입. */
type CSSVarStyle = CSSProperties & Record<`--${string}`, string | number>;
export interface TrendPoint {
day: string;
value: number;
}
export interface TrendChartProps {
data: TrendPoint[];
/** 접근성 라벨 */
label?: string;
}
const W = 560;
const H = 160;
const PAD_L = 34;
const PAD_R = 16;
const PAD_T = 16;
const PAD_B = 26;
export function TrendChart({ data, label = "세션 추이" }: TrendChartProps) {
const { points, peak, gridYs, niceMax } = useMemo(() => {
const max = Math.max(...data.map((d) => d.value), 1);
// 50 단위로 올림(차트 y 상한 — tabular 라벨용)
const nm = Math.ceil(max / 50) * 50;
const innerW = W - PAD_L - PAD_R;
const innerH = H - PAD_T - PAD_B;
const stepX = data.length > 1 ? innerW / (data.length - 1) : 0;
const pts = data.map((d, i) => ({
...d,
x: PAD_L + stepX * i,
y: PAD_T + innerH * (1 - d.value / nm),
}));
// 피크(최댓값) 1점만 라벨
let peakIdx = 0;
data.forEach((d, i) => {
if (d.value > data[peakIdx].value) peakIdx = i;
});
const ys = [0, 0.5, 1].map((r) => ({
v: Math.round(nm * (1 - r)),
y: PAD_T + innerH * r,
}));
return { points: pts, peak: pts[peakIdx], gridYs: ys, niceMax: nm };
}, [data]);
const path = points
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(" ");
// 폴리라인 길이 근사 → draw 애니메이션 dasharray 변수
const len = useMemo(() => {
let total = 0;
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x;
const dy = points[i].y - points[i - 1].y;
total += Math.hypot(dx, dy);
}
return Math.ceil(total) + 4;
}, [points]);
return (
<svg
className="vgad-trend"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={`${label}. 최대 ${niceMax}건, 피크 ${peak.day}요일 ${peak.value}건.`}
style={{ "--vgad-len": len } as CSSVarStyle}
>
{/* y축 점선 격자 + 라벨 (세로 격자 없음) */}
{gridYs.map((g, i) => (
<g key={i}>
<line
className="vgad-trend__grid"
x1={PAD_L}
y1={g.y}
x2={W - PAD_R}
y2={g.y}
/>
<text className="vgad-trend__ylab" x={PAD_L - 8} y={g.y + 3} textAnchor="end">
{g.v}
</text>
</g>
))}
{/* 단일 라인 */}
<path className="vgad-trend__line" d={path} />
{/* 피크 1점만 강조 */}
<circle className="vgad-trend__peak" cx={peak.x} cy={peak.y} r={3.5} />
<text
className="vgad-trend__peaklab"
x={peak.x}
y={peak.y - 10}
textAnchor="middle"
>
{peak.value}
</text>
{/* x축 요일 라벨 */}
{points.map((p, i) => (
<text
key={i}
className="vgad-trend__xlab"
x={p.x}
y={H - 8}
textAnchor="middle"
>
{p.day}
</text>
))}
</svg>
);
}

View file

@ -1,869 +0,0 @@
/* =====================================================================
Admin 페이지 전용 스타일. 토큰(tokens.css) 참조.
네임스페이스 vgad- 공통 ui.css(vg-) 충돌 0.
철칙: border-left 강조선 0 · 이모지 0 · 카드덤프 0 · 순흑/순백 0 ·
radius 절제(8/12) · 강조는 weight+tint+kicker+dot.
===================================================================== */
/* ── 페이지 헤드 (제목 + 우측 동기화 시각) ── */
.vgad-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--sp-5);
margin-bottom: var(--sp-6);
flex-wrap: wrap;
}
.vgad-head__title {
font-size: var(--fs-h1);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-strong);
line-height: 1.25;
margin-top: var(--sp-2);
}
.vgad-head__lead {
font-size: var(--fs-body);
color: var(--text-body);
margin-top: 6px;
max-width: 600px;
}
.vgad-head__when {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
text-align: right;
}
.vgad-head__when b {
display: block;
color: var(--text-body);
font-weight: 600;
letter-spacing: 0.01em;
}
/* ── 한 줄 상태 요약 (에디토리얼 — 큰 문장 하나) ── */
.vgad-statusline {
display: flex;
align-items: center;
gap: 14px;
padding: var(--sp-5) var(--sp-6);
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
}
.vgad-statusline__pulse {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--pos-solid);
flex: none;
box-shadow: 0 0 0 0 rgba(59, 140, 94, 0.4);
animation: vgad-pulse var(--dur-breathe) var(--ease-in-out) infinite;
}
.vgad-statusline__pulse--warn {
background: var(--warn-solid);
animation: none;
}
.vgad-statusline__pulse--crit {
background: var(--crit-solid);
animation: none;
}
@keyframes vgad-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 140, 94, 0.35);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 140, 94, 0);
}
}
.vgad-statusline__msg {
font-size: var(--fs-lead);
font-weight: 500;
color: var(--text-strong);
line-height: 1.5;
}
.vgad-statusline__msg b {
font-weight: 700;
}
.vgad-statusline__detail {
color: var(--text-body);
font-weight: 400;
}
.vgad-statusline__ts {
margin-left: auto;
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
}
/* ── 운영 KPI (인라인 행 — 카드덤프 아님, 헤어라인 셀 구분) ── */
.vgad-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
margin-top: var(--sp-5);
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
overflow: hidden;
}
.vgad-kpi {
padding: var(--sp-5) var(--sp-5);
}
/* 셀 구분 헤어라인 — 강조선이 아니라 컬럼 divider */
.vgad-kpi + .vgad-kpi {
border-left: 1px solid var(--hair);
}
.vgad-kpi__l {
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 500;
}
.vgad-kpi__n {
font-size: 32px;
font-weight: 700;
color: var(--text-strong);
letter-spacing: -0.02em;
margin-top: 8px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.vgad-kpi__n--warn {
color: var(--warn-text);
}
.vgad-kpi__n--crit {
color: var(--crit-text);
}
.vgad-kpi__u {
font-size: 15px;
font-weight: 500;
color: var(--text-muted);
margin-left: 4px;
letter-spacing: 0;
}
.vgad-kpi__d {
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: 9px;
}
.vgad-kpi__d b {
font-weight: 600;
}
.vgad-kpi__d--pos b {
color: var(--pos-text);
}
.vgad-kpi__d--info b {
color: var(--info-text);
}
.vgad-kpi__d--warn b {
color: var(--warn-text);
}
.vgad-kpi__d--crit b {
color: var(--crit-text);
}
/* ── 섹션 ── */
.vgad-section {
margin-top: var(--sp-7);
}
.vgad-sechead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
margin-bottom: var(--sp-5);
}
.vgad-sechead__title {
font-size: var(--fs-h3);
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-strong);
}
.vgad-sechead__meta {
font-size: var(--fs-xs);
color: var(--text-muted);
}
/* ── 헬스 라인 ── */
.vgad-health {
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
overflow: hidden;
}
.vgad-hrow {
display: grid;
grid-template-columns: 220px 1fr auto 86px;
align-items: center;
gap: var(--sp-4);
padding: 14px var(--sp-6);
}
.vgad-hrow + .vgad-hrow {
border-top: 1px solid var(--hair);
}
.vgad-hrow__svc {
display: flex;
align-items: center;
gap: 11px;
}
.vgad-hrow__nm {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
line-height: 1.3;
}
.vgad-hrow__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
.vgad-hrow__gauge {
height: 5px;
background: var(--paper-2);
border-radius: 3px;
overflow: hidden;
max-width: 300px;
}
.vgad-hrow__fill {
height: 100%;
border-radius: 3px;
background: var(--accent-bright);
transition: width var(--dur-slow) var(--ease-out);
}
.vgad-hrow__fill--warn {
background: var(--warn-solid);
}
.vgad-hrow__fill--crit {
background: var(--crit-solid);
}
.vgad-hrow__num {
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-body);
text-align: right;
white-space: nowrap;
}
.vgad-hrow__stat {
justify-self: end;
}
/* ── 2-컬럼 수집 현황 ── */
.vgad-collect {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: var(--sp-6);
}
.vgad-panel__ph {
font-size: var(--fs-body);
font-weight: 600;
color: var(--text-strong);
margin-bottom: var(--sp-2);
}
.vgad-panel__psub {
font-size: var(--fs-xs);
color: var(--text-muted);
margin-bottom: var(--sp-5);
}
/* 분포 — 가로 게이지 목록 */
.vgad-distrib {
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.vgad-dist {
display: grid;
grid-template-columns: 130px 1fr 52px;
align-items: center;
gap: var(--sp-4);
}
.vgad-dist__l {
font-size: var(--fs-sm);
color: var(--text-body);
font-weight: 500;
}
.vgad-dist__v {
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
text-align: right;
}
/* 역할별 사용 — 행 목록 */
.vgad-rolelist {
display: flex;
flex-direction: column;
}
.vgad-rr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
}
.vgad-rr + .vgad-rr {
border-top: 1px solid var(--paper-2);
}
.vgad-rr__l {
display: flex;
align-items: center;
gap: 11px;
}
.vgad-rr__sq {
width: 9px;
height: 9px;
border-radius: 2px;
flex: none;
}
.vgad-rr__nm {
font-size: var(--fs-sm);
color: var(--text-strong);
font-weight: 500;
}
.vgad-rr__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
.vgad-rr__v {
font-family: var(--font-num);
font-size: var(--fs-lead);
font-weight: 700;
color: var(--text-strong);
}
.vgad-rr__delta {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--pos-text);
margin-left: 8px;
}
/* ── 사용·비용 미터 목록 ── */
.vgad-usage {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--sp-5) var(--sp-6);
}
.vgad-meter__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: 8px;
}
.vgad-meter__nm {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
}
.vgad-meter__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
.vgad-meter__v {
font-family: var(--font-num);
font-size: var(--fs-body);
font-weight: 700;
color: var(--text-strong);
white-space: nowrap;
}
.vgad-meter__v--warn {
color: var(--warn-text);
}
.vgad-meter__foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 7px;
}
.vgad-meter__budget {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
}
.vgad-meter__pct {
font-family: var(--font-num);
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-body);
}
.vgad-meter__pct--warn {
color: var(--warn-text);
}
/* ── 세션 추이 라인 차트 (단일 라인, 막대 금지) ── */
.vgad-trend {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.vgad-trend__grid {
stroke: var(--hair);
stroke-width: 1;
stroke-dasharray: 3 4;
}
.vgad-trend__line {
fill: none;
stroke: var(--ink-2);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.vgad-trend__peak {
fill: var(--accent);
}
.vgad-trend__peaklab {
font-family: var(--font-num);
font-size: 12px;
font-weight: 700;
fill: var(--accent-deep);
}
.vgad-trend__xlab {
font-family: var(--font-num);
font-size: 11px;
fill: var(--text-muted);
}
.vgad-trend__ylab {
font-family: var(--font-num);
font-size: 10px;
fill: var(--text-muted);
}
@media (prefers-reduced-motion: no-preference) {
.vgad-trend__line {
stroke-dasharray: var(--vgad-len, 1000);
stroke-dashoffset: var(--vgad-len, 1000);
animation: vgad-draw var(--dur-slow) var(--ease-out) forwards;
}
}
@keyframes vgad-draw {
to {
stroke-dashoffset: 0;
}
}
/* ── 교수 활동 감사 + audit log 공통 테이블 ── */
.vgad-tablewrap {
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
overflow: hidden;
}
.vgad-toolbar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--hair);
flex-wrap: wrap;
}
.vgad-search {
flex: 1;
min-width: 200px;
max-width: 320px;
display: flex;
align-items: center;
gap: 9px;
background: var(--bg-surface-2);
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
padding: 7px 12px;
transition: border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.vgad-search:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.vgad-search__ic {
color: var(--text-muted);
flex: none;
display: inline-flex;
}
.vgad-search input {
border: none;
background: none;
outline: none;
font-family: var(--font-sans);
font-size: var(--fs-sm);
color: var(--text-strong);
width: 100%;
}
.vgad-search input::placeholder {
color: var(--text-muted);
}
.vgad-chip {
font-size: var(--fs-xs);
font-weight: 500;
color: var(--text-body);
padding: 7px 13px;
border-radius: var(--radius-sm);
border: 1px solid var(--hair);
background: var(--bg-surface);
cursor: pointer;
white-space: nowrap;
transition: background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
.vgad-chip:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
.vgad-chip.is-on {
background: var(--accent-tint);
color: var(--accent-deep);
border-color: transparent;
font-weight: 600;
}
.vgad-toolbar__spacer {
flex: 1;
}
.vgad-table {
width: 100%;
border-collapse: collapse;
}
.vgad-table thead th {
font-family: var(--font-num);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 11px var(--sp-5);
background: var(--paper-2);
border-bottom: 1px solid var(--hair);
}
.vgad-table thead th.is-num {
text-align: right;
}
.vgad-table tbody td {
padding: 14px var(--sp-5);
border-bottom: 1px solid var(--paper-2);
font-size: var(--fs-sm);
color: var(--text-body);
vertical-align: middle;
}
.vgad-table tbody tr:last-child td {
border-bottom: none;
}
.vgad-table tbody tr {
transition: background var(--dur-fast) var(--ease-out);
}
.vgad-table tbody tr:hover {
background: var(--paper);
}
.vgad-table td.is-num {
text-align: right;
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
}
.vgad-td-time {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
}
/* 행위자 셀 */
.vgad-actor {
display: flex;
align-items: center;
gap: 10px;
}
.vgad-actor__av {
width: 28px;
height: 28px;
border-radius: 50%;
flex: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
background: var(--accent-tint);
color: var(--accent-deep);
}
.vgad-actor__nm {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
line-height: 1.3;
}
.vgad-actor__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
/* 행위 셀 */
.vgad-act {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* 대상 셀 */
.vgad-target {
color: var(--text-strong);
font-weight: 500;
}
.vgad-target__meta {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
/* 데이터 주권(geo) 표기 */
.vgad-geo {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-num);
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-body);
}
.vgad-geo__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
}
.vgad-geo__dot--kr {
background: var(--accent-bright);
}
.vgad-geo__dot--us {
background: var(--clay);
}
/* 테이블 푸터(페이지네이션) */
.vgad-tfoot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
padding: 13px var(--sp-5);
border-top: 1px solid var(--hair);
flex-wrap: wrap;
}
.vgad-tfoot__cnt {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.vgad-pg {
display: flex;
gap: 6px;
}
.vgad-pg button {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-body);
border: 1px solid var(--hair);
background: var(--bg-surface);
min-width: 30px;
height: 30px;
padding: 0 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.vgad-pg button:hover:not(:disabled) {
background: var(--bg-surface-2);
color: var(--text-strong);
}
.vgad-pg button.is-on {
background: var(--accent);
color: var(--text-on-accent);
border-color: transparent;
font-weight: 600;
}
.vgad-pg button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 빈 결과 행 */
.vgad-empty-row td {
padding: var(--sp-6) var(--sp-5);
text-align: center;
color: var(--text-muted);
font-size: var(--fs-sm);
}
/* 교수 감사 — 열람률 미니바 */
.vgad-ratebar {
display: inline-flex;
align-items: center;
gap: 10px;
justify-content: flex-end;
}
.vgad-ratebar__track {
width: 64px;
height: 5px;
background: var(--paper-2);
border-radius: 3px;
overflow: hidden;
}
.vgad-ratebar__fill {
height: 100%;
border-radius: 3px;
background: var(--accent-bright);
}
.vgad-ratebar__fill--warn {
background: var(--warn-solid);
}
.vgad-ratebar__pct {
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
min-width: 38px;
text-align: right;
}
.vgad-attn {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--warn-text);
font-size: var(--fs-xs);
font-weight: 600;
}
/* ── 관리 진입 (entry 카드, 4개 — 덤프 아님: 명확한 액션 목적지) ── */
.vgad-mgmt {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-4);
}
.vgad-entry {
display: flex;
flex-direction: column;
gap: 11px;
padding: var(--sp-5);
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
text-align: left;
cursor: pointer;
font-family: inherit;
transition: border-color var(--dur-base) var(--ease-out),
transform var(--dur-base) var(--ease-out);
}
.vgad-entry:hover {
border-color: var(--accent-bright);
transform: translateY(-1px);
text-decoration: none;
}
.vgad-entry__head {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.vgad-entry__ico {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: var(--accent-tint);
color: var(--accent-deep);
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.vgad-entry__t {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
}
.vgad-entry__d {
font-size: var(--fs-xs);
color: var(--text-muted);
line-height: 1.55;
}
.vgad-entry__go {
margin-top: auto;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--accent);
display: flex;
align-items: center;
gap: 5px;
padding-top: 4px;
}
/* ── 페이지 푸터 (윤리/버전) ── */
.vgad-foot {
margin-top: var(--sp-7);
padding-top: var(--sp-5);
border-top: 1px solid var(--hair);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
font-size: var(--fs-xs);
color: var(--text-muted);
flex-wrap: wrap;
}
/* ── 반응형 ── */
@media (max-width: 1080px) {
.vgad-kpis {
grid-template-columns: repeat(2, 1fr);
}
.vgad-kpi:nth-child(odd) {
border-left: none;
}
.vgad-kpi:nth-child(n + 3) {
border-top: 1px solid var(--hair);
}
.vgad-kpi:nth-child(even) {
border-left: 1px solid var(--hair);
}
.vgad-collect {
grid-template-columns: 1fr;
}
.vgad-usage {
grid-template-columns: 1fr;
}
.vgad-mgmt {
grid-template-columns: repeat(2, 1fr);
}
.vgad-hrow {
grid-template-columns: 180px 1fr auto;
}
.vgad-hrow__stat {
display: none;
}
}
@media (max-width: 720px) {
.vgad-hrow {
grid-template-columns: 1fr auto;
gap: var(--sp-3);
}
.vgad-hrow__gauge {
display: none;
}
.vgad-mgmt {
grid-template-columns: 1fr;
}
.vgad-table td.vgad-hide-sm,
.vgad-table th.vgad-hide-sm {
display: none;
}
}

View file

@ -1,357 +0,0 @@
/* =====================================================================
Admin mock + .
[] mock()
. api.ts .
§6.6: 운영 KPI · · /AI + · · audit log.
===================================================================== */
import type { BadgeTone, DotTone } from "../../components/ui";
/* ── 시스템 헬스 상태 ── */
export type HealthStatus = "ok" | "warn" | "crit";
export interface ServiceHealth {
/** 서비스 명 */
name: string;
/** 보조 설명(추론 대상 등) */
sub: string;
status: HealthStatus;
/** 부하/사용 게이지 0~1 */
load: number;
/** 우측 수치 라벨 (mono) */
metric: string;
/** 상태 배지 라벨 */
statusLabel: string;
}
/* ── 운영 KPI ── */
export interface Kpi {
label: string;
/** 본문 숫자 (이미 포맷된 문자열, tabular) */
value: string;
/** 숫자 뒤 단위 */
unit?: string;
/** 보조 설명(변화량 등) */
note?: string;
/** 보조 설명 톤 — 정상은 무채(undefined) */
noteTone?: "pos" | "info" | "warn" | "crit";
/** KPI 자체 강조색 — 정상은 무채(undefined). "조용한 게 정상" */
tone?: "warn" | "crit";
}
/* ── 사용량·비용 ── */
export interface UsageMeter {
label: string;
sub: string;
/** 표시 수치 (tabular) */
value: string;
/** 예산 대비 비율 0~1 (게이지) */
ratio: number;
/** 예산 라벨 (게이지 우측) */
budget: string;
}
/* ── 시나리오 분포 ── */
export interface DistRow {
label: string;
count: number;
/** 게이지 비율 0~1 */
ratio: number;
/** 게이지 톤 */
tone?: "accent" | "clay" | "warn";
}
/* ── 역할별 사용 ── */
export interface RoleRow {
label: string;
sub: string;
/** 색 점(role accent hex — 토큰 외 브랜드성 식별색) */
color: string;
active: number;
delta?: number;
}
/* ── 교수 활동 감사(요약 테이블) ── */
export interface ProfessorAudit {
name: string;
initial: string;
/** 담당 코호트 */
cohort: string;
/** 피드백 열람률 0~1 */
reviewRate: number;
/** 발행 시나리오 수 */
published: number;
/** 마지막 활동(상대 시각) */
lastActive: string;
/** 점검 필요 여부 — true 면 "점검" 신호 */
needsAttention?: boolean;
}
/* ── audit log(행위 로그) ── */
export type AuditAction = "view" | "audio" | "export" | "grade" | "edit";
export interface AuditEntry {
id: string;
time: string; // HH:MM:SS
actorName: string;
actorInitial: string;
actorRole: string;
action: AuditAction;
actionLabel: string;
target: string;
targetMeta: string;
/** 데이터 주권 — 추론이 일어난 지역(kr/us) */
geo: "kr" | "us";
/** 접근 범위 라벨 */
scope: string;
/** 민감 접근(학습자 식별/원본 등) */
sensitive?: boolean;
}
/* === action 메타: dot 톤 + 한국어 라벨 매핑 === */
export const ACTION_META: Record<AuditAction, { tone: DotTone; label: string }> = {
view: { tone: "info", label: "열람" },
audio: { tone: "accent", label: "오디오" },
export: { tone: "warn", label: "내보내기" },
grade: { tone: "pos", label: "평가 작성" },
edit: { tone: "warn", label: "평가 수정" },
};
/* === scope 배지 톤 === */
export function scopeTone(sensitive?: boolean): BadgeTone {
return sensitive ? "warn" : "neutral";
}
/* =====================================================================
mock ( /)
===================================================================== */
export const MOCK = {
syncedAt: "2026-06-25 21:14 KST",
syncAgo: "22초 전",
uptime: "47d 12h",
statusMessage: "모든 핵심 서비스가 정상 가동 중입니다.",
statusDetail: "음성 합성 지연만 평소보다 약간 높습니다.",
/** 전체 시스템 상태 — ok 면 녹색 펄스, warn/crit 이면 색 변경 */
statusLevel: "ok" as HealthStatus,
};
/** 운영 KPI 5 — 정상은 전부 무채(§6.6 "조용한 게 정상"). API오류만 warn. */
export const KPIS: Kpi[] = [
{ label: "진행 중 세션", value: "14", unit: "건", note: "+5 지난 1시간", noteTone: "pos" },
{ label: "오늘 완료 세션", value: "186", unit: "건", note: "+12% 전일 동시간", noteTone: "pos" },
{ label: "활성 사용자 (24h)", value: "312", unit: "명", note: "학습 287 · 교수 21 · 관리 4" },
{ label: "가동률 (30일)", value: "99.94", unit: "%", note: "SLA 99.9% 충족", noteTone: "pos" },
// 오류가 0 초과 → 이 KPI만 warn 강조 (다른 건 전부 무채)
{ label: "API 오류 (1h)", value: "3", unit: "건", note: "임계 20건 미만", noteTone: "warn", tone: "warn" },
];
export const SERVICES: ServiceHealth[] = [
{
name: "내담자 대화 엔진",
sub: "claude -p · 가드레일",
status: "ok",
load: 0.42,
metric: "42% 부하",
statusLabel: "정상",
},
{
name: "음성 합성 (TTS)",
sub: "스트리밍 · 8개 보이스",
status: "warn",
load: 0.78,
metric: "P95 1.4s",
statusLabel: "지연 주의",
},
{
name: "음성 인식 (STT)",
sub: "실시간 스트림",
status: "ok",
load: 0.36,
metric: "P95 0.6s",
statusLabel: "정상",
},
{
name: "세션·평가 DB",
sub: "PostgreSQL 기본",
status: "ok",
load: 0.51,
metric: "conn 51/200",
statusLabel: "정상",
},
{
name: "평가 작업 큐",
sub: "회기말 리뷰 생성",
status: "ok",
load: 0.18,
metric: "대기 3건",
statusLabel: "정상",
},
];
/** 음성·AI 사용 + 비용 (claude -p / API 텔레메트리). 80% 넘으면 warn. */
export const USAGE: UsageMeter[] = [
{ label: "내담자 추론 토큰", sub: "claude -p · 7일 누적", value: "48.2M", ratio: 0.61, budget: "예산 80M" },
{ label: "평가 토큰", sub: "회기말 리뷰 생성", value: "12.6M", ratio: 0.42, budget: "예산 30M" },
{ label: "음성 합성 분량", sub: "TTS 스트리밍", value: "5,140분", ratio: 0.83, budget: "예산 6,000분" },
{ label: "추정 운영비", sub: "이번 달 누적", value: "$1,284", ratio: 0.71, budget: "예산 $1,800" },
];
export const DISTRIB: DistRow[] = [
{ label: "우울 호소 청소년", count: 412, ratio: 0.88, tone: "clay" },
{ label: "불안·공황 20대", count: 298, ratio: 0.64 },
{ label: "관계 갈등 성인", count: 221, ratio: 0.48 },
{ label: "학업 스트레스 고3", count: 174, ratio: 0.37 },
{ label: "위기 개입 시뮬", count: 112, ratio: 0.25, tone: "warn" },
{ label: "기타", count: 67, ratio: 0.14 },
];
export const ROLES: RoleRow[] = [
{ label: "학습자", sub: "상담 수련생 · 등록 248", color: "#3E7A6E", active: 231, delta: 18 },
{ label: "교수자", sub: "슈퍼바이저 · 등록 34", color: "#3A5BA0", active: 29, delta: 2 },
{ label: "관리자", sub: "운영 · 등록 6", color: "#5B5F6B", active: 4 },
{ label: "페르소나", sub: "가상 내담자 풀", color: "#B0735C", active: 23, delta: 3 },
];
export const PROF_AUDIT: ProfessorAudit[] = [
{ name: "김상담", initial: "김", cohort: "상담실습 A반 · 12명", reviewRate: 0.94, published: 6, lastActive: "8분 전" },
{ name: "정교수", initial: "정", cohort: "상담실습 B반 · 14명", reviewRate: 0.81, published: 4, lastActive: "23분 전" },
{ name: "한지도", initial: "한", cohort: "위기개입 세미나 · 9명", reviewRate: 0.42, published: 1, lastActive: "3일 전", needsAttention: true },
{ name: "오슈퍼", initial: "오", cohort: "집단상담 · 11명", reviewRate: 0.67, published: 3, lastActive: "1시간 전" },
];
/** 7일 세션 추이(단일 라인 차트용 정규화 전 값). 피크는 컴포넌트가 산출. */
export const TREND_7D: { day: string; value: number }[] = [
{ day: "월", value: 142 },
{ day: "화", value: 168 },
{ day: "수", value: 151 },
{ day: "목", value: 203 },
{ day: "금", value: 247 },
{ day: "토", value: 118 },
{ day: "일", value: 186 },
];
export const AUDIT_LOG: AuditEntry[] = [
{
id: "S-20448-1",
time: "21:08:42",
actorName: "김상담",
actorInitial: "김",
actorRole: "슈퍼바이저",
action: "view",
actionLabel: "회기 리뷰 열람",
target: "서연 사례 · 회기 1 리뷰",
targetMeta: "학습자 이수민 · 세션 #S-20448",
geo: "kr",
scope: "학습자 식별",
sensitive: true,
},
{
id: "S-20431-1",
time: "20:51:17",
actorName: "정교수",
actorInitial: "정",
actorRole: "슈퍼바이저",
action: "export",
actionLabel: "리뷰 PDF 내보내기",
target: "불안·공황 20대 · 회기 3",
targetMeta: "학습자 박지훈 · 세션 #S-20431",
geo: "kr",
scope: "원본 포함",
sensitive: true,
},
{
id: "S-20448-2",
time: "20:39:05",
actorName: "김상담",
actorInitial: "김",
actorRole: "슈퍼바이저",
action: "grade",
actionLabel: "슈퍼바이저 코멘트 작성",
target: "감정 반영 구간 코멘트 3건",
targetMeta: "학습자 이수민 · 세션 #S-20448",
geo: "kr",
scope: "평가 작성",
},
{
id: "S-20419-1",
time: "20:22:48",
actorName: "한지도",
actorInitial: "한",
actorRole: "슈퍼바이저",
action: "audio",
actionLabel: "오디오 다시듣기",
target: "관계 갈등 성인 · 회기 2",
targetMeta: "학습자 최영아 · 세션 #S-20419",
geo: "us",
scope: "음성 원본",
sensitive: true,
},
{
id: "S-20431-2",
time: "19:57:31",
actorName: "정교수",
actorInitial: "정",
actorRole: "슈퍼바이저",
action: "edit",
actionLabel: "평가 항목 수정",
target: "'닫힌 질문' 라벨 1건 해제",
targetMeta: "학습자 박지훈 · 세션 #S-20431",
geo: "kr",
scope: "평가 수정",
},
{
id: "C-A-1",
time: "19:40:12",
actorName: "한지도",
actorInitial: "한",
actorRole: "슈퍼바이저",
action: "view",
actionLabel: "학습자 성장 곡선 열람",
target: "담당 그룹 12명 추세",
targetMeta: "2026 봄학기 상담실습 A반",
geo: "kr",
scope: "집계 조회",
},
{
id: "S-20448-3",
time: "19:12:55",
actorName: "김상담",
actorInitial: "김",
actorRole: "슈퍼바이저",
action: "view",
actionLabel: "실시간 세션 관찰",
target: "우울 호소 청소년 · 진행 중",
targetMeta: "학습자 이수민 · 세션 #S-20448",
geo: "kr",
scope: "실시간 열람",
sensitive: true,
},
{
id: "S-20402-1",
time: "18:44:09",
actorName: "오슈퍼",
actorInitial: "오",
actorRole: "슈퍼바이저",
action: "export",
actionLabel: "집계 리포트 내보내기",
target: "집단상담 주간 요약",
targetMeta: "익명 집계 · 11명",
geo: "kr",
scope: "집계 조회",
},
{
id: "S-20415-1",
time: "18:20:31",
actorName: "정교수",
actorInitial: "정",
actorRole: "슈퍼바이저",
action: "audio",
actionLabel: "오디오 다시듣기",
target: "학업 스트레스 고3 · 회기 2",
targetMeta: "학습자 윤하늘 · 세션 #S-20415",
geo: "us",
scope: "음성 원본",
sensitive: true,
},
];