런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -935,116 +935,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</div>
</header>
<section className="lh-dashboard-hero" aria-label="AI 코칭 요약">
<article className="lh-session-focus">
<div className="lh-session-focus__head">
<div>
<Kicker>{spotlightSession ? "오늘 이어갈 회기" : "첫 회기 준비"}</Kicker>
<h2>{recapSessionTitle}</h2>
<p>{recapPersonaSubtitle}</p>
</div>
<span className="lh-session-focus__status">
{spotlightSession ? sessionStatusLabel(spotlightSession) : "대기"}
</span>
</div>
<section className="lh-recap" aria-label="마지막 세션 리캡">
<div className="lh-recap__avatar">
<ClientAvatar
persona={spotlightAvatar}
state={recapAvatarState(spotlightSession)}
affect={spotlightAffect}
rapport={spotlightSession?.stage === "정리" ? 0.72 : 0.38}
size={132}
showCaption={false}
showMeta={false}
animated={false}
/>
</div>
<div className="lh-recap__body">
<div className="lh-recap__meta-row">
<span>{spotlightSession?.persona_code ?? selected?.code ?? "P-"}</span>
<span>{spotlightSession ? `${spotlightSession.turn_count}` : "선택 전"}</span>
<span>{spotlightSession ? sessionDateLabel(spotlightSession.started_at) : "새 연습"}</span>
</div>
<dl className="lh-recap__facts">
<div>
<dt></dt>
<dd>{spotlightSession?.stage ?? "선택 전"}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightAffectLabel}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightSession ? `${spotlightSession.learner_turn_count}회 응답` : "-"}</dd>
</div>
</dl>
<div
className="lh-recap__progress"
style={{ "--lh-stage-progress": `${recapProgress}%` } as CSSProperties}
>
<div>
<span> </span>
<b>{spotlightSession ? `${recapProgress}%` : "0%"}</b>
</div>
<i aria-hidden="true" />
</div>
<div className="lh-recap__quote">
<span> </span>
<p>
{lastClientLine
? `"${truncateText(lastClientLine)}"`
: recapLoadState === "loading"
? "상세 발화를 확인하고 있습니다."
: "아직 표시할 내담자 발화가 없습니다."}
</p>
</div>
<div className="lh-recap__learner">
<span> </span>
<p>{lastLearnerLine ? truncateText(lastLearnerLine, 118) : "아직 기록된 학습자 발화가 없습니다."}</p>
</div>
</div>
</section>
</article>
<aside className="lh-coach-card" aria-label="AI 코치">
<div>
<Kicker>AI </Kicker>
<h2>{coach.title}</h2>
<p>{coach.body}</p>
</div>
<div className="lh-coach-hints" aria-label="코칭 힌트">
{coachHints.map((hint) => (
<span key={hint}>
<Icon name="check" size={14} />
{hint}
</span>
))}
</div>
<p className="lh-recap__coach">{recapCoachLine(spotlightSession)}</p>
<Button
className="lh-coach-card__cta"
size="lg"
block
onClick={goPrimaryAction}
trailing={<Icon name="chevron-right" size={18} />}
>
{coach.action}
</Button>
</aside>
</section>
<section className="lh-metric-grid" aria-label="학습 현황">
<section className="lh-dashboard-status" aria-label="학습 상태 바로가기">
{dashboardMetrics.map((metric) => (
<button
type="button"
@ -1059,103 +950,247 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
))}
</section>
<section className="lh-dashboard-columns">
<article className="lh-panel">
<div className="lh-panel__head">
<section className="lh-dashboard-grid" aria-label="오늘 학습 대시보드">
<section className="lh-work-cluster" aria-label="오늘의 회기 작업">
<div className="lh-work-cluster__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
<Kicker> </Kicker>
<h2>, , .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
</div>
{renderCompactSessionRows(reviewQueue, "대기 중인 리뷰가 없습니다.", "review")}
<div className="lh-feedback-mini" aria-label="최근 피드백">
<div className="lh-feedback-mini__head">
<Kicker> </Kicker>
<span>
{dashboardReady
? `${recentFeedback.length}`
: dashboardLoadState === "error"
? "불러오기 실패"
: "확인 중"}
</span>
</div>
{dashboardLoadState === "loading" ? (
<p> .</p>
) : dashboardLoadState === "error" ? (
<p> .</p>
) : recentFeedback.length > 0 ? (
<button
type="button"
className="lh-feedback-mini__row"
onClick={() => navigate(`/learn/session/${recentFeedback[0].session_id}/review`)}
>
<b>
{recentFeedback[0].persona_code} · {recentFeedback[0].stage} ·{" "}
{scoreLabel(recentFeedback[0].score)}
</b>
<span>{truncateText(recentFeedback[0].note, 86)}</span>
</button>
) : (
<p> .</p>
)}
</div>
</article>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2>{reviewCount > 0 ? "리뷰 확인을 우선합니다." : "연습 목표를 좁힙니다."}</h2>
</div>
<span className="lh-panel__badge"> </span>
</div>
<div className="lh-recommend-card">
<span>{reviewCount > 0 ? "AI 피드백 먼저" : "개인 연습"}</span>
<b>
{reviewCount > 0
? `${reviewCount}건의 회기 피드백 확인`
: recentAverageTurns != null && recentAverageTurns < 4
? "탐색 질문 늘리기"
: "난도 확장 검토"}
</b>
<p>
{reviewCount > 0
? "새 회기를 시작하기 전에 이미 끝난 대화의 반응과 대안 발화를 확인하세요."
: "다음 회기에서는 감정 반영 뒤 무엇을 더 물을지 한 문장으로 정하고 들어갑니다."}
</p>
<button
type="button"
onClick={() => navigate(reviewCount > 0 ? "/learn/history" : "/learn/practice")}
<Button
variant="secondary"
size="sm"
onClick={() => navigate("/learn/practice")}
trailing={<Icon name="chevron-right" size={15} />}
>
{reviewCount > 0 ? "리뷰 확인하기" : "연습 시작하기"}
<Icon name="chevron-right" size={15} />
</button>
</Button>
</div>
<div className="lh-insight-list">
<div>
<span> </span>
<b>{topPersona?.total ? `${topPersona.name} · ${topPersona.total}` : "아직 없음"}</b>
<p> .</p>
</div>
</div>
</article>
<div className="lh-work-cluster__primary">
<article className="lh-session-focus">
<div className="lh-session-focus__head">
<div>
<Kicker>{spotlightSession ? "오늘 이어갈 회기" : "첫 회기 준비"}</Kicker>
<h2>{recapSessionTitle}</h2>
<p>{recapPersonaSubtitle}</p>
</div>
<span className="lh-session-focus__status">
{spotlightSession ? sessionStatusLabel(spotlightSession) : "대기"}
</span>
</div>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
<section className="lh-recap" aria-label="마지막 세션 리캡">
<div className="lh-recap__avatar">
<ClientAvatar
persona={spotlightAvatar}
state={recapAvatarState(spotlightSession)}
affect={spotlightAffect}
rapport={spotlightSession?.stage === "정리" ? 0.72 : 0.38}
size={132}
showCaption={false}
showMeta={false}
animated={false}
/>
</div>
<div className="lh-recap__body">
<div className="lh-recap__meta-row">
<span>{spotlightSession?.persona_code ?? selected?.code ?? "P-"}</span>
<span>{spotlightSession ? `${spotlightSession.turn_count}` : "선택 전"}</span>
<span>{spotlightSession ? sessionDateLabel(spotlightSession.started_at) : "새 연습"}</span>
</div>
<dl className="lh-recap__facts">
<div>
<dt></dt>
<dd>{spotlightSession?.stage ?? "선택 전"}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightAffectLabel}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightSession ? `${spotlightSession.learner_turn_count}회 응답` : "-"}</dd>
</div>
</dl>
<div
className="lh-recap__progress"
style={{ "--lh-stage-progress": `${recapProgress}%` } as CSSProperties}
>
<div>
<span> </span>
<b>{spotlightSession ? `${recapProgress}%` : "0%"}</b>
</div>
<i aria-hidden="true" />
</div>
<div className="lh-recap__quote">
<span> </span>
<p>
{lastClientLine
? `"${truncateText(lastClientLine)}"`
: recapLoadState === "loading"
? "상세 발화를 확인하고 있습니다."
: "아직 표시할 내담자 발화가 없습니다."}
</p>
</div>
<div className="lh-recap__learner">
<span> </span>
<p>{lastLearnerLine ? truncateText(lastLearnerLine, 118) : "아직 기록된 학습자 발화가 없습니다."}</p>
</div>
</div>
</section>
</article>
<aside className="lh-coach-card" aria-label="AI 코치">
<div>
<Kicker>AI </Kicker>
<h2>{coach.title}</h2>
<p>{coach.body}</p>
</div>
<div className="lh-coach-hints" aria-label="코칭 힌트">
{coachHints.map((hint) => (
<span key={hint}>
<Icon name="check" size={14} />
{hint}
</span>
))}
</div>
<p className="lh-recap__coach">{recapCoachLine(spotlightSession)}</p>
<Button
className="lh-coach-card__cta"
size="lg"
block
onClick={goPrimaryAction}
trailing={<Icon name="chevron-right" size={18} />}
>
{coach.action}
</Button>
</aside>
</div>
{renderCompactSessionRows(recentRecordRows, "최근 기록이 없습니다.", "recent")}
</article>
<div className="lh-work-cluster__secondary">
<article className="lh-panel lh-dashboard-recommend">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2>{reviewCount > 0 ? "리뷰 확인을 우선합니다." : "연습 목표를 좁힙니다."}</h2>
</div>
<span className="lh-panel__badge"> </span>
</div>
<div className="lh-recommend-card">
<span>{reviewCount > 0 ? "AI 피드백 먼저" : "개인 연습"}</span>
<b>
{reviewCount > 0
? `${reviewCount}건의 회기 피드백 확인`
: recentAverageTurns != null && recentAverageTurns < 4
? "탐색 질문 늘리기"
: "난도 확장 검토"}
</b>
<p>
{reviewCount > 0
? "새 회기를 시작하기 전에 이미 끝난 대화의 반응과 대안 발화를 확인하세요."
: "다음 회기에서는 감정 반영 뒤 무엇을 더 물을지 한 문장으로 정하고 들어갑니다."}
</p>
<button
type="button"
onClick={() => navigate(reviewCount > 0 ? "/learn/history" : "/learn/practice")}
>
{reviewCount > 0 ? "리뷰 확인하기" : "연습 시작하기"}
<Icon name="chevron-right" size={15} />
</button>
</div>
</article>
<article className="lh-panel lh-dashboard-feedback">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<span className="lh-panel__badge">
{dashboardReady
? `${recentFeedback.length}`
: dashboardLoadState === "error"
? "불러오기 실패"
: "확인 중"}
</span>
</div>
<div className="lh-feedback-mini" aria-label="최근 피드백">
{dashboardLoadState === "loading" ? (
<p> .</p>
) : dashboardLoadState === "error" ? (
<p> .</p>
) : recentFeedback.length > 0 ? (
<button
type="button"
className="lh-feedback-mini__row"
onClick={() => navigate(`/learn/session/${recentFeedback[0].session_id}/review`)}
>
<b>
{recentFeedback[0].persona_code} · {recentFeedback[0].stage} ·{" "}
{scoreLabel(recentFeedback[0].score)}
</b>
<span>{truncateText(recentFeedback[0].note, 86)}</span>
</button>
) : (
<p> .</p>
)}
</div>
</article>
</div>
</section>
<aside className="lh-dashboard-side" aria-label="최근 기록 패널">
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
</div>
{renderCompactSessionRows(recentRecordRows, "최근 기록이 없습니다.", "recent")}
</article>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
</div>
{renderCompactSessionRows(reviewQueue, "대기 중인 리뷰가 없습니다.", "review")}
</article>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2>{topPersona?.total ? `${topPersona.name} · ${topPersona.total}` : "아직 없음"}</h2>
</div>
</div>
<div className="lh-insight-list">
<div>
<span> </span>
<b>{topPersona?.total ? "주호소 범위를 넓힐 차례" : "첫 회기 이후 표시"}</b>
<p> .</p>
</div>
</div>
</article>
</aside>
</section>
</>
) : null}