안전 이벤트 대시보드 보강

This commit is contained in:
Yun Chan 2026-07-01 12:34:46 +09:00
parent fe2796f05a
commit 51053af536
15 changed files with 1081 additions and 423 deletions

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
import {
@ -20,6 +20,8 @@ import "./professor.css";
type LoadState = "loading" | "ready" | "error";
type ProfessorView = "console" | "analysis";
type LearnerSort = "latest" | "sessions" | "score" | "delta";
type LearnerAnalysisTab = "trend" | "sessions" | "stages";
function formatDateTime(value: string | null): string {
if (!value) return "-";
@ -95,7 +97,9 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
}
export default function Professor({ view = "console" }: { view?: ProfessorView }) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isAnalysisView = view === "analysis";
const selectedLearnerId = isAnalysisView ? searchParams.get("learner") : null;
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
const [loadState, setLoadState] = useState<LoadState>("loading");
@ -104,7 +108,8 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [selectedLearnerId, setSelectedLearnerId] = useState<string | null>(null);
const [learnerSearch, setLearnerSearch] = useState("");
const [learnerSort, setLearnerSort] = useState<LearnerSort>("latest");
const [learnerAnalysis, setLearnerAnalysis] =
useState<TeacherLearnerAnalysisResponse | null>(null);
const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("ready");
@ -235,17 +240,49 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
loadState === "loading" ||
(!isAnalysisView && personaReviewLoading) ||
(isAnalysisView && learnerAnalysisState === "loading");
const openLearnerDetail = useCallback(
(learnerId: string) => {
setSearchParams({ learner: learnerId });
},
[setSearchParams],
);
const closeLearnerDetail = useCallback(() => {
setSearchParams({});
}, [setSearchParams]);
const visibleLearners = useMemo(() => {
const query = learnerSearch.trim().toLocaleLowerCase("ko-KR");
const filtered = query
? learnerGrowth.filter((learner) => {
const haystack = [
learner.learner_label,
learner.learner_id,
trendLabel(learner.trend),
...(learner.top_techniques ?? []),
]
.join(" ")
.toLocaleLowerCase("ko-KR");
return haystack.includes(query);
})
: [...learnerGrowth];
return filtered.sort((a, b) => {
if (learnerSort === "sessions") return b.sessions - a.sessions;
if (learnerSort === "score") return (b.latest_score ?? -1) - (a.latest_score ?? -1);
if (learnerSort === "delta") return (b.score_delta ?? -999) - (a.score_delta ?? -999);
return Date.parse(b.latest_at ?? "") - Date.parse(a.latest_at ?? "");
});
}, [learnerGrowth, learnerSearch, learnerSort]);
useEffect(() => {
if (!isAnalysisView) return;
if (learnerGrowth.length === 0) {
if (selectedLearnerId !== null) setSelectedLearnerId(null);
return;
if (
selectedLearnerId &&
learnerGrowth.length > 0 &&
!learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)
) {
setSearchParams({});
}
if (!selectedLearnerId || !learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)) {
setSelectedLearnerId(learnerGrowth[0].learner_id);
}
}, [isAnalysisView, learnerGrowth, selectedLearnerId]);
}, [isAnalysisView, learnerGrowth, selectedLearnerId, setSearchParams]);
useEffect(() => {
if (!isAnalysisView) {
@ -293,10 +330,10 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
{isAnalysisView ? (
<>
<h1> .</h1>
<h1> , .</h1>
<p>
, ,
.
, , ,
· · .
</p>
</>
) : (
@ -408,103 +445,82 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
) : null}
{isAnalysisView ? (
<section className="pf-analysis-shell" aria-label="학생 분석 작업면">
<aside className="pf-analysis-rail">
<section className="pf-section pf-section--growth">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
{learnerGrowth.length}
</Badge>
</div>
<Card className="pf-panel pf-growth-panel">
{learnerGrowth.length > 0 ? (
<div className="pf-growth-list">
{learnerGrowth.map((learner) => (
<GrowthCard
learner={learner}
key={learner.learner_id}
selected={learner.learner_id === selectedLearnerId}
onSelect={() => setSelectedLearnerId(learner.learner_id)}
/>
))}
</div>
) : (
<EmptyState
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
/>
)}
</Card>
</section>
</aside>
<section className="pf-analysis-workbench">
<nav className="pf-depth-strip" aria-label="학생 분석 단계">
<span className="is-current">
<small>1</small>
</span>
<span>
<small>2</small>
</span>
<span>
<small>3</small>
</span>
<span>
<small>4</small>
</span>
</nav>
<section className="pf-section pf-section--learner-analysis">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>{selectedLearner?.learner_label ?? "학습자 이력"}</h2>
</div>
<Badge tone={learnerAnalysis ? "accent" : "neutral"}>
{learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"}
</Badge>
</div>
<Card className="pf-panel pf-analysis-panel">
{learnerGrowth.length === 0 ? (
<EmptyState
title={loadState === "loading" ? "이력 계산 중" : "분석할 학습자 없음"}
desc="실제 학습자 회기가 쌓이면 사용자별 전체 이력을 표시합니다."
/>
) : learnerAnalysisState === "loading" ? (
<EmptyState
title="학습자 이력 계산 중"
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
/>
) : learnerAnalysisError ? (
<div className="pf-empty" role="alert">
<Icon name="alert" size={18} />
<b> </b>
<span>{learnerAnalysisError}</span>
</div>
) : learnerAnalysis ? (
<LearnerAnalysisPanel
analysis={learnerAnalysis}
onOpenSession={(sessionId) => navigate(`/teach/session/${sessionId}/review`)}
<section className="pf-analysis-page" aria-label="학생 분석 작업면">
{!selectedLearnerId ? (
<LearnerOverviewTable
learners={visibleLearners}
totalLearners={learnerGrowth.length}
query={learnerSearch}
sort={learnerSort}
loadState={loadState}
onSearch={setLearnerSearch}
onSort={setLearnerSort}
onOpen={openLearnerDetail}
/>
) : (
<EmptyState
title="선택된 학습자 없음"
desc="실제 회기 기반 사용자별 분석이 이 영역에 표시됩니다."
/>
<section className="pf-section pf-section--learner-analysis">
<div className="pf-analysis-detail-head">
<Button
size="sm"
variant="secondary"
leading={<Icon name="chevron-left" size={14} />}
onClick={closeLearnerDetail}
>
</Button>
<nav className="pf-depth-strip" aria-label="학생 분석 깊이">
<span>
<small>1</small>
</span>
<span className="is-current">
<small>2</small>
</span>
<span>
<small>3</small>
</span>
</nav>
</div>
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>{selectedLearner?.learner_label ?? "학습자 이력"}</h2>
</div>
<Badge tone={learnerAnalysis ? "accent" : "neutral"}>
{learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"}
</Badge>
</div>
<Card className="pf-panel pf-analysis-panel">
{learnerAnalysisState === "loading" ? (
<EmptyState
title="학습자 이력 계산 중"
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
/>
) : learnerAnalysisError ? (
<div className="pf-empty" role="alert">
<Icon name="alert" size={18} />
<b> </b>
<span>{learnerAnalysisError}</span>
</div>
) : learnerAnalysis ? (
<LearnerAnalysisPanel
analysis={learnerAnalysis}
onOpenSession={(sessionId) => navigate(`/teach/session/${sessionId}/review`)}
/>
) : (
<EmptyState
title="선택된 학습자 없음"
desc="전체 목록에서 학습자를 선택하면 상세 분석이 열립니다."
/>
)}
</Card>
</section>
)}
</Card>
</section>
</section>
</section>
) : null}
{!isAnalysisView ? (
@ -798,6 +814,150 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
);
}
function LearnerOverviewTable({
learners,
totalLearners,
query,
sort,
loadState,
onSearch,
onSort,
onOpen,
}: {
learners: TeacherLearnerGrowth[];
totalLearners: number;
query: string;
sort: LearnerSort;
loadState: LoadState;
onSearch: (value: string) => void;
onSort: (value: LearnerSort) => void;
onOpen: (learnerId: string) => void;
}) {
const sortOptions: Array<{ key: LearnerSort; label: string }> = [
{ key: "latest", label: "최근순" },
{ key: "sessions", label: "회기순" },
{ key: "score", label: "점수순" },
{ key: "delta", label: "변화순" },
];
return (
<section className="pf-section pf-section--learner-overview">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={totalLearners > 0 ? "accent" : "neutral"}>{totalLearners}</Badge>
</div>
<Card className="pf-panel pf-learner-overview">
<div className="pf-learner-toolbar">
<label className="pf-search-field">
<Icon name="users" size={15} />
<input
aria-label="학습자 검색"
placeholder="학습자, ID, 기법 검색"
value={query}
onChange={(event) => onSearch(event.currentTarget.value)}
/>
</label>
<div className="pf-sort-segment" aria-label="학습자 정렬">
{sortOptions.map((option) => (
<button
type="button"
className={sort === option.key ? "is-active" : ""}
key={option.key}
onClick={() => onSort(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
{totalLearners === 0 ? (
<EmptyState
title={loadState === "loading" ? "학습자 추이 계산 중" : "표시할 학습자 이력 없음"}
desc="실제 학습자 회기와 턴별 평가가 쌓이면 이 표에 전체 학습자 추이가 표시됩니다."
/>
) : learners.length === 0 ? (
<EmptyState
title="검색 결과 없음"
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
/>
) : (
<div className="pf-learner-table" data-learner-overview-table="true">
<div className="pf-learner-table__head" aria-hidden="true">
<span></span>
<span></span>
<span></span>
<span> </span>
<span></span>
<span> </span>
<span></span>
<span></span>
</div>
{learners.map((learner) => (
<article
className="pf-learner-row"
data-learner-overview-row="true"
key={learner.learner_id}
>
<div className="pf-learner-row__id">
<b>{learner.learner_label}</b>
<span>{learner.learner_id}</span>
</div>
<div className="pf-learner-row__metric" data-label="회기">
<b>
{learner.ended_sessions}/{learner.sessions}
</b>
<span>{formatDateTime(learner.latest_at)}</span>
</div>
<div className="pf-learner-row__spark" data-label="추이">
<div className="pf-learner-spark" aria-label={`${learner.learner_label} 회기별 추이`}>
{(learner.points ?? []).map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
{trendLabel(learner.trend)}
</Badge>
</div>
<div className="pf-learner-row__metric" data-label="최근 적절성">
<b>{formatScore(learner.latest_score)}</b>
</div>
<div className="pf-learner-row__metric" data-label="변화">
<b>{formatDelta(learner.score_delta)}</b>
</div>
<div className="pf-learner-row__metric" data-label="평균 라포">
<b>{formatRapport(learner.avg_rapport)}</b>
</div>
<div className="pf-learner-row__tags" data-label="기법">
{(learner.top_techniques ?? []).slice(0, 3).map((tag) => (
<span key={tag}>{tag}</span>
))}
{(learner.top_techniques ?? []).length === 0 ? <span> </span> : null}
</div>
<div className="pf-learner-row__action">
<Button
size="sm"
variant="secondary"
leading={<Icon name="chevron-right" size={14} />}
aria-label={`${learner.learner_label} 상세 분석 열기`}
onClick={() => onOpen(learner.learner_id)}
>
</Button>
</div>
</article>
))}
</div>
)}
</Card>
</section>
);
}
function LearnerAnalysisPanel({
analysis,
onOpenSession,
@ -805,11 +965,17 @@ function LearnerAnalysisPanel({
analysis: TeacherLearnerAnalysisResponse;
onOpenSession: (sessionId: string) => void;
}) {
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("trend");
const points = analysis.points ?? [];
const sessions = analysis.sessions ?? [];
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
const stageBreakdown = analysis.stage_breakdown ?? [];
const summary = analysis.summary;
const tabs: Array<{ key: LearnerAnalysisTab; label: string; count: string }> = [
{ key: "trend", label: "추이", count: `${points.length}` },
{ key: "sessions", label: "전체 회기", count: `${sessions.length}` },
{ key: "stages", label: "단계 분석", count: `${stageBreakdown.length}단계` },
];
return (
<div className="pf-analysis" data-learner-analysis-panel="true">
@ -848,7 +1014,23 @@ function LearnerAnalysisPanel({
</div>
</div>
<div className="pf-analysis__body">
<div className="pf-analysis-tabs" role="tablist" aria-label="학습자 상세 분석 탭">
{tabs.map((tab) => (
<button
type="button"
role="tab"
aria-selected={activeTab === tab.key}
className={activeTab === tab.key ? "is-active" : ""}
key={tab.key}
onClick={() => setActiveTab(tab.key)}
>
<b>{tab.label}</b>
<span>{tab.count}</span>
</button>
))}
</div>
{activeTab === "trend" ? (
<section className="pf-analysis__chart" aria-label="회기별 추이">
<div className="pf-analysis__subhead">
<b> </b>
@ -864,7 +1046,9 @@ function LearnerAnalysisPanel({
<EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." />
)}
</section>
) : null}
{activeTab === "stages" ? (
<section className="pf-analysis__stages" aria-label="단계 분포">
<div className="pf-analysis__subhead">
<b> </b>
@ -880,28 +1064,30 @@ function LearnerAnalysisPanel({
))}
</div>
</section>
</div>
) : null}
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
<div className="pf-analysis__subhead">
<b> </b>
<span>{sessions.length}</span>
</div>
{sessions.length > 0 ? (
<div className="pf-timeline">
{sessions.map((session) => (
<LearnerSessionRow
session={session}
point={pointBySession.get(session.session_id)}
key={session.session_id}
onOpen={() => onOpenSession(session.session_id)}
/>
))}
{activeTab === "sessions" ? (
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
<div className="pf-analysis__subhead">
<b> </b>
<span>{sessions.length}</span>
</div>
) : (
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
)}
</section>
{sessions.length > 0 ? (
<div className="pf-timeline">
{sessions.map((session) => (
<LearnerSessionRow
session={session}
point={pointBySession.get(session.session_id)}
key={session.session_id}
onOpen={() => onOpenSession(session.session_id)}
/>
))}
</div>
) : (
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
)}
</section>
) : null}
</div>
);
}
@ -962,91 +1148,6 @@ function LearnerSessionRow({
);
}
function GrowthCard({
learner,
selected,
onSelect,
}: {
learner: TeacherLearnerGrowth;
selected: boolean;
onSelect: () => void;
}) {
const points = learner.points ?? [];
const topTechniques = learner.top_techniques ?? [];
const recentPoints = points.slice(-3).reverse();
return (
<button
type="button"
className={`pf-growth-card trend-${learner.trend} ${selected ? "is-selected" : ""}`}
data-learner-growth-card="true"
aria-pressed={selected}
aria-label={`${learner.learner_label} 사용자별 분석`}
onClick={onSelect}
>
<div className="pf-growth-card__top">
<div className="pf-growth-card__id">
<b>{learner.learner_label}</b>
<span>
{learner.ended_sessions}/{learner.sessions} · {formatDateTime(learner.latest_at)}
</span>
</div>
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
{trendLabel(learner.trend)}
</Badge>
</div>
<div className="pf-growth-card__metrics" aria-label="학습자 성장 요약">
<span>
<small> </small>
<b>{formatScore(learner.latest_score)}</b>
</span>
<span>
<small></small>
<b>{formatDelta(learner.score_delta)}</b>
</span>
<span>
<small> </small>
<b>{formatScore(learner.avg_rapport == null ? null : (learner.avg_rapport + 1) / 2)}</b>
</span>
</div>
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
{points.map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<div className="pf-growth-card__tags">
{topTechniques.length > 0 ? (
topTechniques.map((tag) => <span key={tag}>{tag}</span>)
) : (
<span> </span>
)}
</div>
<div className="pf-growth-card__points">
{recentPoints.length > 0 ? (
recentPoints.map((point) => (
<div className="pf-growth-point" key={point.session_id}>
<b>
{point.persona_code} · {point.session_no}
</b>
<span>
{formatScore(point.score)} · {point.technique_count} · {point.watch_count}
</span>
</div>
))
) : (
<div className="pf-growth-point">
<b> </b>
<span> .</span>
</div>
)}
</div>
</button>
);
}
function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;

View file

@ -181,17 +181,11 @@
gap:14px;
min-width:0;
}
.pf-analysis-shell{
.pf-analysis-page{
order:2;
display:grid;
grid-template-columns:minmax(300px,360px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
}
.pf-analysis-rail{
position:sticky;
top:16px;
display:flex;
flex-direction:column;
gap:12px;
min-width:0;
}
.pf-analysis-workbench{
@ -200,9 +194,16 @@
flex-direction:column;
gap:10px;
}
.pf-analysis-detail-head{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:10px;
align-items:center;
min-width:0;
}
.pf-depth-strip{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
grid-template-columns:repeat(3,minmax(0,1fr));
min-width:0;
border:1px solid var(--hair);
border-radius:var(--radius);
@ -325,86 +326,217 @@
.pf-growth-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-growth-list{
max-height:min(420px,44vh);
overflow:auto;
scrollbar-gutter:stable;
.pf-learner-overview{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
}
.pf-learner-toolbar{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;
grid-template-columns:minmax(240px,.72fr) auto;
gap:10px;
align-items:center;
padding:12px;
border-bottom:1px solid var(--hair);
background:color-mix(in srgb,var(--bg-surface) 86%,var(--bg-surface-2));
}
.pf-analysis-rail .pf-growth-list{
max-height:min(690px,calc(100vh - 250px));
grid-template-columns:1fr;
gap:9px;
padding:10px;
.pf-search-field{
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:8px;
align-items:center;
padding:9px 10px;
border:1px solid var(--paper-2);
border-radius:var(--radius);
color:var(--text-muted);
background:var(--bg-surface-2);
}
.pf-growth-card{
width:100%;
.pf-search-field input{
min-width:0;
border:0;
outline:0;
color:var(--text-strong);
background:transparent;
font:inherit;
text-align:left;
color:inherit;
font-size:var(--fs-sm);
}
.pf-search-field input::placeholder{
color:var(--text-muted);
}
.pf-sort-segment{
display:flex;
align-items:center;
justify-content:flex-end;
gap:0;
border:1px solid var(--paper-2);
border-radius:var(--radius);
overflow:hidden;
background:var(--bg-surface-2);
}
.pf-sort-segment button{
min-height:38px;
padding:0 11px;
border:0;
border-left:1px solid var(--hair);
color:var(--text-muted);
background:transparent;
font:inherit;
font-size:var(--fs-xs);
font-weight:760;
cursor:pointer;
white-space:nowrap;
}
.pf-sort-segment button:first-child{
border-left:0;
}
.pf-sort-segment button:hover,
.pf-sort-segment button:focus-visible{
color:var(--text-strong);
background:var(--bg-surface);
}
.pf-sort-segment button:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-sort-segment button.is-active{
color:var(--text-on-accent);
background:var(--accent);
}
.pf-learner-table{
min-width:0;
display:flex;
flex-direction:column;
gap:12px;
padding:13px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2));
box-shadow:none;
cursor:pointer;
}
.pf-growth-card:hover{
border-color:var(--accent);
background:var(--bg-surface);
}
.pf-growth-card:focus-visible{
outline:2px solid var(--accent);
outline-offset:2px;
}
.pf-growth-card.is-selected{
border-color:var(--accent);
background:color-mix(in srgb,var(--accent-tint) 34%,var(--bg-surface));
}
.pf-analysis-rail .pf-growth-card{
.pf-learner-table__head,
.pf-learner-row{
display:grid;
grid-template-columns:
minmax(170px,1.25fr)
minmax(86px,.55fr)
minmax(164px,.9fr)
minmax(76px,.48fr)
minmax(64px,.42fr)
minmax(76px,.48fr)
minmax(118px,.75fr)
minmax(96px,.42fr);
gap:9px;
padding:11px;
border-radius:var(--radius);
}
.pf-analysis-rail .pf-growth-card__points{
display:none;
}
.pf-analysis-rail .pf-growth-card__tags{
min-height:0;
}
.pf-analysis-rail .pf-growth-bars{
height:54px;
}
.pf-growth-card__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
align-items:center;
min-width:0;
}
.pf-growth-card__id{
.pf-learner-table__head{
position:sticky;
top:0;
z-index:1;
padding:9px 12px;
border-bottom:1px solid var(--hair);
color:var(--text-muted);
background:var(--bg-surface);
font-size:var(--fs-xs);
font-weight:800;
white-space:nowrap;
}
.pf-learner-row{
padding:11px 12px;
border-top:1px solid var(--paper-2);
}
.pf-learner-table__head + .pf-learner-row{
border-top:0;
}
.pf-learner-row:hover{
background:color-mix(in srgb,var(--accent-tint) 14%,transparent);
}
.pf-learner-row__id,
.pf-learner-row__metric,
.pf-learner-row__spark,
.pf-learner-row__tags{
min-width:0;
}
.pf-learner-row__id{
display:grid;
gap:3px;
}
.pf-growth-card__id b{
.pf-learner-row__id b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
}
.pf-learner-row__id span,
.pf-learner-row__metric span{
color:var(--text-muted);
font-size:11px;
line-height:1.3;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__id span,
.pf-growth-card__metrics small,
.pf-growth-point span,
.pf-learner-row__metric{
display:grid;
gap:3px;
}
.pf-learner-row__metric::before,
.pf-learner-row__spark::before,
.pf-learner-row__tags::before{
display:none;
content:attr(data-label);
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:800;
}
.pf-learner-row__metric b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:15px;
line-height:1.15;
white-space:nowrap;
}
.pf-learner-row__spark{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:8px;
align-items:center;
}
.pf-learner-spark{
min-width:0;
height:46px;
display:flex;
align-items:flex-end;
gap:4px;
padding:6px 6px 4px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.pf-learner-spark .pf-growth-bar{
min-width:10px;
}
.pf-learner-spark .pf-growth-bar small{
display:none;
}
.pf-learner-row__tags{
display:flex;
flex-wrap:wrap;
gap:5px;
}
.pf-learner-row__tags span{
max-width:100%;
padding:4px 7px;
border:1px solid var(--paper-2);
border-radius:999px;
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-learner-row__action{
display:flex;
justify-content:flex-end;
}
.pf-learner-row__action .vg-btn{
min-width:92px;
padding-inline:10px;
}
.pf-recent__review-state{
color:var(--text-muted);
font-size:12px;
@ -415,37 +547,6 @@
margin-top:3px;
line-height:1.2;
}
.pf-growth-card__metrics{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
}
.pf-growth-card__metrics span{
min-width:0;
display:grid;
gap:3px;
padding:9px 10px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.pf-growth-card__metrics b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:15px;
line-height:1.15;
white-space:nowrap;
}
.pf-growth-bars{
height:82px;
display:flex;
align-items:flex-end;
gap:6px;
padding:8px 8px 6px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
}
.pf-growth-bar{
flex:1 1 0;
min-width:14px;
@ -472,50 +573,6 @@
text-align:center;
line-height:1;
}
.pf-growth-card__tags{
min-height:26px;
display:flex;
flex-wrap:wrap;
gap:6px;
align-content:flex-start;
}
.pf-growth-card__tags span{
max-width:100%;
padding:4px 7px;
border:1px solid var(--paper-2);
border-radius:999px;
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__points{
display:grid;
gap:7px;
}
.pf-growth-point{
min-width:0;
display:grid;
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-growth-point b{
color:var(--text-strong);
font-size:12px;
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-point span{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-analysis-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
}
@ -525,6 +582,54 @@
gap:12px;
padding:12px;
}
.pf-analysis-tabs{
min-width:0;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
overflow:hidden;
background:var(--bg-surface-2);
}
.pf-analysis-tabs button{
min-width:0;
min-height:48px;
display:grid;
gap:3px;
align-content:center;
padding:8px 12px;
border:0;
border-left:1px solid var(--hair);
color:var(--text-muted);
background:transparent;
font:inherit;
text-align:left;
cursor:pointer;
}
.pf-analysis-tabs button:first-child{
border-left:0;
}
.pf-analysis-tabs button:hover,
.pf-analysis-tabs button:focus-visible{
background:var(--bg-surface);
}
.pf-analysis-tabs button:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-analysis-tabs button.is-active{
color:var(--text-strong);
background:color-mix(in srgb,var(--accent-tint) 32%,var(--bg-surface));
}
.pf-analysis-tabs b{
font-size:var(--fs-sm);
line-height:1.2;
}
.pf-analysis-tabs span{
color:var(--text-muted);
font-size:11px;
line-height:1.2;
}
.pf-analysis__top{
min-width:0;
display:grid;
@ -594,12 +699,6 @@
overflow:hidden;
text-overflow:ellipsis;
}
.pf-analysis__body{
min-width:0;
display:grid;
grid-template-columns:minmax(0,1.25fr) minmax(240px,.75fr);
gap:12px;
}
.pf-analysis__chart,
.pf-analysis__stages,
.pf-analysis__timeline{
@ -1125,13 +1224,9 @@
}
@media (max-width:1100px){
.pf-signal-strip,
.pf-workspace,
.pf-analysis-shell{
.pf-workspace{
grid-template-columns:1fr;
}
.pf-analysis-rail{
position:static;
}
.pf-triage{
grid-template-columns:auto minmax(0,max-content) auto;
justify-content:start;
@ -1141,12 +1236,57 @@
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-growth-list{
grid-template-columns:repeat(2,minmax(0,1fr));
.pf-learner-toolbar{
grid-template-columns:1fr;
}
.pf-analysis-rail .pf-growth-list{
max-height:360px;
grid-template-columns:repeat(2,minmax(0,1fr));
.pf-sort-segment{
justify-content:flex-start;
overflow:auto;
scrollbar-gutter:stable;
}
.pf-learner-table{
background:var(--bg-surface-2);
}
.pf-learner-table__head{
display:none;
}
.pf-learner-row{
margin:10px;
padding:12px;
border:1px solid var(--paper-2);
border-radius:var(--radius);
background:var(--bg-surface);
grid-template-columns:repeat(3,minmax(0,1fr));
gap:10px 12px;
}
.pf-learner-row + .pf-learner-row{
margin-top:0;
}
.pf-learner-row__id,
.pf-learner-row__spark,
.pf-learner-row__tags,
.pf-learner-row__action{
grid-column:1 / -1;
}
.pf-learner-row__metric::before,
.pf-learner-row__spark::before,
.pf-learner-row__tags::before{
display:block;
}
.pf-learner-row__spark,
.pf-learner-row__tags{
display:grid;
grid-template-columns:minmax(76px,.28fr) minmax(0,1fr);
align-items:center;
}
.pf-learner-row__tags{
display:flex;
}
.pf-learner-row__tags::before{
flex:0 0 76px;
}
.pf-learner-row__action{
justify-content:flex-start;
}
.pf-analysis__top{
grid-template-columns:1fr;
@ -1160,9 +1300,6 @@
.pf-analysis__metrics span:nth-child(n+4){
border-top:1px solid var(--hair);
}
.pf-analysis__body{
grid-template-columns:1fr;
}
.pf-timeline-row{
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
}
@ -1184,6 +1321,9 @@
width:100%;
justify-content:flex-start;
}
.pf-analysis-detail-head{
grid-template-columns:1fr;
}
.pf-depth-strip{
grid-template-columns:repeat(2,minmax(0,1fr));
}
@ -1204,15 +1344,19 @@
.pf-kpi:nth-child(n+2){border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-growth-list{
grid-template-columns:1fr;
}
.pf-analysis-rail .pf-growth-list{
grid-template-columns:1fr;
}
.pf-analysis{
padding:10px;
}
.pf-analysis-tabs{
grid-template-columns:1fr;
}
.pf-analysis-tabs button,
.pf-analysis-tabs button:first-child{
border-left:0;
}
.pf-analysis-tabs button:nth-child(n+2){
border-top:1px solid var(--hair);
}
.pf-analysis__metrics{
grid-template-columns:repeat(2,minmax(0,1fr));
}
@ -1345,11 +1489,38 @@
.pf-depth-strip span:nth-child(n+2){
border-top:1px solid var(--hair);
}
.pf-growth-list{
.pf-learner-toolbar{
padding:10px;
}
.pf-growth-card__metrics{
.pf-sort-segment{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
overflow:hidden;
}
.pf-sort-segment button,
.pf-sort-segment button:first-child{
border-left:0;
}
.pf-sort-segment button:nth-child(even){
border-left:1px solid var(--hair);
}
.pf-sort-segment button:nth-child(n+3){
border-top:1px solid var(--hair);
}
.pf-learner-row{
grid-template-columns:repeat(2,minmax(0,1fr));
margin:8px;
}
.pf-learner-row__spark,
.pf-learner-row__tags{
grid-template-columns:1fr;
gap:6px;
}
.pf-learner-row__tags::before{
flex-basis:100%;
}
.pf-learner-row__action .vg-btn{
width:100%;
}
.pf-analysis__metrics{
grid-template-columns:1fr;
@ -1379,14 +1550,6 @@
.pf-timeline-row__dates{
grid-template-columns:1fr;
}
.pf-growth-point{
grid-template-columns:1fr;
gap:2px;
}
.pf-growth-point b,
.pf-growth-point span{
white-space:normal;
}
.pf-alert{
grid-template-columns:auto minmax(0,1fr);
}