현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -6,8 +6,13 @@ import {
personaReviewApi,
teacherApi,
type PersonaReviewAction,
type PersonaDraftDetail,
type PersonaDraftPayload,
type PersonaReviewStatus,
type PersonaReviewSummary,
type TeacherLearnerGrowth,
type TeacherGrowthPoint,
type TeacherSafetyAlert,
type TeacherDashboardResponse,
} from "../lib/api";
@ -25,6 +30,24 @@ function formatDateTime(value: string | null): string {
});
}
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 personaReviewStatusLabel(status: PersonaReviewStatus): string {
if (status === "review") return "검수 대기";
if (status === "draft") return "수정 대기";
@ -38,6 +61,79 @@ function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" |
return "neutral";
}
const EMPTY_PERSONA_DRAFT: PersonaDraftPayload = {
code: "P4",
display_name: "새 페르소나",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: {
age_band: "F-20s",
},
presenting: {
complaint: "",
},
history: {},
big5: {
O: 0.5,
C: 0.5,
E: 0.5,
A: 0.5,
N: 0.5,
},
resistance: {
base_resistance: 0.5,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
},
speech_style: {
register: "polite",
avg_sentence_len: "medium",
fillers: [],
honorific: true,
verbal_tics: [],
},
affect_baseline: {
negative_affect: 0.45,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
anxiety: 0.35,
suicide_ideation_stage: 1,
},
ccd: {},
dsm5_dimensional: {},
source_provenance: "clinical draft",
is_synthetic: true,
submit_for_review: false,
};
function stringifyDraft(payload: PersonaDraftPayload): string {
return JSON.stringify(payload, null, 2);
}
function draftDetailToPayload(detail: PersonaDraftDetail): PersonaDraftPayload {
return {
code: detail.code,
display_name: detail.display_name,
difficulty: detail.difficulty === "easy" || detail.difficulty === "hard" ? detail.difficulty : "moderate",
theory_target: detail.theory_target,
demographics: detail.demographics,
presenting: detail.presenting,
history: detail.history,
big5: detail.big5,
resistance: detail.resistance,
speech_style: detail.speech_style,
affect_baseline: detail.affect_baseline,
ccd: detail.ccd,
dsm5_dimensional: detail.dsm5_dimensional,
source_provenance: detail.source_provenance,
is_synthetic: detail.is_synthetic,
submit_for_review: detail.status === "review",
};
}
function EmptyState({ title, desc }: { title: string; desc: string }) {
return (
<div className="pf-empty">
@ -55,6 +151,11 @@ export default function Professor() {
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
const [personaReviewBusy, setPersonaReviewBusy] = useState<string | null>(null);
const [draftJson, setDraftJson] = useState(() => stringifyDraft(EMPTY_PERSONA_DRAFT));
const [draftEditingId, setDraftEditingId] = useState<string | null>(null);
const [draftBusy, setDraftBusy] = useState<"load" | "save" | "submit" | null>(null);
const [draftError, setDraftError] = useState<string | null>(null);
const [draftMessage, setDraftMessage] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const loadDashboard = useCallback(async () => {
@ -119,6 +220,73 @@ export default function Professor() {
[],
);
const resetPersonaDraft = useCallback(() => {
setDraftJson(stringifyDraft(EMPTY_PERSONA_DRAFT));
setDraftEditingId(null);
setDraftError(null);
setDraftMessage(null);
}, []);
const parsePersonaDraft = useCallback(
(submitForReview: boolean): PersonaDraftPayload => {
const parsed = JSON.parse(draftJson) as PersonaDraftPayload;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("JSON 객체가 필요합니다.");
}
return {
...parsed,
submit_for_review: submitForReview,
};
},
[draftJson],
);
const savePersonaDraft = useCallback(
async (submitForReview: boolean) => {
setDraftBusy(submitForReview ? "submit" : "save");
setDraftError(null);
setDraftMessage(null);
try {
const payload = parsePersonaDraft(submitForReview);
const updated = draftEditingId
? await personaReviewApi.updateDraft(draftEditingId, payload)
: await personaReviewApi.createDraft(payload);
setDraftEditingId(updated.persona_id);
setDraftMessage(
updated.status === "review"
? `${updated.code} v${updated.version} 검수 요청을 올렸습니다.`
: `${updated.code} v${updated.version} 초안을 저장했습니다.`,
);
await loadPersonaReviews();
} catch (err) {
if (err instanceof SyntaxError) {
setDraftError("JSON 형식이 올바르지 않습니다.");
} else {
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 저장하지 못했습니다.");
}
} finally {
setDraftBusy(null);
}
},
[draftEditingId, loadPersonaReviews, parsePersonaDraft],
);
const loadPersonaDraft = useCallback(async (personaId: string) => {
setDraftBusy("load");
setDraftError(null);
setDraftMessage(null);
try {
const detail = await personaReviewApi.getDraft(personaId);
setDraftEditingId(detail.persona_id);
setDraftJson(stringifyDraft(draftDetailToPayload(detail)));
setDraftMessage(`${detail.code} v${detail.version} 초안을 불러왔습니다.`);
} catch (err) {
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 불러오지 못했습니다.");
} finally {
setDraftBusy(null);
}
}, []);
const kpis = useMemo(
() => [
{
@ -139,6 +307,12 @@ export default function Professor() {
hint: "저장 완료",
icon: "check" as const,
},
{
label: "위기 알림",
value: dashboard?.safety_alerts.length ?? 0,
hint: "109 확인",
icon: "alert" as const,
},
{
label: "리뷰 대기",
value: dashboard?.pending_reviews.length ?? 0,
@ -152,6 +326,8 @@ export default function Professor() {
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 ?? [];
return (
<AppShell>
@ -225,8 +401,99 @@ export default function Professor() {
</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">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={draftEditingId ? "warn" : "neutral"}>
{draftEditingId ? "편집 중" : "새 초안"}
</Badge>
</div>
<Card className="pf-panel pf-draft-panel">
<div className="pf-draft-toolbar">
<span>{draftEditingId ? "기존 초안 수정" : "새 페르소나 버전"}</span>
<Button
size="sm"
variant="secondary"
leading={<Icon name="x" size={14} />}
onClick={resetPersonaDraft}
disabled={draftBusy !== null}
>
</Button>
</div>
<textarea
className="pf-draft-json"
value={draftJson}
onChange={(event) => {
setDraftJson(event.target.value);
setDraftError(null);
setDraftMessage(null);
}}
spellCheck={false}
aria-label="페르소나 JSON 초안"
/>
{draftError ? (
<p className="pf-draft-status is-error" role="alert">
{draftError}
</p>
) : draftMessage ? (
<p className="pf-draft-status">{draftMessage}</p>
) : null}
<div className="pf-draft-actions">
<Button
size="sm"
variant="secondary"
leading={<Icon name="check" size={14} />}
onClick={() => void savePersonaDraft(false)}
disabled={draftBusy !== null}
>
{draftBusy === "save" ? "저장 중" : "초안 저장"}
</Button>
<Button
size="sm"
leading={<Icon name="review" size={14} />}
onClick={() => void savePersonaDraft(true)}
disabled={draftBusy !== null}
>
{draftBusy === "submit" ? "요청 중" : "검수 요청"}
</Button>
</div>
</Card>
</section>
<section className="pf-section">
<div className="pf-section__head">
<div>
@ -284,6 +551,15 @@ export default function Professor() {
<span>{formatDateTime(persona.created_at)}</span>
</div>
<div className="pf-persona__actions">
<Button
size="sm"
variant="secondary"
leading={<Icon name="review" size={14} />}
onClick={() => void loadPersonaDraft(persona.persona_id)}
disabled={busy || draftBusy !== null}
>
{draftBusy === "load" && draftEditingId === persona.persona_id ? "불러오는 중" : "편집"}
</Button>
<Button
size="sm"
variant="secondary"
@ -315,6 +591,33 @@ export default function Professor() {
</Card>
</section>
<section className="pf-section">
<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">
<div className="pf-section__head">
<div>
@ -422,6 +725,110 @@ export default function Professor() {
);
}
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
const recentPoints = learner.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="회기별 적절성 추이">
{learner.points.map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<div className="pf-growth-card__tags">
{learner.top_techniques.length > 0 ? (
learner.top_techniques.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:var(--maxw);
@ -526,7 +933,7 @@ const PF_CSS = `
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
grid-template-columns:repeat(3,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
@ -541,8 +948,8 @@ const PF_CSS = `
grid-template-columns:minmax(0,1fr) auto;
gap:4px 10px;
}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:1px solid var(--hair);}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
@ -609,6 +1016,213 @@ const PF_CSS = `
padding:0;
overflow:hidden;
}
.pf-draft-panel{
display:flex;
flex-direction:column;
gap:10px;
padding:12px;
}
.pf-draft-toolbar{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-width:0;
}
.pf-draft-toolbar span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-draft-json{
width:100%;
min-height:240px;
max-height:min(420px,48vh);
resize:vertical;
overflow:auto;
padding:11px 12px;
border:1px solid var(--line-strong);
border-radius:var(--radius);
background:var(--bg);
color:var(--text-strong);
font-family:var(--font-num);
font-size:11px;
line-height:1.55;
outline:none;
}
.pf-draft-json:focus{
border-color:var(--accent);
box-shadow:0 0 0 3px var(--accent-tint);
}
.pf-draft-status{
margin:0;
color:var(--accent-deep);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-draft-status.is-error{
color:var(--crit-text);
}
.pf-draft-actions{
display:flex;
justify-content:flex-end;
gap:8px;
min-width:0;
}
.pf-draft-actions .vg-btn{
min-width:84px;
}
.pf-section--growth{
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{
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(--paper-2);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.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{
color:var(--text-muted);
font-size:12px;
line-height:1.4;
}
.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;
@ -707,6 +1321,63 @@ const PF_CSS = `
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{
display:grid;
grid-template-columns:8px minmax(0,1fr);
@ -831,9 +1502,11 @@ const PF_CSS = `
.pf-kpis{
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-kpi:nth-child(even),
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:0;}
.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;
@ -851,8 +1524,12 @@ const PF_CSS = `
width:100%;
}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi:nth-child(odd){border-left:0;}
.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;
@ -911,6 +1588,27 @@ const PF_CSS = `
.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;
}