Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue