런타임 계약과 학습자 흐름 보강
This commit is contained in:
parent
f456b8997a
commit
206018b088
56 changed files with 4306 additions and 1008 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue