1638 lines
46 KiB
TypeScript
1638 lines
46 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { AppShell } from "../components/shell/AppShell";
|
|
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
|
|
import {
|
|
ApiError,
|
|
personaReviewApi,
|
|
teacherApi,
|
|
type PersonaReviewAction,
|
|
type PersonaReviewStatus,
|
|
type PersonaReviewSummary,
|
|
type TeacherLearnerGrowth,
|
|
type TeacherGrowthPoint,
|
|
type TeacherSafetyAlert,
|
|
type TeacherDashboardResponse,
|
|
} from "../lib/api";
|
|
|
|
type LoadState = "loading" | "ready" | "error";
|
|
|
|
function formatDateTime(value: string | null): string {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString("ko-KR", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function formatScore(value: number | null | undefined): string {
|
|
if (typeof value !== "number" || Number.isNaN(value)) return "평가 부족";
|
|
return `${Math.round(value * 100)}%`;
|
|
}
|
|
|
|
function formatDelta(value: number | null | undefined): string {
|
|
if (typeof value !== "number" || Number.isNaN(value)) return "변화 부족";
|
|
const sign = value > 0 ? "+" : "";
|
|
return `${sign}${Math.round(value * 100)}%p`;
|
|
}
|
|
|
|
function trendLabel(value: string): string {
|
|
if (value === "up") return "상승";
|
|
if (value === "down") return "하락";
|
|
if (value === "flat") return "유지";
|
|
return "평가 부족";
|
|
}
|
|
|
|
function sessionOpenLabel(status: string): string {
|
|
return status === "ended" ? "상세 리뷰" : "진행 기록";
|
|
}
|
|
|
|
function sessionReviewStatusLabel(status: string | null | undefined): string {
|
|
if (status === "closed") return "검토 완료";
|
|
if (status === "viewed") return "메모 저장";
|
|
return "검토 대기";
|
|
}
|
|
|
|
function sessionReviewTone(status: string | null | undefined): "accent" | "neutral" | "warn" {
|
|
if (status === "closed") return "accent";
|
|
if (status === "viewed") return "warn";
|
|
return "neutral";
|
|
}
|
|
|
|
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
|
|
if (status === "review") return "검수 대기";
|
|
if (status === "draft") return "수정 대기";
|
|
if (status === "approved") return "승인됨";
|
|
return "보관됨";
|
|
}
|
|
|
|
function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" | "warn" {
|
|
if (status === "review") return "accent";
|
|
if (status === "draft") return "warn";
|
|
return "neutral";
|
|
}
|
|
|
|
function EmptyState({ title, desc }: { title: string; desc: string }) {
|
|
return (
|
|
<div className="pf-empty">
|
|
<b>{title}</b>
|
|
<span>{desc}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Professor() {
|
|
const navigate = useNavigate();
|
|
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
|
|
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
|
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
|
|
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
|
|
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
|
|
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
|
|
|
const loadDashboard = useCallback(async () => {
|
|
setLoadState("loading");
|
|
setError(null);
|
|
try {
|
|
const next = await teacherApi.dashboard();
|
|
setDashboard(next);
|
|
setUpdatedAt(new Date());
|
|
setLoadState("ready");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "교수자 대시보드를 불러오지 못했습니다.");
|
|
setLoadState("error");
|
|
}
|
|
}, []);
|
|
|
|
const loadPersonaReviews = useCallback(async () => {
|
|
setPersonaReviewLoading(true);
|
|
setPersonaReviewError(null);
|
|
try {
|
|
const next = await personaReviewApi.list();
|
|
setPersonaReviews(next);
|
|
} catch (err) {
|
|
// 404(검수 엔드포인트 부재/검수 데이터 없음)는 장애가 아니므로
|
|
// 빨간 에러 배너 대신 정상 빈 상태로만 표시한다.
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
setPersonaReviews([]);
|
|
} else {
|
|
// 진짜 장애(5xx/네트워크)일 때만 정돈된 에러 카드를 노출한다.
|
|
setPersonaReviewError("검수 데이터를 불러오지 못했습니다.");
|
|
}
|
|
} finally {
|
|
setPersonaReviewLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const decidePersonaReview = useCallback(
|
|
async (personaId: string, action: PersonaReviewAction) => {
|
|
setPersonaDecisionBusy(`${personaId}:${action}`);
|
|
setPersonaReviewError(null);
|
|
try {
|
|
const next = await personaReviewApi.decide(personaId, action);
|
|
setPersonaReviews((current) => {
|
|
if (next.status === "draft" || next.status === "review") {
|
|
return current.map((item) => (item.persona_id === next.persona_id ? next : item));
|
|
}
|
|
return current.filter((item) => item.persona_id !== next.persona_id);
|
|
});
|
|
} catch (err) {
|
|
console.warn("[professor] failed to decide persona review", err);
|
|
setPersonaReviewError("페르소나 검수 결정을 저장하지 못했습니다.");
|
|
} finally {
|
|
setPersonaDecisionBusy(null);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void loadDashboard();
|
|
void loadPersonaReviews();
|
|
}, [loadDashboard, loadPersonaReviews]);
|
|
|
|
const kpis = useMemo(
|
|
() => [
|
|
{
|
|
label: "검토 대기",
|
|
value: dashboard?.pending_reviews?.length ?? 0,
|
|
hint: "종료 회기",
|
|
icon: "review" as const,
|
|
emphasis: "primary" as const,
|
|
},
|
|
{
|
|
label: "위기 알림",
|
|
value: dashboard?.safety_alerts?.length ?? 0,
|
|
hint: "109 확인",
|
|
icon: "alert" as const,
|
|
emphasis: "warn" as const,
|
|
},
|
|
{
|
|
label: "페르소나 검수",
|
|
value: personaReviews.length,
|
|
hint: "승인 대기",
|
|
icon: "check" as const,
|
|
},
|
|
{
|
|
label: "최근 세션",
|
|
value: dashboard?.recent_sessions?.length ?? 0,
|
|
hint: "종료 · 진행",
|
|
icon: "play" as const,
|
|
},
|
|
{
|
|
label: "전체 학생",
|
|
value: dashboard?.total_learners ?? 0,
|
|
hint: "담당 코호트",
|
|
icon: "users" as const,
|
|
},
|
|
{
|
|
label: "활성 세션",
|
|
value: dashboard?.active_sessions ?? 0,
|
|
hint: "진행 중",
|
|
icon: "play" as const,
|
|
},
|
|
],
|
|
[dashboard, personaReviews.length],
|
|
);
|
|
const pendingCount = dashboard?.pending_reviews?.length ?? 0;
|
|
const hasPending = pendingCount > 0;
|
|
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
|
|
const personaReviewCount = personaReviews.length;
|
|
const safetyAlerts = dashboard?.safety_alerts ?? [];
|
|
const learnerGrowth = dashboard?.learner_growth ?? [];
|
|
const pendingReviews = dashboard?.pending_reviews ?? [];
|
|
const recentSessions = dashboard?.recent_sessions ?? [];
|
|
const firstPendingReview = pendingReviews[0];
|
|
|
|
return (
|
|
<AppShell navRole="teacher" wide>
|
|
<style>{PF_CSS}</style>
|
|
<main className="pf-root">
|
|
<header className="pf-head">
|
|
<div>
|
|
<Kicker>교수 콘솔</Kicker>
|
|
<h1>
|
|
{hasPending ? `${pendingCount}건의 리뷰가 대기 중입니다.` : "검토할 실제 회기가 없습니다."}
|
|
</h1>
|
|
<p>
|
|
현재 기록된 학습 세션을 기준으로 표시합니다. 기록이 없는 항목은 비워 두며,
|
|
임의 코호트나 추정 위험도는 보여주지 않습니다.
|
|
</p>
|
|
</div>
|
|
<div className="pf-head__actions">
|
|
{firstPendingReview ? (
|
|
<Button
|
|
leading={<Icon name="review" size={16} />}
|
|
onClick={() => navigate(`/teach/session/${firstPendingReview.session_id}/review`)}
|
|
>
|
|
첫 검토 열기
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant="secondary"
|
|
leading={<Icon name="review" size={16} />}
|
|
onClick={() => {
|
|
void loadDashboard();
|
|
void loadPersonaReviews();
|
|
}}
|
|
disabled={loadState === "loading" || personaReviewLoading}
|
|
>
|
|
{loadState === "loading" || personaReviewLoading ? "확인 중" : "새로고침"}
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="pf-signal-strip" aria-label="교수자 검토 요약">
|
|
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
|
|
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
|
|
<div className="pf-triage__copy">
|
|
<Kicker>검토 큐</Kicker>
|
|
<b>{dashboard?.message ?? "학습 세션을 확인하는 중입니다."}</b>
|
|
<span>
|
|
{dashboard ? `${dashboard.cohort_label} · 실제 기록` : "확인 중"}
|
|
{updatedAt ? ` · ${updatedAt.toLocaleTimeString("ko-KR")} 갱신` : ""}
|
|
</span>
|
|
</div>
|
|
<div className="pf-triage__meta">
|
|
<span>
|
|
<b>{pendingCount}</b>
|
|
<small>리뷰 대기</small>
|
|
</span>
|
|
<span>
|
|
<b>{totalSessions}</b>
|
|
<small>누적 회기</small>
|
|
</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="pf-kpis" aria-label="교수자 요약">
|
|
{kpis.map((kpi) => {
|
|
const emphasis = "emphasis" in kpi ? kpi.emphasis : null;
|
|
return (
|
|
<div
|
|
className={`pf-kpi ${emphasis ? `pf-kpi--${emphasis}` : ""}`}
|
|
key={kpi.label}
|
|
>
|
|
<span className="pf-kpi__ic" aria-hidden="true">
|
|
<Icon name={kpi.icon} size={15} strokeWidth={1.9} />
|
|
</span>
|
|
<span className="pf-kpi__lab">{kpi.label}</span>
|
|
<b>{kpi.value}</b>
|
|
<small>{kpi.hint}</small>
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
</section>
|
|
|
|
{error ? (
|
|
<section className="pf-error" role="alert">
|
|
<Icon name="alert" size={18} />
|
|
{error}
|
|
</section>
|
|
) : null}
|
|
|
|
<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} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
|
|
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
|
|
/>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
|
|
<section className="pf-workspace">
|
|
<div className="pf-queue-stack">
|
|
<section className="pf-section pf-section--pending">
|
|
<div className="pf-section__head">
|
|
<div>
|
|
<Kicker>검토 대기</Kicker>
|
|
<h2>아직 닫지 않은 종료 회기</h2>
|
|
</div>
|
|
<Badge tone="accent">{pendingReviews.length}건</Badge>
|
|
</div>
|
|
|
|
<Card className="pf-panel">
|
|
{pendingReviews.length > 0 ? (
|
|
<div className="pf-list">
|
|
{pendingReviews.map((session) => (
|
|
<button
|
|
type="button"
|
|
className="pf-session pf-session--action"
|
|
key={session.session_id}
|
|
data-pending-review-row="true"
|
|
aria-label={`${session.learner_label} ${session.persona_code} 회기 상세 검토`}
|
|
onClick={() => navigate(`/teach/session/${session.session_id}/review`)}
|
|
>
|
|
<span className="pf-session__dot" aria-hidden="true" />
|
|
<div className="pf-session__main">
|
|
<b>{session.learner_label}</b>
|
|
<span>
|
|
{session.persona_code} · {session.stage} · {session.turn_count}턴
|
|
</span>
|
|
<code>{session.session_id}</code>
|
|
</div>
|
|
<div className="pf-session__meta">
|
|
<span>{formatDateTime(session.ended_at ?? null)}</span>
|
|
<Badge tone={sessionReviewTone(session.review_status)}>
|
|
{sessionReviewStatusLabel(session.review_status)}
|
|
</Badge>
|
|
</div>
|
|
<span className="pf-session__open">
|
|
<Icon name="review" size={13} />
|
|
상세 검토
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
title="검토할 실제 회기가 없습니다"
|
|
desc="종료된 회기를 검토 완료로 닫으면 이 목록에서 사라집니다."
|
|
/>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
|
|
<section className="pf-section pf-section--safety">
|
|
<div className="pf-section__head">
|
|
<div>
|
|
<Kicker>위기 알림</Kicker>
|
|
<h2>109 안전 확인 큐</h2>
|
|
</div>
|
|
<Badge tone={safetyAlerts.length > 0 ? "warn" : "neutral"}>
|
|
{safetyAlerts.length}건
|
|
</Badge>
|
|
</div>
|
|
|
|
<Card className="pf-panel">
|
|
{safetyAlerts.length > 0 ? (
|
|
<div className="pf-alerts">
|
|
{safetyAlerts.map((alert) => (
|
|
<SafetyAlertRow alert={alert} key={alert.id} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
title="현재 위기 알림 없음"
|
|
desc="실제 위기 신호가 감지되면 이 목록에 109 확인 큐로 표시됩니다."
|
|
/>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
|
|
<section className="pf-section pf-section--persona">
|
|
<div className="pf-section__head">
|
|
<div>
|
|
<Kicker>페르소나 검수</Kicker>
|
|
<h2>공개 전 승인 큐</h2>
|
|
</div>
|
|
<Badge tone={personaReviewCount > 0 ? "accent" : "neutral"}>
|
|
{personaReviewCount}건
|
|
</Badge>
|
|
</div>
|
|
|
|
<Card className="pf-panel">
|
|
{personaReviewError ? (
|
|
<div className="pf-empty" role="alert">
|
|
<Icon name="alert" size={18} />
|
|
<b>{personaReviewError}</b>
|
|
<span>네트워크 또는 서버 오류가 발생했습니다.</span>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
leading={<Icon name="review" size={14} />}
|
|
onClick={() => void loadPersonaReviews()}
|
|
disabled={personaReviewLoading}
|
|
>
|
|
{personaReviewLoading ? "확인 중" : "다시 시도"}
|
|
</Button>
|
|
</div>
|
|
) : personaReviews.length > 0 ? (
|
|
<div className="pf-personas">
|
|
{personaReviews.map((persona) => {
|
|
return (
|
|
<article
|
|
className="pf-persona"
|
|
key={`${persona.persona_id}:${persona.version}`}
|
|
data-persona-review-row="true"
|
|
>
|
|
<div className="pf-persona__main">
|
|
<span className="pf-persona__code">{persona.code}</span>
|
|
<div>
|
|
<b>{persona.display_name}</b>
|
|
<span>
|
|
v{persona.version} · {persona.difficulty} ·{" "}
|
|
{persona.theory_target.join(" · ") || "공통"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p>{persona.source_provenance || "출처 정보 없음"}</p>
|
|
<div className="pf-persona__meta">
|
|
<Badge tone={personaReviewTone(persona.status)}>
|
|
{personaReviewStatusLabel(persona.status)}
|
|
</Badge>
|
|
<span>{formatDateTime(persona.created_at ?? null)}</span>
|
|
</div>
|
|
<div className="pf-persona__actions">
|
|
{persona.status === "review" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
leading={<Icon name="check" size={14} />}
|
|
onClick={() => void decidePersonaReview(persona.persona_id, "approve")}
|
|
disabled={personaDecisionBusy === `${persona.persona_id}:approve`}
|
|
>
|
|
승인
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
leading={<Icon name="review" size={14} />}
|
|
onClick={() =>
|
|
navigate(`/teach/personas?draft=${encodeURIComponent(persona.persona_id)}`)
|
|
}
|
|
>
|
|
스튜디오에서 검수
|
|
</Button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
title={personaReviewLoading ? "페르소나 검수 큐 확인 중" : "검수 대기 페르소나 없음"}
|
|
desc="draft 또는 review 상태의 페르소나가 등록되면 스튜디오에서 검수합니다."
|
|
/>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
|
|
<section className="pf-section pf-section--draft">
|
|
<div className="pf-section__head">
|
|
<div>
|
|
<Kicker>페르소나 저작</Kicker>
|
|
<h2>콘텐츠 저작실</h2>
|
|
</div>
|
|
<Badge tone="accent">별도 작업면</Badge>
|
|
</div>
|
|
|
|
<Card className="pf-panel pf-studio-card">
|
|
<div className="pf-studio-card__copy">
|
|
<b>페르소나 스튜디오</b>
|
|
<p>
|
|
배경 서사, 임상 개념화, 역린·금기, 회기별 시나리오, 프롬프트 미리보기와
|
|
검수 요청을 한 작업면에서 관리합니다.
|
|
</p>
|
|
<div className="pf-studio-card__meta">
|
|
<span>초안 {personaReviewCount}건</span>
|
|
<span>공개 전 검수 큐 연동</span>
|
|
</div>
|
|
</div>
|
|
<div className="pf-studio-card__actions">
|
|
<Button
|
|
variant="secondary"
|
|
leading={<Icon name="review" size={16} />}
|
|
onClick={() => navigate("/teach/personas")}
|
|
>
|
|
스튜디오 열기
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
</div>
|
|
|
|
<section className="pf-section pf-section--recent">
|
|
<div className="pf-section__head">
|
|
<div>
|
|
<Kicker>최근 세션</Kicker>
|
|
<h2>최근 회기 기록</h2>
|
|
</div>
|
|
<Badge tone="neutral">{recentSessions.length}건</Badge>
|
|
</div>
|
|
|
|
<Card className="pf-panel">
|
|
{recentSessions.length > 0 ? (
|
|
<div className="pf-recent-list">
|
|
<div className="pf-recent-head" aria-hidden="true">
|
|
<span>학습자</span>
|
|
<span>페르소나</span>
|
|
<span>상태</span>
|
|
<span>단계</span>
|
|
<span>턴</span>
|
|
<span>시작</span>
|
|
<span>종료</span>
|
|
<span>열기</span>
|
|
</div>
|
|
{recentSessions.map((session) => {
|
|
const openLabel = sessionOpenLabel(session.status);
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="pf-recent-row pf-recent-row--action"
|
|
key={session.session_id}
|
|
data-recent-session-row="true"
|
|
aria-label={`${session.learner_label} ${session.persona_code} ${openLabel}`}
|
|
onClick={() => navigate(`/teach/session/${session.session_id}/review`)}
|
|
>
|
|
<div className="pf-recent__learner">
|
|
<b>{session.learner_label}</b>
|
|
<code>{session.session_id}</code>
|
|
</div>
|
|
<span className="pf-recent__cell" data-label="페르소나">
|
|
{session.persona_code}
|
|
</span>
|
|
<span className="pf-recent__cell" data-label="상태">
|
|
<Badge tone={session.status === "ended" ? "neutral" : "accent"}>
|
|
{session.status === "ended" ? "종료" : "진행 중"}
|
|
</Badge>
|
|
{session.status === "ended" ? (
|
|
<small className="pf-recent__review-state">
|
|
{sessionReviewStatusLabel(session.review_status)}
|
|
</small>
|
|
) : null}
|
|
</span>
|
|
<span className="pf-recent__cell" data-label="단계">
|
|
{session.stage}
|
|
</span>
|
|
<span className="pf-recent__cell tabular" data-label="턴">
|
|
{session.turn_count}
|
|
</span>
|
|
<span className="pf-recent__cell tabular" data-label="시작">
|
|
{formatDateTime(session.started_at)}
|
|
</span>
|
|
<span className="pf-recent__cell tabular" data-label="종료">
|
|
{formatDateTime(session.ended_at ?? null)}
|
|
</span>
|
|
<span className="pf-recent__cell pf-recent__cell--open" data-label="열기">
|
|
<span className="pf-recent__open">
|
|
<Icon name="review" size={13} />
|
|
{openLabel}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
title="세션 로그 없음"
|
|
desc="현재 표시할 학습 세션이 없습니다."
|
|
/>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
|
|
const points = learner.points ?? [];
|
|
const topTechniques = learner.top_techniques ?? [];
|
|
const recentPoints = points.slice(-3).reverse();
|
|
return (
|
|
<article className={`pf-growth-card trend-${learner.trend}`}>
|
|
<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>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
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;
|
|
return (
|
|
<span
|
|
className={`pf-growth-bar ${hasScore ? "" : "is-empty"}`}
|
|
title={`${point.persona_code} ${point.session_no}회기 · ${formatScore(point.score)}`}
|
|
>
|
|
<i style={{ height: `${height}%` }} />
|
|
<small>{point.session_no}</small>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
|
|
return (
|
|
<article className="pf-alert">
|
|
<span className="pf-alert__ic" aria-hidden="true">
|
|
<Icon name="alert" size={16} />
|
|
</span>
|
|
<div className="pf-alert__main">
|
|
<b>{alert.learner_label}</b>
|
|
<span>
|
|
{alert.persona_code || "세션"} · 위험도 {alert.ko_risk_level} ·{" "}
|
|
{formatDateTime(alert.created_at)}
|
|
</span>
|
|
<code>{alert.session_id}</code>
|
|
</div>
|
|
<div className="pf-alert__resource">
|
|
<span>{alert.resource_title}</span>
|
|
<b>{alert.resource_number}</b>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const PF_CSS = `
|
|
.pf-root{
|
|
max-width:1280px;
|
|
margin:0 auto;
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:16px;
|
|
}
|
|
.pf-head{
|
|
display:flex;
|
|
align-items:flex-end;
|
|
justify-content:space-between;
|
|
gap:var(--sp-4);
|
|
flex-wrap:wrap;
|
|
}
|
|
.pf-head__actions{
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:flex-end;
|
|
gap:8px;
|
|
flex-wrap:wrap;
|
|
}
|
|
.pf-head h1{
|
|
margin:6px 0 0;
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-h2);
|
|
line-height:1.28;
|
|
letter-spacing:0;
|
|
}
|
|
.pf-head p{
|
|
margin:6px 0 0;
|
|
max-width:760px;
|
|
color:var(--text-body);
|
|
font-size:var(--fs-xs);
|
|
line-height:1.55;
|
|
}
|
|
.pf-signal-strip{
|
|
order:1;
|
|
display:grid;
|
|
grid-template-columns:1fr;
|
|
gap:0;
|
|
min-width:0;
|
|
}
|
|
.pf-triage{
|
|
display:grid;
|
|
grid-template-columns:auto minmax(0,1fr) auto;
|
|
align-items:center;
|
|
gap:12px;
|
|
min-width:0;
|
|
padding:14px 16px;
|
|
border:1px solid var(--hair);
|
|
border-radius:var(--radius);
|
|
background:var(--bg-surface);
|
|
box-shadow:var(--shadow-sm);
|
|
}
|
|
.pf-triage.is-active{
|
|
background:color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface));
|
|
}
|
|
.pf-triage__copy{
|
|
min-width:0;
|
|
}
|
|
.pf-triage__copy .vg-kicker{
|
|
margin-bottom:4px;
|
|
}
|
|
.pf-triage__copy b{
|
|
display:block;
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-body);
|
|
line-height:1.35;
|
|
}
|
|
.pf-triage__copy span,
|
|
.pf-triage__meta small{
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
}
|
|
.pf-triage__meta{
|
|
display:grid;
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
gap:0;
|
|
min-width:180px;
|
|
border:1px solid var(--border-subtle);
|
|
border-radius:var(--radius);
|
|
background:color-mix(in srgb,var(--bg-surface) 86%,transparent);
|
|
overflow:hidden;
|
|
}
|
|
.pf-triage__meta span{
|
|
display:grid;
|
|
align-content:center;
|
|
gap:2px;
|
|
padding:9px 12px;
|
|
}
|
|
.pf-triage__meta span + span{
|
|
border-left:1px solid var(--hair);
|
|
}
|
|
.pf-triage__meta b{
|
|
color:var(--text-strong);
|
|
font-family:var(--font-num);
|
|
font-size:17px;
|
|
line-height:1;
|
|
}
|
|
.pf-error{
|
|
display:flex;
|
|
align-items:center;
|
|
gap:10px;
|
|
padding:12px 14px;
|
|
border-radius:var(--radius);
|
|
background:var(--crit-tint);
|
|
color:var(--crit-text);
|
|
font-size:var(--fs-sm);
|
|
}
|
|
.pf-kpis{
|
|
display:grid;
|
|
grid-template-columns:repeat(6,minmax(0,1fr));
|
|
border:1px solid var(--hair);
|
|
border-radius:var(--radius);
|
|
overflow:hidden;
|
|
background:var(--bg-surface);
|
|
box-shadow:var(--shadow-sm);
|
|
}
|
|
.pf-kpi{
|
|
position:relative;
|
|
min-width:0;
|
|
padding:14px 16px;
|
|
display:grid;
|
|
grid-template-columns:minmax(0,1fr) auto;
|
|
gap:4px 10px;
|
|
}
|
|
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
|
.pf-kpi:nth-child(n+4){border-top:0;}
|
|
.pf-kpi--primary,
|
|
.pf-kpi--warn{
|
|
background:color-mix(in srgb,var(--accent-tint) 32%,var(--bg-surface));
|
|
}
|
|
.pf-kpi--warn{
|
|
background:color-mix(in srgb,var(--warn-tint) 36%,var(--bg-surface));
|
|
}
|
|
.pf-kpi--primary .pf-kpi__ic{
|
|
color:var(--text-on-accent);
|
|
background:var(--accent);
|
|
}
|
|
.pf-kpi--warn .pf-kpi__ic{
|
|
color:var(--warn-text);
|
|
background:var(--warn-tint);
|
|
}
|
|
.pf-kpi__ic{
|
|
grid-column:2;
|
|
grid-row:1 / span 3;
|
|
width:30px;
|
|
height:30px;
|
|
display:grid;
|
|
place-items:center;
|
|
border-radius:var(--radius);
|
|
color:var(--accent);
|
|
background:var(--accent-tint);
|
|
}
|
|
.pf-kpi__lab{
|
|
display:block;
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
font-weight:700;
|
|
}
|
|
.pf-kpi b{
|
|
display:block;
|
|
color:var(--text-strong);
|
|
font-family:var(--font-num);
|
|
font-size:24px;
|
|
line-height:1;
|
|
}
|
|
.pf-kpi small{
|
|
color:var(--text-muted);
|
|
font-size:12px;
|
|
line-height:1.35;
|
|
}
|
|
.pf-workspace{
|
|
order:2;
|
|
display:grid;
|
|
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
|
|
align-items:start;
|
|
gap:14px;
|
|
min-width:0;
|
|
}
|
|
.pf-queue-stack{
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:14px;
|
|
min-width:0;
|
|
}
|
|
.pf-section{
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:10px;
|
|
min-width:0;
|
|
}
|
|
.pf-section__head{
|
|
display:flex;
|
|
align-items:flex-end;
|
|
justify-content:space-between;
|
|
gap:12px;
|
|
}
|
|
.pf-section__head h2{
|
|
margin:4px 0 0;
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-body);
|
|
font-weight:700;
|
|
line-height:1.3;
|
|
letter-spacing:0;
|
|
}
|
|
.pf-panel{
|
|
padding:0;
|
|
overflow:hidden;
|
|
}
|
|
.pf-studio-card{
|
|
display:grid;
|
|
grid-template-columns:minmax(0,1fr) auto;
|
|
gap:14px;
|
|
align-items:center;
|
|
padding:14px;
|
|
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
|
}
|
|
.pf-studio-card__copy{
|
|
min-width:0;
|
|
display:grid;
|
|
gap:7px;
|
|
}
|
|
.pf-studio-card__copy b{
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-body);
|
|
}
|
|
.pf-studio-card__copy p{
|
|
margin:0;
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
line-height:1.55;
|
|
}
|
|
.pf-studio-card__meta{
|
|
display:flex;
|
|
flex-wrap:wrap;
|
|
gap:6px;
|
|
}
|
|
.pf-studio-card__meta span{
|
|
padding:4px 7px;
|
|
border:1px solid var(--hair);
|
|
border-radius:var(--radius-sm);
|
|
color:var(--text-body);
|
|
background:var(--bg-surface-2);
|
|
font-size:11px;
|
|
line-height:1.2;
|
|
}
|
|
.pf-studio-card__actions{
|
|
display:flex;
|
|
justify-content:flex-end;
|
|
}
|
|
.pf-section--growth{
|
|
order:3;
|
|
min-width:0;
|
|
}
|
|
.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;
|
|
display:grid;
|
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
|
gap:12px;
|
|
padding:12px;
|
|
}
|
|
.pf-growth-card{
|
|
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;
|
|
}
|
|
.pf-growth-card__top{
|
|
display:flex;
|
|
align-items:flex-start;
|
|
justify-content:space-between;
|
|
gap:10px;
|
|
min-width:0;
|
|
}
|
|
.pf-growth-card__id{
|
|
min-width:0;
|
|
display:grid;
|
|
gap:3px;
|
|
}
|
|
.pf-growth-card__id b{
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-sm);
|
|
line-height:1.35;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
.pf-growth-card__id span,
|
|
.pf-growth-card__metrics small,
|
|
.pf-growth-point span,
|
|
.pf-recent__review-state{
|
|
color:var(--text-muted);
|
|
font-size:12px;
|
|
line-height:1.4;
|
|
}
|
|
.pf-recent__review-state{
|
|
display:block;
|
|
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;
|
|
height:100%;
|
|
display:grid;
|
|
grid-template-rows:minmax(0,1fr) 14px;
|
|
gap:4px;
|
|
align-items:end;
|
|
}
|
|
.pf-growth-bar i{
|
|
display:block;
|
|
width:100%;
|
|
min-height:6px;
|
|
border-radius:6px 6px 3px 3px;
|
|
background:linear-gradient(180deg,var(--accent),var(--accent-deep));
|
|
}
|
|
.pf-growth-bar.is-empty i{
|
|
background:repeating-linear-gradient(135deg,var(--paper-2),var(--paper-2) 3px,var(--hair) 3px,var(--hair) 6px);
|
|
}
|
|
.pf-growth-bar small{
|
|
color:var(--text-muted);
|
|
font-family:var(--font-num);
|
|
font-size:10px;
|
|
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-empty{
|
|
min-height:118px;
|
|
display:grid;
|
|
place-items:center;
|
|
gap:6px;
|
|
padding:var(--sp-5);
|
|
text-align:center;
|
|
}
|
|
.pf-empty b{
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-body);
|
|
}
|
|
.pf-empty span{
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
}
|
|
.pf-list{
|
|
max-height:min(320px,42vh);
|
|
overflow:auto;
|
|
scrollbar-gutter:stable;
|
|
display:flex;
|
|
flex-direction:column;
|
|
}
|
|
.pf-personas{
|
|
max-height:min(320px,42vh);
|
|
overflow:auto;
|
|
scrollbar-gutter:stable;
|
|
display:flex;
|
|
flex-direction:column;
|
|
}
|
|
.pf-persona{
|
|
display:grid;
|
|
grid-template-columns:minmax(0,1fr);
|
|
gap:8px;
|
|
align-items:start;
|
|
padding:12px;
|
|
border-top:1px solid var(--paper-2);
|
|
}
|
|
.pf-persona:first-child{border-top:0;}
|
|
.pf-persona__main{
|
|
min-width:0;
|
|
display:grid;
|
|
grid-template-columns:40px minmax(0,1fr);
|
|
gap:10px;
|
|
align-items:center;
|
|
}
|
|
.pf-persona__code{
|
|
width:40px;
|
|
height:32px;
|
|
display:grid;
|
|
place-items:center;
|
|
border-radius:var(--radius);
|
|
color:var(--accent-deep);
|
|
background:var(--accent-tint);
|
|
font-family:var(--font-num);
|
|
font-weight:800;
|
|
font-size:12px;
|
|
}
|
|
.pf-persona__main b{
|
|
display:block;
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-sm);
|
|
line-height:1.35;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
.pf-persona__main span,
|
|
.pf-persona p,
|
|
.pf-persona__meta span{
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
line-height:1.45;
|
|
}
|
|
.pf-persona p{
|
|
margin:0;
|
|
min-width:0;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
.pf-persona__meta{
|
|
display:flex;
|
|
align-items:center;
|
|
gap:10px;
|
|
justify-content:space-between;
|
|
white-space:nowrap;
|
|
}
|
|
.pf-persona__actions{
|
|
display:flex;
|
|
justify-content:flex-end;
|
|
gap:8px;
|
|
min-width:0;
|
|
}
|
|
.pf-persona__actions .vg-btn{
|
|
min-width:72px;
|
|
padding-inline:10px;
|
|
}
|
|
.pf-alerts{
|
|
max-height:min(300px,38vh);
|
|
overflow:auto;
|
|
scrollbar-gutter:stable;
|
|
display:flex;
|
|
flex-direction:column;
|
|
}
|
|
.pf-alert{
|
|
display:grid;
|
|
grid-template-columns:auto minmax(0,1fr) auto;
|
|
gap:10px;
|
|
align-items:center;
|
|
padding:12px;
|
|
border-top:1px solid var(--paper-2);
|
|
background:color-mix(in srgb,var(--warn-tint) 34%,transparent);
|
|
}
|
|
.pf-alert:first-child{border-top:0;}
|
|
.pf-alert__ic{
|
|
width:30px;
|
|
height:30px;
|
|
display:grid;
|
|
place-items:center;
|
|
border-radius:var(--radius);
|
|
color:var(--warn-text);
|
|
background:var(--warn-tint);
|
|
}
|
|
.pf-alert__main{
|
|
min-width:0;
|
|
display:grid;
|
|
gap:3px;
|
|
}
|
|
.pf-alert__main b{
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-sm);
|
|
}
|
|
.pf-alert__main span,
|
|
.pf-alert__main code,
|
|
.pf-alert__resource span{
|
|
color:var(--text-muted);
|
|
font-size:12px;
|
|
}
|
|
.pf-alert__main code{
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
.pf-alert__resource{
|
|
display:grid;
|
|
gap:2px;
|
|
justify-items:end;
|
|
min-width:86px;
|
|
}
|
|
.pf-alert__resource b{
|
|
color:var(--warn-text);
|
|
font-family:var(--font-num);
|
|
font-size:18px;
|
|
}
|
|
.pf-session{
|
|
width:100%;
|
|
font:inherit;
|
|
text-align:left;
|
|
background:transparent;
|
|
color:inherit;
|
|
display:grid;
|
|
grid-template-columns:8px minmax(0,1fr) auto;
|
|
gap:8px 10px;
|
|
align-items:start;
|
|
padding:12px;
|
|
border:0;
|
|
border-top:1px solid var(--paper-2);
|
|
cursor:default;
|
|
}
|
|
.pf-session:first-child{border-top:0;}
|
|
.pf-session--action{
|
|
cursor:pointer;
|
|
}
|
|
.pf-session--action:hover{
|
|
background:var(--bg-surface-2);
|
|
}
|
|
.pf-session--action:focus-visible{
|
|
outline:2px solid var(--accent);
|
|
outline-offset:-2px;
|
|
}
|
|
.pf-session__dot{
|
|
width:8px;
|
|
height:8px;
|
|
border-radius:50%;
|
|
background:var(--accent);
|
|
}
|
|
.pf-session__main{
|
|
min-width:0;
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:3px;
|
|
}
|
|
.pf-session__main b{
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-sm);
|
|
}
|
|
.pf-session__main span,.pf-session__main code{
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
overflow-wrap:anywhere;
|
|
}
|
|
.pf-session__main code,.pf-recent__learner code{
|
|
font-family:var(--font-num);
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
}
|
|
.pf-session__meta{
|
|
grid-column:2 / 4;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:space-between;
|
|
gap:8px;
|
|
color:var(--text-muted);
|
|
font-family:var(--font-num);
|
|
font-size:var(--fs-xs);
|
|
white-space:normal;
|
|
}
|
|
.pf-session__open{
|
|
grid-column:3;
|
|
grid-row:1;
|
|
display:inline-flex;
|
|
align-items:center;
|
|
gap:5px;
|
|
align-self:center;
|
|
justify-self:end;
|
|
min-height:30px;
|
|
padding:0 9px;
|
|
border:1px solid var(--hair);
|
|
border-radius:var(--radius-sm);
|
|
color:var(--accent-deep);
|
|
background:var(--accent-tint);
|
|
font-size:var(--fs-xs);
|
|
font-weight:760;
|
|
white-space:nowrap;
|
|
}
|
|
.pf-session--action:hover .pf-session__open{
|
|
border-color:var(--accent);
|
|
}
|
|
.pf-recent-list{
|
|
max-height:min(620px,calc(100vh - 220px));
|
|
overflow:auto;
|
|
scrollbar-gutter:stable;
|
|
min-width:0;
|
|
}
|
|
.pf-recent-head,
|
|
.pf-recent-row{
|
|
display:grid;
|
|
grid-template-columns:
|
|
minmax(128px,1.25fr)
|
|
minmax(58px,.55fr)
|
|
minmax(66px,.55fr)
|
|
minmax(82px,.85fr)
|
|
minmax(32px,.35fr)
|
|
minmax(70px,.6fr)
|
|
minmax(66px,.55fr)
|
|
minmax(86px,.65fr);
|
|
align-items:center;
|
|
gap:8px;
|
|
min-width:0;
|
|
}
|
|
.pf-recent-head{
|
|
position:sticky;
|
|
top:0;
|
|
z-index:1;
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
font-weight:700;
|
|
padding:9px 12px;
|
|
border-bottom:1px solid var(--hair);
|
|
background:var(--bg-surface);
|
|
white-space:nowrap;
|
|
}
|
|
.pf-recent-row{
|
|
width:100%;
|
|
font:inherit;
|
|
text-align:left;
|
|
color:inherit;
|
|
background:transparent;
|
|
padding:10px 12px;
|
|
border:0;
|
|
border-top:1px solid var(--paper-2);
|
|
}
|
|
.pf-recent-row--action{
|
|
cursor:pointer;
|
|
}
|
|
.pf-recent-row--action:hover{
|
|
background:var(--bg-surface-2);
|
|
}
|
|
.pf-recent-row--action:focus-visible{
|
|
outline:2px solid var(--accent);
|
|
outline-offset:-2px;
|
|
}
|
|
.pf-recent-head + .pf-recent-row{
|
|
border-top:0;
|
|
}
|
|
.pf-recent__learner,
|
|
.pf-recent__cell{
|
|
min-width:0;
|
|
color:var(--text-body);
|
|
font-size:var(--fs-sm);
|
|
overflow-wrap:anywhere;
|
|
}
|
|
.pf-recent__cell::before{
|
|
display:none;
|
|
}
|
|
.pf-recent__learner{
|
|
min-width:0;
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:3px;
|
|
}
|
|
.pf-recent__learner b{
|
|
color:var(--text-strong);
|
|
font-size:var(--fs-sm);
|
|
line-height:1.35;
|
|
overflow-wrap:anywhere;
|
|
}
|
|
.pf-recent__learner code{
|
|
max-width:100%;
|
|
color:var(--text-muted);
|
|
font-size:11px;
|
|
white-space:nowrap;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
}
|
|
.pf-recent__cell .vg-badge{
|
|
justify-self:start;
|
|
}
|
|
.pf-recent__cell--open{
|
|
display:flex;
|
|
justify-content:flex-end;
|
|
}
|
|
.pf-recent__open{
|
|
display:inline-flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
gap:4px;
|
|
min-height:30px;
|
|
padding:0 7px;
|
|
border:1px solid var(--hair);
|
|
border-radius:var(--radius-sm);
|
|
color:var(--accent-deep);
|
|
background:var(--accent-tint);
|
|
font-size:var(--fs-xs);
|
|
font-weight:760;
|
|
white-space:nowrap;
|
|
}
|
|
.pf-recent-row--action:hover .pf-recent__open{
|
|
border-color:var(--accent);
|
|
}
|
|
@media (max-width:1100px){
|
|
.pf-signal-strip,
|
|
.pf-workspace{
|
|
grid-template-columns:1fr;
|
|
}
|
|
.pf-triage{
|
|
grid-template-columns:auto minmax(0,max-content) auto;
|
|
justify-content:start;
|
|
}
|
|
.pf-kpis{
|
|
grid-template-columns:repeat(4,minmax(0,1fr));
|
|
}
|
|
.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-list,
|
|
.pf-personas{
|
|
max-height:360px;
|
|
}
|
|
.pf-recent-list{
|
|
max-height:460px;
|
|
}
|
|
}
|
|
@media (max-width:860px){
|
|
.pf-head__actions{
|
|
width:100%;
|
|
justify-content:flex-start;
|
|
}
|
|
.pf-triage{
|
|
grid-template-columns:auto minmax(0,1fr);
|
|
}
|
|
.pf-triage__meta{
|
|
grid-column:1 / -1;
|
|
width:100%;
|
|
}
|
|
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
|
.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-recent-list{
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:10px;
|
|
padding:10px;
|
|
background:var(--bg-surface-2);
|
|
overflow-x:hidden;
|
|
}
|
|
.pf-recent-head{
|
|
display:none;
|
|
}
|
|
.pf-recent-row{
|
|
display:grid;
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
gap:10px 12px;
|
|
padding:12px;
|
|
border:1px solid var(--paper-2);
|
|
border-radius:var(--radius);
|
|
background:var(--bg-surface);
|
|
}
|
|
.pf-recent__learner{
|
|
grid-column:1 / -1;
|
|
padding-bottom:10px;
|
|
border-bottom:1px solid var(--paper-2);
|
|
}
|
|
.pf-recent__learner code{
|
|
white-space:normal;
|
|
overflow-wrap:anywhere;
|
|
}
|
|
.pf-recent__cell{
|
|
display:grid;
|
|
grid-template-columns:minmax(74px,.4fr) minmax(0,1fr);
|
|
gap:8px;
|
|
align-items:center;
|
|
}
|
|
.pf-recent__cell--open{
|
|
justify-content:stretch;
|
|
}
|
|
.pf-recent__cell::before{
|
|
display:block;
|
|
content:attr(data-label);
|
|
color:var(--text-muted);
|
|
font-size:var(--fs-xs);
|
|
font-weight:700;
|
|
line-height:1.35;
|
|
}
|
|
.pf-session{
|
|
grid-template-columns:8px minmax(0,1fr);
|
|
}
|
|
.pf-session__meta{
|
|
grid-column:2;
|
|
justify-content:flex-start;
|
|
}
|
|
.pf-session__open{
|
|
grid-column:2;
|
|
grid-row:auto;
|
|
justify-self:start;
|
|
}
|
|
}
|
|
@media (max-width:520px){
|
|
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
|
.pf-kpi,
|
|
.pf-kpi + .pf-kpi{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-persona__actions{
|
|
display:grid;
|
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
|
}
|
|
.pf-persona__actions .vg-btn{
|
|
width:100%;
|
|
}
|
|
.pf-growth-list{
|
|
padding:10px;
|
|
}
|
|
.pf-growth-card__metrics{
|
|
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);
|
|
}
|
|
.pf-alert__resource{
|
|
grid-column:2;
|
|
justify-items:start;
|
|
}
|
|
.pf-recent-list{
|
|
padding:8px;
|
|
}
|
|
.pf-recent-row{
|
|
grid-template-columns:1fr;
|
|
gap:10px;
|
|
}
|
|
.pf-recent__cell{
|
|
grid-template-columns:minmax(64px,.32fr) minmax(0,1fr);
|
|
}
|
|
.pf-recent__cell--open .pf-recent__open{
|
|
justify-self:start;
|
|
}
|
|
}
|
|
`;
|