feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -52,6 +52,36 @@ import "./learner-home.css";
|
|||
type LoadState = "loading" | "ready" | "error";
|
||||
export type LearnerHomeView = "dashboard" | "practice" | "history";
|
||||
|
||||
// ── D3(가운뎃점 라인당 1개) 보조 헬퍼 ────────────────────────────────────────
|
||||
/**
|
||||
* 페르소나 display_name 은 "이름(가명) · 학년 · 주호소" 형태라 한 줄에
|
||||
* 가운뎃점이 2개 찍힌다. 앞 두 조각만 제목 줄에 남기고 나머지(주호소)는
|
||||
* 아래 줄로 내려 한 줄에 가운뎃점이 1개만 보이게 한다.
|
||||
*/
|
||||
function splitPersonaTitle(name: string): { head: string; tail: string } {
|
||||
const parts = name
|
||||
.split("·")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length <= 2) return { head: name.trim(), tail: "" };
|
||||
return {
|
||||
head: parts.slice(0, 2).join(" · "),
|
||||
tail: parts.slice(2).join(" · "),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 난도·이론·인적정보처럼 값이 여러 개인 메타 문자열에서 두 번째 이후
|
||||
* 가운뎃점을 쉼표로 낮춘다. 가운뎃점은 "큰 구분", 쉼표는 "같은 종류의 나열"로
|
||||
* 역할을 나눠 한 줄에 가운뎃점이 1개만 남게 한다.
|
||||
* 예) "기초 · cbt · humanistic" → "기초 · cbt, humanistic"
|
||||
*/
|
||||
function oneMiddot(text: string): string {
|
||||
const parts = text.split(" · ");
|
||||
if (parts.length <= 2) return text;
|
||||
return `${parts[0]} · ${parts.slice(1).join(", ")}`;
|
||||
}
|
||||
|
||||
interface LearnerHomeProps {
|
||||
view?: LearnerHomeView;
|
||||
}
|
||||
|
|
@ -582,11 +612,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
spotlightPersona?.display_name ??
|
||||
spotlightSession?.persona_name ??
|
||||
"첫 연습 대상 선택";
|
||||
// D3: 난도 + 이론 목록이 겹쳐 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
|
||||
const recapPersonaSubtitle = spotlightPersona
|
||||
? `${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}`
|
||||
? oneMiddot(
|
||||
`${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}`,
|
||||
)
|
||||
: spotlightSession
|
||||
? `${spotlightSession.persona_code} · ${sessionStatusLabel(spotlightSession)}`
|
||||
: "학습 대상에서 내담자를 선택하세요";
|
||||
// D3: 페르소나 제목은 "이름 · 학년"만 제목 줄에 두고 주호소는 아랫줄로 내린다.
|
||||
const recapTitleParts = splitPersonaTitle(recapSessionTitle);
|
||||
const previewTitleParts = splitPersonaTitle(selected?.display_name ?? "");
|
||||
const recapProgress = stageProgressValue(spotlightSession?.stage);
|
||||
const coachHints = spotlightSession
|
||||
? [
|
||||
|
|
@ -683,9 +719,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<span>{session.persona_code}</span>
|
||||
<b>{shortPersonaName(session.persona_name)}</b>
|
||||
</div>
|
||||
{/* D3: "단계 · 턴 · 날짜"로 가운뎃점이 2개였다. 날짜는 별도 컬럼으로 분리 */}
|
||||
<p>
|
||||
{session.stage} 단계 · {session.turn_count}턴 ·{" "}
|
||||
{sessionDateLabel(session.started_at)}
|
||||
<span>
|
||||
{session.stage} 단계 · {session.turn_count}턴
|
||||
</span>
|
||||
<span>{sessionDateLabel(session.started_at)}</span>
|
||||
</p>
|
||||
<span className="lh-session-card__progress" aria-hidden="true">
|
||||
<span style={{ width: `${progress}%` }} />
|
||||
|
|
@ -780,9 +819,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
</span>
|
||||
<span className="lh-compact-list__body">
|
||||
<b>{shortPersonaName(session.persona_name)}</b>
|
||||
{/* D3: 가운뎃점 2개였던 줄 — 날짜를 오른쪽 컬럼으로 분리 */}
|
||||
<small>
|
||||
{session.persona_code} · {session.stage} ·{" "}
|
||||
{sessionDateLabel(session.started_at)}
|
||||
<span>
|
||||
{session.persona_code} · {session.stage}
|
||||
</span>
|
||||
<span>{sessionDateLabel(session.started_at)}</span>
|
||||
</small>
|
||||
</span>
|
||||
{mode === "review" ? (
|
||||
|
|
@ -851,10 +893,14 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
const usablePersona = isUsablePersona(persona);
|
||||
const selectedPersona =
|
||||
usablePersona && persona.code === selected?.code;
|
||||
const personaMeta =
|
||||
// D3: 이론이 2개면 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
|
||||
const personaMeta = oneMiddot(
|
||||
(DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) +
|
||||
" · " +
|
||||
theoryLine(persona);
|
||||
" · " +
|
||||
theoryLine(persona),
|
||||
);
|
||||
// D3: 카드 이름줄도 "이름 · 학년"까지만 두고 주호소는 아랫줄로 내린다.
|
||||
const personaTitle = splitPersonaTitle(persona.display_name);
|
||||
return (
|
||||
<button
|
||||
key={persona.code}
|
||||
|
|
@ -874,12 +920,19 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<span className="lh-persona__mark">{persona.code}</span>
|
||||
<span className="lh-persona__body">
|
||||
<span className="lh-persona__name">
|
||||
{persona.display_name}
|
||||
{personaTitle.head}
|
||||
</span>
|
||||
{personaTitle.tail ? (
|
||||
<span className="lh-persona__concern">
|
||||
{personaTitle.tail}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="lh-persona__meta">
|
||||
{usablePersona
|
||||
? personaMeta
|
||||
: `${personaMeta} · 사용 불가`}
|
||||
<span>{personaMeta}</span>
|
||||
{/* D3: "· 사용 불가"를 이어 붙이면 가운뎃점이 늘어나 별도 칩으로 분리 */}
|
||||
{usablePersona ? null : (
|
||||
<b className="lh-persona__flag">사용 불가</b>
|
||||
)}
|
||||
</span>
|
||||
<span className="lh-persona__summary">
|
||||
{usablePersona
|
||||
|
|
@ -913,7 +966,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<>
|
||||
<header className="lh-head">
|
||||
<div className="lh-head__copy">
|
||||
<Kicker>학습 대시보드</Kicker>
|
||||
{/* D1: 단순 섹션 라벨이라 상태 dot 제거(대시보드 화면 dot 밀집 정리) */}
|
||||
<Kicker dot={false}>학습 대시보드</Kicker>
|
||||
<h1 className="lh-title">오늘 이어갈 회기를 먼저 봅니다.</h1>
|
||||
<p className="lh-sub">
|
||||
진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서
|
||||
|
|
@ -992,7 +1046,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
>
|
||||
<div className="lh-work-cluster__head">
|
||||
<div>
|
||||
<Kicker>오늘의 회기 작업</Kicker>
|
||||
{/* D1: 섹션 라벨 — 상태가 아니므로 dot 제거 */}
|
||||
<Kicker dot={false}>오늘의 회기 작업</Kicker>
|
||||
<h2>이어가기, 코칭, 다음 연습을 한 흐름으로 봅니다.</h2>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -1013,12 +1068,20 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
>
|
||||
<div className="lh-session-focus__head">
|
||||
<div>
|
||||
<Kicker>
|
||||
{/* D1: 실제 진행 상태는 우측 status 배지가 맡으므로 dot 제거 */}
|
||||
<Kicker dot={false}>
|
||||
{spotlightSession
|
||||
? "오늘 이어갈 회기"
|
||||
: "첫 회기 준비"}
|
||||
</Kicker>
|
||||
<h2>{recapSessionTitle}</h2>
|
||||
<h2>
|
||||
{recapTitleParts.head}
|
||||
{recapTitleParts.tail ? (
|
||||
<span className="lh-title-tail">
|
||||
{recapTitleParts.tail}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<p>{recapPersonaSubtitle}</p>
|
||||
</div>
|
||||
<span className="lh-session-focus__status">
|
||||
|
|
@ -1149,7 +1212,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
aria-label="AI 코치"
|
||||
>
|
||||
<div>
|
||||
<Kicker>AI 코치</Kicker>
|
||||
{/* D1: 패널 이름표 — 상태가 아니므로 dot 제거 */}
|
||||
<Kicker dot={false}>AI 코치</Kicker>
|
||||
<h2>{coach.title}</h2>
|
||||
<p>{coach.body}</p>
|
||||
</div>
|
||||
|
|
@ -1194,7 +1258,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
>
|
||||
<div className="lh-panel__head">
|
||||
<div>
|
||||
<Kicker>다음 연습 추천</Kicker>
|
||||
{/* D1: 섹션 라벨 — dot 제거 */}
|
||||
<Kicker dot={false}>다음 연습 추천</Kicker>
|
||||
<h2>
|
||||
{reviewCount > 0
|
||||
? "리뷰 확인을 우선합니다."
|
||||
|
|
@ -1248,7 +1313,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
>
|
||||
<div className="lh-panel__head">
|
||||
<div>
|
||||
<Kicker>최근 피드백</Kicker>
|
||||
{/* D1: 섹션 라벨 — 건수 상태는 우측 badge 가 표시한다 */}
|
||||
<Kicker dot={false}>최근 피드백</Kicker>
|
||||
<h2>다음 회기에 반영할 근거입니다.</h2>
|
||||
</div>
|
||||
<span className="lh-panel__badge">
|
||||
|
|
@ -1277,10 +1343,13 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
)
|
||||
}
|
||||
>
|
||||
{/* D3: 가운뎃점 2개였던 줄 — 평가 점수는 오른쪽 컬럼으로 분리 */}
|
||||
<b>
|
||||
{recentFeedback[0].persona_code} ·{" "}
|
||||
{recentFeedback[0].stage} ·{" "}
|
||||
{scoreLabel(recentFeedback[0].score)}
|
||||
<span>
|
||||
{recentFeedback[0].persona_code} ·{" "}
|
||||
{recentFeedback[0].stage}
|
||||
</span>
|
||||
<span>{scoreLabel(recentFeedback[0].score)}</span>
|
||||
</b>
|
||||
<span>
|
||||
{truncateText(recentFeedback[0].note, 86)}
|
||||
|
|
@ -1310,7 +1379,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
flat: true,
|
||||
})}
|
||||
>
|
||||
<Kicker>최근 기록</Kicker>
|
||||
{/* D1: 섹션 라벨 — dot 제거 */}
|
||||
<Kicker dot={false}>최근 기록</Kicker>
|
||||
<h2>방금 끝낸 흐름입니다.</h2>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -1337,7 +1407,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
flat: true,
|
||||
})}
|
||||
>
|
||||
<Kicker>리뷰 대기</Kicker>
|
||||
{/* D4: eyebrow "리뷰 대기"는 바로 아래 제목과 같은 말이라 삭제 */}
|
||||
<h2>피드백 받을 기록입니다.</h2>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -1362,6 +1432,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
>
|
||||
<div className="lh-panel__head">
|
||||
<div>
|
||||
{/* D1: 평가 기록에서 파생된 "현재 신호"라 dot 유지 */}
|
||||
<Kicker>보완할 부분</Kicker>
|
||||
<h2>평가 기록에서 찾은 신호입니다.</h2>
|
||||
</div>
|
||||
|
|
@ -1391,7 +1462,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
flat: true,
|
||||
})}
|
||||
>
|
||||
<Kicker>내담자와 쌓은 라포</Kicker>
|
||||
{/* D1: 섹션 라벨 — dot 제거 */}
|
||||
<Kicker dot={false}>내담자와 쌓은 라포</Kicker>
|
||||
<h2>회기를 건너 누적됩니다.</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1452,7 +1524,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
>
|
||||
<div className="lh-panel__head">
|
||||
<div>
|
||||
<Kicker>연습 이정표</Kicker>
|
||||
{/* D1: 섹션 라벨 — dot 제거 */}
|
||||
<Kicker dot={false}>연습 이정표</Kicker>
|
||||
<h2>실제 연습 기준으로 열립니다.</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1484,7 +1557,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<>
|
||||
<header className="lh-head">
|
||||
<div className="lh-head__copy">
|
||||
<Kicker>학습 대상</Kicker>
|
||||
{/* D4: eyebrow "학습 대상"은 아래 제목("연습할 내담자")과 같은 말이라 삭제 */}
|
||||
<h1 className="lh-title">연습할 내담자를 선택합니다.</h1>
|
||||
<p className="lh-sub">
|
||||
대상별 주호소와 난도를 확인한 뒤 새 회기를 시작합니다. 기존
|
||||
|
|
@ -1529,15 +1602,26 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
: (selected?.code ?? "...")}
|
||||
</span>
|
||||
<h2 id="lh-preview-title">
|
||||
{personaCatalogUnavailable
|
||||
? "사용 가능한 내담자가 없습니다."
|
||||
: (selected?.display_name ??
|
||||
"내담자 정보를 불러오는 중")}
|
||||
{personaCatalogUnavailable ? (
|
||||
"사용 가능한 내담자가 없습니다."
|
||||
) : selected ? (
|
||||
<>
|
||||
{previewTitleParts.head}
|
||||
{previewTitleParts.tail ? (
|
||||
<span className="lh-title-tail">
|
||||
{previewTitleParts.tail}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
"내담자 정보를 불러오는 중"
|
||||
)}
|
||||
</h2>
|
||||
{/* D3: 인적 정보도 가운뎃점 1개만 남기고 나머지는 쉼표 나열 */}
|
||||
<p>
|
||||
{personaCatalogUnavailable
|
||||
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
|
||||
: demographicsLine(selected)}
|
||||
: oneMiddot(demographicsLine(selected))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1660,7 +1744,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<>
|
||||
<header className="lh-head">
|
||||
<div className="lh-head__copy">
|
||||
<Kicker>회기 기록</Kicker>
|
||||
{/* D4: eyebrow "회기 기록"은 아래 제목과 같은 말이라 삭제 */}
|
||||
<h1 className="lh-title">회기 기록을 찾고 정리합니다.</h1>
|
||||
<p className="lh-sub">
|
||||
이어갈 회기, 피드백 받을 회기, 보관된 회기를 분리해 다음
|
||||
|
|
@ -1702,7 +1786,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
>
|
||||
<div className="lh-history-main__head">
|
||||
<div>
|
||||
<Kicker>기록 목록</Kicker>
|
||||
{/* D4: eyebrow "기록 목록"은 아래 제목("전체 회기"/필터명)과 겹쳐 삭제 */}
|
||||
<h2>
|
||||
{historyFilter === "all"
|
||||
? "전체 회기"
|
||||
|
|
@ -1833,12 +1917,21 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
<b>{row.code}</b>
|
||||
<em>{row.name}</em>
|
||||
</span>
|
||||
{/* D3: 가운뎃점 3개였던 줄 — 진행/리뷰 건수를 별도 컬럼으로 쪼갠다 */}
|
||||
<span className="lh-persona-progress__meta">
|
||||
{row.total > 0
|
||||
? `${row.total}회 · ${row.active}회 진행 · ${row.reviewReady}회 리뷰 · 평균 ${
|
||||
row.averageTurns ?? 0
|
||||
}턴`
|
||||
: "아직 진행 없음"}
|
||||
{row.total > 0 ? (
|
||||
<>
|
||||
<span>
|
||||
{row.total}회 · 평균{" "}
|
||||
{row.averageTurns ?? 0}턴
|
||||
</span>
|
||||
<span>
|
||||
진행 {row.active} · 리뷰 {row.reviewReady}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>아직 진행 없음</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="lh-persona-progress__bar"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue