feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포): - 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브 - 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정 - ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서 - 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨 api (FastAPI): - 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존) - services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 / 턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG - store: DB off 폴백(in-memory), sessions 실구현 검증: - web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200 - api: app.main import 통과 - 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시) - E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
583
apps/web/src/pages/Professor.tsx
Normal file
583
apps/web/src/pages/Professor.tsx
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
/* =====================================================================
|
||||
Professor — 교수자 콘솔 (/teach). 완성 페이지.
|
||||
설계: DESIGN_CONCEPT §6.5 — awareness가 아니라 triage.
|
||||
1) 페이지 헤드라인: 한 화면 한 메시지("3명에게 개입이 필요합니다")
|
||||
2) 개입 큐(triage, 풀폭): 사유 자연어 + crit dot. 행 펼치면 슈퍼바이저 코멘트 작성.
|
||||
3) 검수 대기 회기: 코멘트 미작성 회기를 시간순으로.
|
||||
4) 담당 학습자 현황 테이블: sparkline + 역량 화살표 + 위험도/이름 정렬.
|
||||
5) 반 전체 취약 영역: 가로 막대(레이더 아님) + small-multiples 추세 + 코칭 인사이트.
|
||||
accent=인디고-블루는 AuthProvider 가 body[data-role=instructor] 로 반영(teacher→instructor).
|
||||
철칙: border-left 0 · 이모지 0(아이콘 inline SVG/Icon) · 카드덤프 0 ·
|
||||
순흑/순백 금지 · 강조는 weight/tint/kicker/dot · red는 개입에만.
|
||||
스타일: Login 컨벤션대로 페이지 스코프 <style>{PF_CSS}</style> (pf- 프리픽스).
|
||||
===================================================================== */
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import { Badge, Button, Dot, Icon, Kicker } from "../components/ui";
|
||||
import { formatDelta, trendOf } from "../lib/format";
|
||||
import { MiniMultiple, SparkLine, TrendArrow } from "./professor/charts";
|
||||
import {
|
||||
COHORT_BARS,
|
||||
COHORT_META,
|
||||
COHORT_TRENDS,
|
||||
LEARNERS,
|
||||
PENDING_REVIEWS,
|
||||
TRIAGE,
|
||||
type CompetencyCell,
|
||||
type LearnerRow,
|
||||
} from "./professor/mock";
|
||||
|
||||
type SortKey = "risk" | "name";
|
||||
|
||||
const STATE_LABEL: Record<LearnerRow["state"], string> = {
|
||||
crit: "개입",
|
||||
warn: "관찰",
|
||||
ok: "양호",
|
||||
};
|
||||
|
||||
/** 역량 셀 — 숫자 + 추세 화살표(색만 시맨틱). 절대 점수 박스 강조 금지. */
|
||||
function MetricCell({ cell }: { cell: CompetencyCell }) {
|
||||
const trend = trendOf(cell.delta, 1.5);
|
||||
return (
|
||||
<span className="pf-metric">
|
||||
<span className="pf-metric__v tabular">{cell.value}</span>
|
||||
<TrendArrow trend={trend} delta={cell.delta} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Professor() {
|
||||
// 개입 큐: 펼쳐서 슈퍼바이저 코멘트를 그 자리에서 작성(교수 핵심 동선).
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [sent, setSent] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 학습자 테이블 정렬(위험도 ↔ 이름).
|
||||
const [sortKey, setSortKey] = useState<SortKey>("risk");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const sortedLearners = useMemo(() => {
|
||||
const rows = [...LEARNERS];
|
||||
if (sortKey === "risk") rows.sort((a, b) => b.risk - a.risk);
|
||||
else rows.sort((a, b) => a.name.localeCompare(b.name, "ko"));
|
||||
return rows;
|
||||
}, [sortKey]);
|
||||
|
||||
const visibleLearners = showAll ? sortedLearners : sortedLearners.slice(0, 7);
|
||||
const hiddenCount = COHORT_META.total - visibleLearners.length;
|
||||
|
||||
const toggleRow = (id: string) => setOpenId((cur) => (cur === id ? null : id));
|
||||
|
||||
const submitComment = (id: string) => {
|
||||
const text = (drafts[id] ?? "").trim();
|
||||
if (!text) return;
|
||||
// mock — 실제로는 POST /sessions/{id}/comment. 여기선 보낸 상태만 표시.
|
||||
setSent((s) => ({ ...s, [id]: true }));
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<style>{PF_CSS}</style>
|
||||
|
||||
<div className="pf-root">
|
||||
{/* ── 페이지 헤드라인 (한 화면 한 메시지) ── */}
|
||||
<header className="pf-head">
|
||||
<Kicker>
|
||||
{COHORT_META.course} · {COHORT_META.total}명 담당 · {COHORT_META.week}주차
|
||||
</Kicker>
|
||||
<h1 className="pf-head__title">
|
||||
이번 주, <em>{COHORT_META.needIntervention}명에게 개입이 필요</em>합니다.
|
||||
</h1>
|
||||
<p className="pf-head__sub">
|
||||
정체·하락 신호가 잡힌 학습자를 먼저 보여드립니다. 나머지 {COHORT_META.stable}명은
|
||||
안정 범위입니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* ── 1. 개입 큐 (triage, 최우선 풀폭) ── */}
|
||||
<section className="pf-block">
|
||||
<div className="pf-sechead">
|
||||
<div className="pf-sechead__lt">
|
||||
<Kicker>개입 필요 학습자</Kicker>
|
||||
<p className="pf-sechead__desc">
|
||||
수치 변화와 행동 신호로 정렬했습니다. 행을 펼쳐 슈퍼바이저 코멘트를 바로
|
||||
남기세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pf-queue">
|
||||
{TRIAGE.map((t) => {
|
||||
const open = openId === t.id;
|
||||
const wasSent = sent[t.id];
|
||||
return (
|
||||
<div key={t.id} className={"pf-qitem" + (open ? " is-open" : "")}>
|
||||
<button
|
||||
type="button"
|
||||
className="pf-qrow"
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleRow(t.id)}
|
||||
>
|
||||
<span className="pf-qrow__dot" aria-hidden="true">
|
||||
<Dot tone="crit" size={8} />
|
||||
</span>
|
||||
<span className="pf-qrow__who">
|
||||
<span className="pf-qrow__nm">{t.name}</span>
|
||||
<span className="pf-qrow__yr">{t.meta}</span>
|
||||
</span>
|
||||
<span className="pf-qrow__why">
|
||||
<b>{t.reasonLead}</b>
|
||||
{t.trail ? <span className="pf-qrow__trail tabular"> {t.trail}</span> : null}
|
||||
</span>
|
||||
<span className="pf-qrow__last">
|
||||
최근 연습 {t.lastPracticed}
|
||||
<span className="pf-qrow__em">{t.signal}</span>
|
||||
</span>
|
||||
<span className="pf-qrow__go" aria-hidden="true">
|
||||
<Icon name={open ? "chevron-left" : "chevron-right"} size={18} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="pf-qpanel">
|
||||
<div className="pf-qpanel__meta">
|
||||
<span className="pf-qpanel__mk">
|
||||
{t.existingComments > 0 ? (
|
||||
<Badge tone="info">기존 코멘트 {t.existingComments}건</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">코멘트 없음</Badge>
|
||||
)}
|
||||
</span>
|
||||
<a className="pf-qpanel__link" href={`#learner-${t.id}`}>
|
||||
학습자 회기 열람
|
||||
<Icon name="chevron-right" size={14} strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{wasSent ? (
|
||||
<div className="pf-qpanel__done">
|
||||
<Icon name="check" size={16} strokeWidth={2} />
|
||||
코멘트를 전달했습니다. 다음 회기 리뷰에서 확인됩니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="pf-compose">
|
||||
<label className="pf-compose__lab" htmlFor={`cmt-${t.id}`}>
|
||||
슈퍼바이저 코멘트
|
||||
</label>
|
||||
<textarea
|
||||
id={`cmt-${t.id}`}
|
||||
className="pf-compose__ta"
|
||||
rows={3}
|
||||
placeholder={`${t.name} 학습자에게 남길 코멘트… (예: 위기 신호 직후 침묵을 견디는 연습을 권합니다)`}
|
||||
value={drafts[t.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, [t.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className="pf-compose__act">
|
||||
<span className="pf-compose__hint">
|
||||
학습자 다음 회기 리뷰에 “교수자” 태그로 표시됩니다.
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={!(drafts[t.id] ?? "").trim()}
|
||||
onClick={() => submitComment(t.id)}
|
||||
>
|
||||
코멘트 전달
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 2. 검수 대기 회기 (코멘트 미작성, 시간순) ── */}
|
||||
<section className="pf-block">
|
||||
<div className="pf-sechead">
|
||||
<div className="pf-sechead__lt">
|
||||
<Kicker>검수 대기 회기</Kicker>
|
||||
<p className="pf-sechead__desc">
|
||||
슈퍼바이저 코멘트가 아직 없는 회기입니다. AI가 표시한 살펴볼 순간 수를 함께
|
||||
보여드립니다.
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="accent">{PENDING_REVIEWS.length}건 대기</Badge>
|
||||
</div>
|
||||
|
||||
<ul className="pf-review">
|
||||
{PENDING_REVIEWS.map((r) => (
|
||||
<li key={r.id} className="pf-review__row">
|
||||
<a className="pf-review__link" href={`#review-${r.id}`}>
|
||||
<span className="pf-review__who">
|
||||
<span className="pf-review__nm">{r.learner}</span>
|
||||
<span className="pf-review__case">{r.caseLabel}</span>
|
||||
</span>
|
||||
<span className="pf-review__sess tabular">{r.sessionNo}회기</span>
|
||||
<span className="pf-review__flag">
|
||||
{r.flagged > 0 ? (
|
||||
<Badge tone="warn">살펴볼 순간 {r.flagged}</Badge>
|
||||
) : (
|
||||
<span className="pf-review__noflag">표시 없음</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="pf-review__time tabular">{r.endedAt}</span>
|
||||
<span className="pf-review__go" aria-hidden="true">
|
||||
<Icon name="chevron-right" size={16} strokeWidth={2} />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* ── 3. 담당 학습자 현황 (테이블 + sparkline) ── */}
|
||||
<section className="pf-block">
|
||||
<div className="pf-sechead">
|
||||
<div className="pf-sechead__lt">
|
||||
<Kicker>담당 학습자 현황</Kicker>
|
||||
<p className="pf-sechead__desc">
|
||||
행을 클릭하면 학습자별 세션·발화 단위 평가로 들어갑니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pf-sechead__rt">
|
||||
<div className="pf-sort" role="group" aria-label="정렬 기준">
|
||||
<button
|
||||
type="button"
|
||||
className={"pf-sort__btn" + (sortKey === "risk" ? " is-on" : "")}
|
||||
onClick={() => setSortKey("risk")}
|
||||
>
|
||||
위험도
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={"pf-sort__btn" + (sortKey === "name" ? " is-on" : "")}
|
||||
onClick={() => setSortKey("name")}
|
||||
>
|
||||
이름
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pf-tablewrap">
|
||||
<table className="pf-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>학습자</th>
|
||||
<th className="pf-tbl__n">세션</th>
|
||||
<th className="pf-tbl__n">라포</th>
|
||||
<th className="pf-tbl__n">기법</th>
|
||||
<th className="pf-tbl__n">개입</th>
|
||||
<th className="pf-tbl__n">공감</th>
|
||||
<th>추세 (최근 8회)</th>
|
||||
<th>최근 연습</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleLearners.map((l) => (
|
||||
<tr key={l.id} className="pf-tbl__row">
|
||||
<td>
|
||||
<a className="pf-tbl__name" href={`#learner-${l.id}`}>
|
||||
{l.name}
|
||||
<span className="pf-tbl__id tabular">{l.id}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td className="pf-tbl__n tabular">{l.sessions}</td>
|
||||
<td className="pf-tbl__m"><MetricCell cell={l.rapport} /></td>
|
||||
<td className="pf-tbl__m"><MetricCell cell={l.technique} /></td>
|
||||
<td className="pf-tbl__m"><MetricCell cell={l.intervention} /></td>
|
||||
<td className="pf-tbl__m"><MetricCell cell={l.empathy} /></td>
|
||||
<td className="pf-tbl__spark">
|
||||
<SparkLine values={l.trend} />
|
||||
</td>
|
||||
<td className="pf-tbl__last tabular">{l.lastPracticed}</td>
|
||||
<td className="pf-tbl__state">
|
||||
<span className={"pf-statetag pf-statetag--" + l.state}>
|
||||
<Dot
|
||||
tone={l.state === "crit" ? "crit" : l.state === "warn" ? "warn" : "muted"}
|
||||
size={7}
|
||||
/>
|
||||
{STATE_LABEL[l.state]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="pf-tbl__foot">
|
||||
전체 {COHORT_META.total}명 중 {visibleLearners.length}명 표시
|
||||
{hiddenCount > 0 ? (
|
||||
<>
|
||||
{" · "}
|
||||
<button type="button" className="pf-link" onClick={() => setShowAll(true)}>
|
||||
{hiddenCount}명 더 보기
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{" · "}
|
||||
<button type="button" className="pf-link" onClick={() => setShowAll(false)}>
|
||||
접기
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 4. 반 전체 취약 영역 (가로 막대 + small-multiples + 인사이트) ── */}
|
||||
<section className="pf-block">
|
||||
<div className="pf-sechead">
|
||||
<div className="pf-sechead__lt">
|
||||
<Kicker>반 전체 취약 영역</Kicker>
|
||||
<p className="pf-sechead__desc">
|
||||
반 평균 역량을 비교합니다. 다 같이 못하는 기법이 다음 수업의 표적입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pf-compare">
|
||||
{/* 좌: 가로 막대(최저만 강조) + small-multiples 추세 */}
|
||||
<div className="pf-compare__main">
|
||||
<div className="pf-hbars">
|
||||
{COHORT_BARS.map((b) => (
|
||||
<div className="pf-hbar" key={b.label}>
|
||||
<div className="pf-hbar__top">
|
||||
<span className="pf-hbar__lb">{b.label}</span>
|
||||
{b.weakest ? (
|
||||
<span className="pf-hbar__tag">
|
||||
<Icon name="alert" size={12} strokeWidth={2} />
|
||||
가장 취약
|
||||
</span>
|
||||
) : null}
|
||||
<span className="pf-hbar__vv tabular">{b.avg}</span>
|
||||
</div>
|
||||
<div className="pf-hbar__track">
|
||||
<span
|
||||
className={"pf-hbar__fill" + (b.weakest ? " is-weak" : "")}
|
||||
style={{ width: `${b.avg}%` }}
|
||||
/>
|
||||
</div>
|
||||
{b.note ? <p className="pf-hbar__note">{b.note}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pf-mm__group" aria-label="반 평균 역량 추이(최근 6주)">
|
||||
<span className="pf-mm__caption">반 평균 추이 · 최근 6주 · 공통 0–100</span>
|
||||
<div className="pf-mm__grid">
|
||||
{COHORT_TRENDS.map((t, i) => (
|
||||
<MiniMultiple
|
||||
key={t.label}
|
||||
label={t.label}
|
||||
values={t.values}
|
||||
delta={t.delta}
|
||||
showAxis={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우: 코칭 인사이트 콜아웃 (accent tint, 좌측바 없음 → tint+kicker+dot) */}
|
||||
<aside className="pf-insight">
|
||||
<span className="pf-insight__kicker">
|
||||
<span className="pf-insight__dot" aria-hidden="true" />
|
||||
이번 주 코칭 포커스
|
||||
</span>
|
||||
<h3 className="pf-insight__h3">
|
||||
위기 개입에서 <em>침묵을 견디는 훈련</em>이 반 전체에 필요합니다.
|
||||
</h3>
|
||||
<p className="pf-insight__p">
|
||||
자해 언급 직후 19명이 평균 1.8초 안에 화제를 돌렸습니다. 박서연·이도현·최민준의
|
||||
하락도 모두 이 지점에서 시작됩니다. 위기 시나리오를 다음 합동 실습 과제로
|
||||
배정하시겠어요?
|
||||
</p>
|
||||
<div className="pf-insight__act">
|
||||
<Button
|
||||
variant="primary"
|
||||
trailing={<Icon name="chevron-right" size={16} strokeWidth={2} />}
|
||||
>
|
||||
위기 시나리오 과제 배정
|
||||
</Button>
|
||||
</div>
|
||||
<p className="pf-insight__meta tabular">
|
||||
반 평균 위기 개입 {COHORT_BARS.find((b) => b.weakest)?.avg ?? 0}점 ·
|
||||
전주 대비 {formatDelta(-2)}
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
페이지 스코프 스타일 — Login 컨벤션(<style>{CSS}>) 따름.
|
||||
토큰만 참조. border-left 0 · 좌측바 0 · 라운드 절제(카드 8/12, 입력 6).
|
||||
===================================================================== */
|
||||
const PF_CSS = `
|
||||
.pf-root{ max-width: var(--maxw); margin:0 auto; }
|
||||
|
||||
/* 헤드라인 */
|
||||
.pf-head{ margin-bottom: var(--sp-7); }
|
||||
.pf-head .vg-kicker{ margin-bottom: var(--sp-3); }
|
||||
.pf-head__title{ font-size: var(--fs-h1); font-weight:700; letter-spacing:-0.02em; color:var(--text-strong); line-height:1.3; }
|
||||
.pf-head__title em{ font-style:normal; color:var(--accent); }
|
||||
.pf-head__sub{ font-size: var(--fs-lead); color:var(--text-muted); margin-top: var(--sp-2); line-height:1.55; }
|
||||
|
||||
/* 블록/섹션헤드 */
|
||||
.pf-block{ margin-bottom: var(--sp-8); }
|
||||
.pf-sechead{ display:flex; align-items:flex-end; justify-content:space-between; gap: var(--sp-4); margin-bottom: var(--sp-4); }
|
||||
.pf-sechead__lt .vg-kicker{ margin-bottom: 6px; }
|
||||
.pf-sechead__desc{ font-size: var(--fs-xs); color: var(--text-muted); max-width: 560px; line-height:1.5; }
|
||||
.pf-sechead__rt{ display:flex; align-items:center; gap: var(--sp-2); flex:none; }
|
||||
|
||||
.pf-link{ background:none; border:none; padding:0; cursor:pointer; color:var(--accent); font-weight:600; font-size: var(--fs-xs); font-family:inherit; }
|
||||
.pf-link:hover{ color: var(--accent-deep); }
|
||||
|
||||
/* 정렬 토글 */
|
||||
.pf-sort{ display:inline-flex; gap:2px; background:var(--bg-surface-2); border:1px solid var(--border-subtle); border-radius: var(--radius); padding:2px; }
|
||||
.pf-sort__btn{ border:none; background:none; cursor:pointer; font-family: var(--font-num); font-size: var(--fs-xs); font-weight:600; color:var(--text-muted); padding:5px 11px; border-radius: var(--radius-sm); transition: color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); }
|
||||
.pf-sort__btn:hover{ color: var(--text-body); }
|
||||
.pf-sort__btn.is-on{ background: var(--bg-surface); color: var(--accent); box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* ── 1. 개입 큐 ── */
|
||||
.pf-queue{ background: var(--bg-surface); border:1px solid var(--border-subtle); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow:hidden; }
|
||||
.pf-qitem{ border-top:1px solid var(--paper-2); }
|
||||
.pf-qitem:first-child{ border-top:none; }
|
||||
.pf-qrow{ width:100%; display:grid; grid-template-columns: 18px 150px 1fr 150px 22px; align-items:center; column-gap: var(--sp-4); padding: var(--sp-5); background:none; border:none; cursor:pointer; text-align:left; font-family:inherit; transition: background var(--dur-base) var(--ease-out); }
|
||||
.pf-qrow:hover{ background: var(--accent-tint); }
|
||||
.pf-qitem.is-open > .pf-qrow{ background: var(--accent-tint); }
|
||||
.pf-qrow__dot{ display:inline-flex; }
|
||||
.pf-qrow__who{ display:flex; flex-direction:column; gap:2px; }
|
||||
.pf-qrow__nm{ font-size: var(--fs-body); font-weight:700; color: var(--text-strong); letter-spacing:-0.01em; }
|
||||
.pf-qrow__yr{ font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
.pf-qrow__why{ font-size: var(--fs-sm); color: var(--text-body); line-height:1.5; }
|
||||
.pf-qrow__why b{ font-weight:600; color: var(--text-strong); }
|
||||
.pf-qrow__trail{ font-family: var(--font-num); font-variant-numeric: tabular-nums; color: var(--crit-text); font-weight:600; }
|
||||
.pf-qrow__last{ font-size: var(--fs-xs); color: var(--text-muted); text-align:right; line-height:1.4; }
|
||||
.pf-qrow__em{ display:block; color: var(--warn-text); font-weight:600; margin-top:2px; }
|
||||
.pf-qrow__go{ display:flex; justify-content:flex-end; color: var(--text-muted); transition: color var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out); }
|
||||
.pf-qrow:hover .pf-qrow__go{ color: var(--accent); transform: translateX(2px); }
|
||||
|
||||
.pf-qpanel{ padding: 0 var(--sp-5) var(--sp-5) calc(18px + var(--sp-4) + var(--sp-5)); animation: pf-fade var(--dur-base) var(--ease-out); }
|
||||
.pf-qpanel__meta{ display:flex; align-items:center; gap: var(--sp-4); margin-bottom: var(--sp-3); }
|
||||
.pf-qpanel__link{ display:inline-flex; align-items:center; gap:4px; font-size: var(--fs-xs); font-weight:600; color: var(--accent); text-decoration:none; }
|
||||
.pf-qpanel__link:hover{ color: var(--accent-deep); }
|
||||
.pf-qpanel__done{ display:inline-flex; align-items:center; gap:8px; font-size: var(--fs-sm); color: var(--pos-text); background: var(--pos-tint); border-radius: var(--radius); padding:10px 14px; }
|
||||
|
||||
.pf-compose{ display:flex; flex-direction:column; gap:8px; }
|
||||
.pf-compose__lab{ font-size: var(--fs-sm); font-weight:600; color: var(--text-strong); }
|
||||
.pf-compose__ta{ width:100%; resize:vertical; min-height:64px; background: var(--bg-surface-2); color: var(--text-strong); border:1px solid var(--border-strong); border-radius: var(--radius-sm); padding:10px 12px; font: var(--fs-sm)/1.55 var(--font-sans); transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out); }
|
||||
.pf-compose__ta::placeholder{ color: var(--neutral-400); }
|
||||
.pf-compose__ta:focus{ outline:none; border-color: var(--border-focus); box-shadow:0 0 0 3px var(--focus-ring); }
|
||||
.pf-compose__act{ display:flex; align-items:center; justify-content:space-between; gap: var(--sp-4); }
|
||||
.pf-compose__hint{ font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
|
||||
/* ── 2. 검수 대기 회기 ── */
|
||||
.pf-review{ list-style:none; background: var(--bg-surface); border:1px solid var(--border-subtle); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow:hidden; }
|
||||
.pf-review__row{ border-top:1px solid var(--paper-2); }
|
||||
.pf-review__row:first-child{ border-top:none; }
|
||||
.pf-review__link{ display:grid; grid-template-columns: 1fr 80px 130px 110px 20px; align-items:center; column-gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); text-decoration:none; transition: background var(--dur-base) var(--ease-out); }
|
||||
.pf-review__link:hover{ background: var(--accent-tint); }
|
||||
.pf-review__who{ display:flex; flex-direction:column; gap:2px; }
|
||||
.pf-review__nm{ font-size: var(--fs-sm); font-weight:600; color: var(--text-strong); }
|
||||
.pf-review__case{ font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
.pf-review__sess{ font-family: var(--font-num); font-size: var(--fs-xs); color: var(--text-body); text-align:right; }
|
||||
.pf-review__flag{ display:flex; }
|
||||
.pf-review__noflag{ font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
.pf-review__time{ font-family: var(--font-num); font-size: var(--fs-xs); color: var(--text-muted); text-align:right; }
|
||||
.pf-review__go{ display:flex; justify-content:flex-end; color: var(--text-muted); transition: color var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out); }
|
||||
.pf-review__link:hover .pf-review__go{ color: var(--accent); transform: translateX(2px); }
|
||||
|
||||
/* ── 3. 학습자 테이블 ── */
|
||||
.pf-tablewrap{ overflow-x:auto; }
|
||||
.pf-tbl{ width:100%; border-collapse:collapse; font-size: var(--fs-sm); }
|
||||
.pf-tbl thead th{ font-size: var(--fs-xs); font-weight:600; color: var(--text-muted); letter-spacing:0.02em; text-align:left; padding:0 var(--sp-3) var(--sp-3); border-bottom:1px solid var(--border-strong); white-space:nowrap; }
|
||||
.pf-tbl thead th.pf-tbl__n{ text-align:right; }
|
||||
.pf-tbl tbody td{ padding: var(--sp-4) var(--sp-3); border-bottom:1px solid var(--paper-2); vertical-align:middle; }
|
||||
.pf-tbl__row{ transition: background var(--dur-base) var(--ease-out); }
|
||||
.pf-tbl__row:hover{ background: var(--accent-tint); }
|
||||
.pf-tbl__name{ display:inline-flex; flex-direction:column; gap:1px; font-weight:600; color: var(--text-strong); text-decoration:none; white-space:nowrap; }
|
||||
.pf-tbl__name:hover{ color: var(--accent); }
|
||||
.pf-tbl__id{ font-size: 11.5px; font-weight:400; color: var(--text-muted); font-family: var(--font-num); }
|
||||
.pf-tbl__n{ text-align:right; font-family: var(--font-num); font-variant-numeric: tabular-nums; color: var(--text-body); }
|
||||
.pf-tbl__m{ text-align:right; }
|
||||
.pf-metric{ display:inline-flex; align-items:center; gap:3px; justify-content:flex-end; font-family: var(--font-num); font-variant-numeric: tabular-nums; color: var(--text-strong); }
|
||||
.pf-metric__v{ font-weight:600; }
|
||||
.pf-tbl__spark{ width:80px; }
|
||||
.pf-tbl__last{ color: var(--text-muted); font-family: var(--font-num); font-variant-numeric: tabular-nums; font-size: var(--fs-xs); white-space:nowrap; }
|
||||
.pf-tbl__state{ white-space:nowrap; }
|
||||
.pf-statetag{ display:inline-flex; align-items:center; gap:7px; font-size: var(--fs-sm); }
|
||||
.pf-statetag--ok{ color: var(--text-muted); }
|
||||
.pf-statetag--warn{ color: var(--warn-text); font-weight:500; }
|
||||
.pf-statetag--crit{ color: var(--crit-text); font-weight:600; }
|
||||
.pf-tbl__foot{ margin-top: var(--sp-4); font-size: var(--fs-xs); color: var(--text-muted); }
|
||||
|
||||
/* 추세 화살표 (charts.tsx 공통) */
|
||||
.pf-ar{ display:inline-flex; align-items:center; }
|
||||
.pf-ar--up{ color: var(--pos-solid); }
|
||||
.pf-ar--down{ color: var(--warn-solid); }
|
||||
.pf-ar--flat{ color: var(--text-muted); }
|
||||
|
||||
/* ── 4. 비교(7:5 비대칭) ── */
|
||||
.pf-compare{ display:grid; grid-template-columns: 1.4fr 1fr; gap: var(--sp-7); align-items:start; }
|
||||
.pf-compare__main{ display:flex; flex-direction:column; gap: var(--sp-7); }
|
||||
.pf-hbars{ display:flex; flex-direction:column; gap: var(--sp-5); }
|
||||
.pf-hbar__top{ display:flex; align-items:baseline; gap: var(--sp-3); margin-bottom:8px; }
|
||||
.pf-hbar__lb{ font-size: var(--fs-sm); color: var(--text-strong); font-weight:500; }
|
||||
.pf-hbar__tag{ display:inline-flex; align-items:center; gap:5px; font-size: var(--fs-xs); font-weight:600; color: var(--warn-text); }
|
||||
.pf-hbar__tag svg{ color: var(--warn-solid); }
|
||||
.pf-hbar__vv{ margin-left:auto; font-family: var(--font-num); font-variant-numeric: tabular-nums; font-size: var(--fs-xs); color: var(--text-body); font-weight:600; }
|
||||
.pf-hbar__track{ height:8px; background: var(--paper-2); border-radius:4px; overflow:hidden; }
|
||||
.pf-hbar__fill{ display:block; height:100%; border-radius:4px; background: var(--accent); transition: width var(--dur-slow) var(--ease-out); }
|
||||
.pf-hbar__fill.is-weak{ background: var(--warn-solid); }
|
||||
.pf-hbar__note{ font-size: var(--fs-xs); color: var(--text-muted); margin-top:7px; line-height:1.5; }
|
||||
|
||||
/* small-multiples */
|
||||
.pf-mm__group{ border-top:1px solid var(--hair); padding-top: var(--sp-5); }
|
||||
.pf-mm__caption{ display:block; font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: var(--sp-3); }
|
||||
.pf-mm__grid{ display:grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-4); }
|
||||
.pf-mm{ background: var(--bg-surface); border:1px solid var(--border-subtle); border-radius: var(--radius); padding: var(--sp-4); }
|
||||
.pf-mm__top{ margin-bottom:8px; }
|
||||
.pf-mm__label{ font-size: var(--fs-xs); color: var(--text-body); font-weight:500; }
|
||||
.pf-mm__chart{ width:100%; }
|
||||
.pf-mm__foot{ display:flex; align-items:baseline; gap:5px; margin-top:6px; }
|
||||
.pf-mm__val{ font-family: var(--font-num); font-variant-numeric: tabular-nums; font-size: var(--fs-h3); font-weight:700; color: var(--text-strong); letter-spacing:-0.02em; }
|
||||
|
||||
/* 인사이트 콜아웃 */
|
||||
.pf-insight{ background: var(--accent-tint); border-radius: var(--radius-lg); padding: var(--sp-6); }
|
||||
.pf-insight__kicker{ display:inline-flex; align-items:center; gap:8px; font-family: var(--font-num); font-size: var(--fs-kicker); font-weight:600; letter-spacing:0.04em; color: var(--accent-deep); margin-bottom: var(--sp-3); }
|
||||
.pf-insight__dot{ width:7px; height:7px; border-radius:50%; background: var(--accent); flex:none; }
|
||||
.pf-insight__h3{ font-size: var(--fs-lead); font-weight:600; color: var(--text-strong); line-height:1.5; letter-spacing:-0.01em; margin-bottom: var(--sp-3); }
|
||||
.pf-insight__h3 em{ font-style:normal; color: var(--accent-deep); }
|
||||
.pf-insight__p{ font-size: var(--fs-sm); color: var(--text-body); line-height:1.65; }
|
||||
.pf-insight__act{ margin-top: var(--sp-5); }
|
||||
.pf-insight__meta{ margin-top: var(--sp-4); font-size: var(--fs-xs); color: var(--text-muted); font-family: var(--font-num); }
|
||||
|
||||
@keyframes pf-fade{ from{ opacity:0; transform: translateY(-4px); } to{ opacity:1; transform:none; } }
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.pf-qpanel{ animation:none; }
|
||||
.pf-hbar__fill, .pf-qrow__go, .pf-review__go, .pf-tbl__row, .pf-qrow, .pf-review__link{ transition:none; }
|
||||
}
|
||||
@media (max-width: 980px){
|
||||
.pf-compare{ grid-template-columns: 1fr; }
|
||||
.pf-qrow{ grid-template-columns: 18px 1fr 22px; row-gap:8px; }
|
||||
.pf-qrow__why{ grid-column: 2 / 4; }
|
||||
.pf-qrow__last{ grid-column: 2 / 4; text-align:left; }
|
||||
}
|
||||
@media (max-width: 640px){
|
||||
.pf-mm__grid{ grid-template-columns: 1fr; }
|
||||
}
|
||||
`;
|
||||
Loading…
Add table
Add a link
Reference in a new issue