vignette/apps/web/src/pages/LearnerHome.tsx
2026-08-09 22:00:32 +09:00

2163 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import {
ClientAvatar,
expressionLabelFor,
} from "../components/avatar/ClientAvatar";
import { Button, Icon, Kicker, surfaceClassName } from "../components/ui";
import {
personaApi,
sessionApi,
type LearnerDashboardPersonaProgress,
type LearnerDashboardResponse,
type LearnerSessionSummary,
type PersonaSummary,
type SessionDetailResponse,
} from "../lib/api";
import { displayPiiSafeText } from "../lib/piiDisplay";
import {
DIFFICULTY_LABEL,
isUsablePersona,
shortPersonaName,
unavailablePersonaMessage,
} from "../lib/personaViewModel";
import {
parseVoicePracticeContext,
voicePracticeSearch,
VOICE_SCENE_LABEL,
} from "../lib/voicePracticeContext";
import {
parsePracticeLaunchIntent,
practiceCriterionLabel,
practiceLaunchSearch,
PRACTICE_SOURCE_SESSION_LABEL,
PRACTICE_MODE_LABEL,
PRACTICE_NOVELTY_LABEL,
} from "../lib/practiceLaunchIntent";
import {
HISTORY_FILTERS,
averageLearnerTurns,
buildDashboardAvatar,
demographicsLine,
difficultyLine,
historyBucket,
historyBucketLabel,
historyEmptyMessage,
lastTurnText,
personaInitial,
rapportLabel,
recapAvatarState,
recapCoachLine,
recapExpressionFor,
scoreLabel,
sessionDateLabel,
sessionSortTime,
sessionStatusLabel,
stageProgressValue,
starterPersona,
theoryLine,
trendLabel,
truncateText,
type HistoryFilter,
} from "./learner-home/model";
import "./learner-home.css";
type LoadState = "loading" | "ready" | "error";
export type LearnerHomeView = "dashboard" | "practice" | "history";
const DASHBOARD_TABS = [
{ id: "today", label: "오늘의 회기" },
{ id: "records", label: "기록 · 리뷰" },
{ id: "growth", label: "성장 지표" },
] as const;
type DashboardTab = (typeof DASHBOARD_TABS)[number]["id"];
// ── 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;
}
export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const voicePracticeRequested = view === "practice" && searchParams.get("mode") === "voice";
const voicePracticeContext = useMemo(
() => view === "practice" ? parseVoicePracticeContext(searchParams) : null,
[searchParams, view],
);
const practiceLaunchRequested = view === "practice" && searchParams.has("launch");
const practiceLaunchIntent = useMemo(
() => view === "practice" ? parsePracticeLaunchIntent(searchParams) : null,
[searchParams, view],
);
const [personas, setPersonas] = useState<PersonaSummary[]>([]);
const [sessions, setSessions] = useState<LearnerSessionSummary[]>([]);
const [dashboard, setDashboard] = useState<LearnerDashboardResponse | null>(
null,
);
const [selectedCode, setSelectedCode] = useState("");
const [loadState, setLoadState] = useState<LoadState>("loading");
const [sessionLoadState, setSessionLoadState] =
useState<LoadState>("loading");
const [dashboardLoadState, setDashboardLoadState] =
useState<LoadState>("loading");
const [recapDetail, setRecapDetail] = useState<SessionDetailResponse | null>(
null,
);
const [recapLoadState, setRecapLoadState] = useState<LoadState>("loading");
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>("all");
const [historyQuery, setHistoryQuery] = useState("");
const [archiveBusyId, setArchiveBusyId] = useState<string | null>(null);
const [archiveNotice, setArchiveNotice] = useState<string | null>(null);
// 중간 폭(≤1180px)에서는 대시보드 섹션을 상단 가로 탭으로 전환한다(2026-07-15 소유자 피드백).
// 데스크톱(>1180px)은 2컬럼 그리드 유지 — 탭 바는 CSS로 숨겨지고 이 상태는 무시된다.
const [dashTab, setDashTab] = useState<DashboardTab>("today");
const dashTabRefs = useRef<Array<HTMLButtonElement | null>>([]);
function handleDashTabKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
currentIndex: number,
) {
let nextIndex: number | null = null;
if (event.key === "ArrowRight") {
nextIndex = (currentIndex + 1) % DASHBOARD_TABS.length;
} else if (event.key === "ArrowLeft") {
nextIndex =
(currentIndex - 1 + DASHBOARD_TABS.length) % DASHBOARD_TABS.length;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = DASHBOARD_TABS.length - 1;
}
if (nextIndex === null) return;
event.preventDefault();
setDashTab(DASHBOARD_TABS[nextIndex].id);
dashTabRefs.current[nextIndex]?.focus();
}
useEffect(() => {
let alive = true;
(async () => {
setLoadState("loading");
setSessionLoadState("loading");
setDashboardLoadState("loading");
const [personaResult, sessionResult, dashboardResult] =
await Promise.allSettled([
personaApi.list(),
sessionApi.list(),
sessionApi.dashboard(),
]);
if (!alive) return;
if (personaResult.status === "fulfilled") {
const personaRows = personaResult.value;
// 결정 D3: 회기 기록이 0건인 신규 학습자는 난도 오름차순(기초 우선) 첫
// 사용 가능 페르소나를 기본 선택한다. 기록이 있거나 기록 조회에 실패하면
// 기존 규칙(API 순서 첫 사용 가능 페르소나)을 유지한다.
const hasNoSessionRecords =
sessionResult.status === "fulfilled" &&
(sessionResult.value.sessions ?? []).length === 0;
const firstUsableCode = hasNoSessionRecords
? (starterPersona(personaRows)?.code ?? "")
: (personaRows.find(isUsablePersona)?.code ?? "");
setPersonas(personaRows);
setSelectedCode((cur) =>
personaRows.some(
(persona) => persona.code === cur && isUsablePersona(persona),
)
? cur
: firstUsableCode,
);
setLoadState("ready");
} else {
console.warn(
"[learner-home] failed to load personas",
personaResult.reason,
);
setPersonas([]);
setLoadState("error");
}
if (sessionResult.status === "fulfilled") {
setSessions(sessionResult.value.sessions ?? []);
setSessionLoadState("ready");
} else {
console.warn(
"[learner-home] failed to load sessions",
sessionResult.reason,
);
setSessions([]);
setSessionLoadState("error");
}
if (dashboardResult.status === "fulfilled") {
setDashboard(dashboardResult.value);
setDashboardLoadState("ready");
} else {
console.warn(
"[learner-home] failed to load dashboard",
dashboardResult.reason,
);
setDashboard(null);
setDashboardLoadState("error");
}
})();
return () => {
alive = false;
};
}, []);
const linkedSourceSessionId =
practiceLaunchIntent?.sourceSessionId ?? voicePracticeContext?.sourceSessionId ?? null;
useEffect(() => {
if (!linkedSourceSessionId || sessionLoadState !== "ready") return;
const sourceSession = sessions.find(
(session) => session.session_id === linkedSourceSessionId,
);
if (!sourceSession) return;
const matchingPersona = personas.find(
(persona) => persona.code === sourceSession.persona_code && isUsablePersona(persona),
);
if (matchingPersona) setSelectedCode(matchingPersona.code);
}, [linkedSourceSessionId, personas, sessionLoadState, sessions]);
const usablePersonas = useMemo(
() => personas.filter(isUsablePersona),
[personas],
);
const selected = useMemo(
() =>
usablePersonas.find((p) => p.code === selectedCode) ??
usablePersonas[0] ??
null,
[selectedCode, usablePersonas],
);
const sortedSessions = useMemo(
() => [...sessions].sort((a, b) => sessionSortTime(b) - sessionSortTime(a)),
[sessions],
);
const recentSessions = useMemo(
() => sortedSessions.slice(0, 6),
[sortedSessions],
);
const selectedSessions = useMemo(
() =>
sortedSessions
.filter((session) => session.persona_code === selected?.code)
.slice(0, 5),
[selected?.code, sortedSessions],
);
const sessionActiveCount = useMemo(
() => sessions.filter((session) => session.status === "active").length,
[sessions],
);
const sessionReviewCount = useMemo(
() =>
sessions.filter(
(session) =>
session.status === "ended" &&
session.review_ready &&
!session.archived,
).length,
[sessions],
);
const sessionCompletedCount = useMemo(
() => sessions.filter((session) => session.status === "ended").length,
[sessions],
);
const sessionArchivedCount = useMemo(
() => sessions.filter((session) => session.archived).length,
[sessions],
);
const dashboardReady = dashboardLoadState === "ready" && dashboard != null;
const overview = dashboard?.overview;
const growth = dashboard?.growth;
const activeCount = overview?.active_sessions ?? sessionActiveCount;
const reviewCount = overview?.review_ready_sessions ?? sessionReviewCount;
const completedCount = overview?.completed_sessions ?? sessionCompletedCount;
const totalCount = overview?.total_sessions ?? sessions.length;
const recentFeedback = dashboard?.recent_feedback ?? [];
const historyReady = sessionLoadState === "ready";
const dashboardStatus =
dashboardLoadState === "ready"
? `${totalCount}`
: dashboardLoadState === "error"
? "대시보드 오류"
: "대시보드 확인 중";
const historyStatus =
sessionLoadState === "ready"
? `${sessions.length}`
: sessionLoadState === "error"
? "기록 오류"
: "기록 확인 중";
const recentAverageTurns = useMemo(
() => averageLearnerTurns(recentSessions),
[recentSessions],
);
const historyFilterCounts = useMemo(
() => ({
all: sessions.length,
active: sessionActiveCount,
review: sessionReviewCount,
archived: sessionArchivedCount,
}),
[
sessionActiveCount,
sessionArchivedCount,
sessionReviewCount,
sessions.length,
],
);
const filteredHistorySessions = useMemo(() => {
const query = historyQuery.trim().toLocaleLowerCase("ko-KR");
return sortedSessions.filter((session) => {
const bucket = historyBucket(session);
if (historyFilter !== "all" && bucket !== historyFilter) return false;
if (!query) return true;
const searchable = [
session.persona_code,
session.persona_name,
session.stage,
sessionStatusLabel(session),
historyBucketLabel(session),
sessionDateLabel(session.started_at),
]
.join(" ")
.toLocaleLowerCase("ko-KR");
return searchable.includes(query);
});
}, [historyFilter, historyQuery, sortedSessions]);
const nextActiveSession = useMemo(
() => sortedSessions.find((session) => session.status === "active") ?? null,
[sortedSessions],
);
const nextReviewSession = useMemo(
() =>
sortedSessions.find(
(session) => session.review_ready && !session.archived,
) ?? null,
[sortedSessions],
);
const spotlightSession =
nextActiveSession ?? nextReviewSession ?? recentSessions[0] ?? null;
const personaProgressRows = useMemo(() => {
const dashboardProgress = new Map<
string,
LearnerDashboardPersonaProgress
>();
for (const row of dashboard?.persona_progress ?? []) {
dashboardProgress.set(row.persona_code, row);
}
const rows = usablePersonas.map((persona) => {
const progress = dashboardProgress.get(persona.code);
const related = sortedSessions.filter(
(session) => session.persona_code === persona.code,
);
const relatedAverageTurns = averageLearnerTurns(related);
return {
code: persona.code,
name: shortPersonaName(persona.display_name),
total: progress?.sessions ?? related.length,
active:
progress?.active_sessions ??
related.filter((session) => session.status === "active").length,
completed:
progress?.completed_sessions ??
related.filter((session) => session.status === "ended").length,
reviewReady:
progress?.review_ready_sessions ??
related.filter((session) => session.review_ready).length,
latest: related[0] ?? null,
latestStage: progress?.latest_stage ?? related[0]?.stage ?? null,
latestScore: progress?.latest_score ?? null,
trend: progress?.trend ?? "insufficient",
averageTurns: relatedAverageTurns,
rapportPercent: progress?.rapport_percent ?? 0,
};
});
return rows.sort((a, b) => {
if (b.total !== a.total) return b.total - a.total;
const bTime = b.latest ? sessionSortTime(b.latest) : 0;
const aTime = a.latest ? sessionSortTime(a.latest) : 0;
if (bTime !== aTime) return bTime - aTime;
return a.code.localeCompare(b.code);
});
}, [dashboard?.persona_progress, sortedSessions, usablePersonas]);
const maxPersonaSessionCount = useMemo(
() => Math.max(1, ...personaProgressRows.map((row) => row.total)),
[personaProgressRows],
);
const topPersona =
personaProgressRows.find((row) => row.total > 0) ?? personaProgressRows[0];
const personaCatalogUnavailable = loadState === "ready" && !selected;
const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0;
const rootClassName = [
"lh-root",
`lh-root--${view}`,
hasSessionRecords ? "has-session-records" : "",
personaCatalogUnavailable ? "has-catalog-unavailable" : "",
]
.filter(Boolean)
.join(" ");
useEffect(() => {
let alive = true;
if (view !== "dashboard" || !spotlightSession) {
setRecapDetail(null);
setRecapLoadState("ready");
return () => {
alive = false;
};
}
setRecapLoadState("loading");
sessionApi
.get(spotlightSession.session_id)
.then((detail) => {
if (!alive) return;
setRecapDetail(detail);
setRecapLoadState("ready");
})
.catch((error) => {
if (!alive) return;
console.warn("[learner-home] failed to load recap detail", error);
setRecapDetail(null);
setRecapLoadState("error");
});
return () => {
alive = false;
};
}, [spotlightSession, view]);
const startPractice = () => {
if (selected && isUsablePersona(selected)) {
if (voicePracticeRequested && !voicePracticeContext) return;
if (practiceLaunchRequested && !practiceLaunchIntent) return;
const suffix = practiceLaunchIntent
? `?${practiceLaunchSearch(practiceLaunchIntent)}`
: voicePracticeContext
? `?${voicePracticeSearch(voicePracticeContext)}`
: "";
navigate(`/learn/session/${selected.code}${suffix}`);
}
};
const goPrimaryAction = () => {
if (nextActiveSession) {
navigate(`/learn/session/${nextActiveSession.session_id}`);
return;
}
if (nextReviewSession) {
navigate(`/learn/session/${nextReviewSession.session_id}/review`);
return;
}
navigate("/learn/practice");
};
const refreshHistoryState = async () => {
const [sessionResult, dashboardResult] = await Promise.allSettled([
sessionApi.list(),
sessionApi.dashboard(),
]);
if (sessionResult.status === "fulfilled") {
setSessions(sessionResult.value.sessions ?? []);
setSessionLoadState("ready");
} else {
console.warn(
"[learner-home] failed to refresh sessions",
sessionResult.reason,
);
setSessionLoadState("error");
}
if (dashboardResult.status === "fulfilled") {
setDashboard(dashboardResult.value);
setDashboardLoadState("ready");
} else {
console.warn(
"[learner-home] failed to refresh dashboard",
dashboardResult.reason,
);
setDashboardLoadState("error");
}
};
const toggleArchiveSession = async (session: LearnerSessionSummary) => {
if (session.status === "active") return;
setArchiveBusyId(session.session_id);
setArchiveNotice(null);
try {
if (session.archived) {
await sessionApi.restore(session.session_id);
setArchiveNotice("보관을 해제했습니다.");
} else {
await sessionApi.archive(session.session_id);
setArchiveNotice("회기를 보관했습니다.");
}
await refreshHistoryState();
} catch (error) {
console.warn("[learner-home] failed to update archive state", error);
setArchiveNotice("보관 상태를 바꾸지 못했습니다.");
} finally {
setArchiveBusyId(null);
}
};
const coach = useMemo(() => {
if (sessionLoadState === "loading") {
return {
title: "기록을 확인하고 있습니다.",
body: "저장된 회기를 불러온 뒤 다음 학습 순서를 추천합니다.",
action: "잠시 기다리기",
};
}
if (sessionLoadState === "error") {
return {
title: "코칭을 만들 기록을 불러오지 못했습니다.",
body: "서버 기록을 다시 확인해야 정확한 추천을 만들 수 있습니다.",
action: "기록 다시 확인",
};
}
if (nextActiveSession) {
return {
title: "진행 중인 회기를 먼저 마무리합니다.",
body: "중단된 회기를 이어서 끝내면 이후 리뷰와 코칭 데이터가 더 안정적으로 쌓입니다.",
action: "이어하기",
};
}
if (nextReviewSession) {
return {
title: "새 회기보다 리뷰가 먼저입니다.",
body: "종료된 회기에서 어떤 개입이 효과적이었는지 확인해야 다음 연습의 목표가 또렷해집니다.",
action: "리뷰 확인",
};
}
if (growth?.trend === "down") {
return {
title: "최근 평가가 내려가는 흐름입니다.",
body: "직전 리뷰의 개선점과 대안 발화를 복습한 뒤, 같은 내담자로 한 회기 더 연습하는 것을 권합니다.",
action: "리뷰 다시 보기",
};
}
if (!sessions.length) {
return {
title: "첫 회기를 시작할 차례입니다.",
body: "난도가 낮은 대상부터 시작하고, 회기 종료 후 리뷰에서 상담 반응을 확인합니다.",
action: "학습 대상 선택",
};
}
if (recentAverageTurns != null && recentAverageTurns < 4) {
return {
title: "대화가 너무 짧게 끝나고 있습니다.",
body: "이번 회기에서는 감정 반영 뒤 한 번 더 탐색 질문을 붙이는 연습을 권합니다.",
action: "다음 대상 선택",
};
}
return {
title: "반복 대상보다 난도 확장이 필요합니다.",
body: "최근 회기 흐름이 안정적이면 같은 대상 반복보다 다른 난도나 주호소로 범위를 넓혀야 합니다.",
action: "학습 대상 선택",
};
}, [
growth?.trend,
nextActiveSession,
nextReviewSession,
recentAverageTurns,
sessionLoadState,
sessions.length,
]);
// 보완할 부분(부족한 영역) — 대시보드 평가 데이터에서 결정론으로 파생한다.
const weaknessInsights = useMemo(() => {
if (!dashboardReady || !growth) return [];
const items: { key: string; label: string; detail: string; to: string }[] =
[];
const watchTotal = (growth.points ?? []).reduce(
(sum, point) => sum + (point.watch_count ?? 0),
0,
);
if (growth.trend === "down") {
items.push({
key: "trend",
label: "평가가 내려가는 흐름",
detail:
"직전 리뷰의 개선점을 복습한 뒤 같은 내담자로 한 회기 더 연습해 보세요.",
to: "/learn/history",
});
}
if (
growth.avg_rapport != null &&
growth.avg_rapport < 0.35 &&
(growth.evaluated_sessions ?? 0) >= 1
) {
items.push({
key: "rapport",
label: "라포 신호가 아직 약함",
detail: "감정 반영과 타당화 문장을 먼저 두는 회기를 권합니다.",
to: "/learn/practice",
});
}
if (watchTotal > 0) {
items.push({
key: "watch",
label: `살펴볼 개입 ${watchTotal}`,
detail:
"리뷰의 '살펴보기' 코멘트를 확인하면 같은 실수를 줄일 수 있습니다.",
to: "/learn/history",
});
}
const techniqueCount = (growth.top_techniques ?? []).length;
if (
techniqueCount > 0 &&
techniqueCount < 3 &&
(growth.evaluated_sessions ?? 0) >= 2
) {
items.push({
key: "variety",
label: "기법 다양성이 낮음",
detail: `최근에는 ${(growth.top_techniques ?? []).join(", ")} 중심입니다. 개방질문·요약 같은 다른 기법도 시도해 보세요.`,
to: "/learn/practice",
});
}
return items.slice(0, 3);
}, [dashboardReady, growth]);
const achievements = dashboard?.achievements ?? [];
const spotlightPersona = useMemo(
() =>
spotlightSession
? (usablePersonas.find(
(persona) => persona.code === spotlightSession.persona_code,
) ?? null)
: selected,
[selected, spotlightSession, usablePersonas],
);
const effectiveRecapDetail =
recapDetail && recapDetail.session_id === spotlightSession?.session_id
? recapDetail
: null;
const spotlightAvatar = useMemo(
() =>
buildDashboardAvatar(spotlightPersona, spotlightSession?.persona_code),
[spotlightPersona, spotlightSession?.persona_code],
);
const spotlightAffect = recapExpressionFor(
spotlightSession,
spotlightPersona,
);
const spotlightAffectLabel = expressionLabelFor(spotlightAffect);
const rawLastClientLine = lastTurnText(effectiveRecapDetail, "client");
const rawLastLearnerLine = lastTurnText(effectiveRecapDetail, "learner");
const lastClientLine = rawLastClientLine
? displayPiiSafeText(rawLastClientLine)
: null;
const lastLearnerLine = rawLastLearnerLine
? displayPiiSafeText(rawLastLearnerLine)
: null;
const reviewQueue = sortedSessions
.filter((session) => session.review_ready)
.slice(0, 3);
const recentRecordRows = recentSessions.slice(0, 4);
const recapSessionTitle =
spotlightPersona?.display_name ??
spotlightSession?.persona_name ??
"첫 연습 대상 선택";
// D3: 난도 + 이론 목록이 겹쳐 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
const recapPersonaSubtitle = 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
? [
"마지막 반응 먼저 반영",
spotlightSession.stage === "라포" ? "안전감 확인" : "감정 변화 확인",
spotlightSession.stage === "정리"
? "다음 약속 정리"
: "탐색 질문 1개 준비",
]
: ["낮은 난도부터 시작", "라포 목표 설정", "회기 종료 후 리뷰"];
const dashboardMetrics = [
{
label: "진행 회기",
value: dashboardReady ? `${totalCount}` : "-",
meta: dashboardReady
? `${completedCount}회 종료 · ${activeCount}회 진행`
: dashboardStatus,
to: "/learn/history",
},
{
label: "리뷰 대기",
value: dashboardReady ? `${reviewCount}` : "-",
meta:
reviewCount > 0 ? "종료 회기 피드백 확인 필요" : "대기 중인 리뷰 없음",
to: "/learn/history",
},
{
label: "최근 평가",
value: dashboardReady ? scoreLabel(growth?.latest_score) : "-",
meta:
dashboardReady && growth?.evaluated_sessions
? `${growth.evaluated_sessions}회 평가 · ${trendLabel(growth.trend)}`
: "첫 회기 후 표시",
to: "/learn/history",
},
{
label: "라포 흐름",
value: dashboardReady ? rapportLabel(growth?.avg_rapport) : "-",
meta: topPersona?.total
? `${topPersona.name} · ${topPersona.total}`
: "아직 연습 없음",
to: "/learn/practice",
},
];
const renderSessionList = (
rows: LearnerSessionSummary[],
emptyMessage = "저장된 기존 회기가 없습니다.",
) => {
if (sessionLoadState === "loading") {
return <p className="lh-muted"> .</p>;
}
if (sessionLoadState === "error") {
return (
<div className="lh-inline-alert" role="status">
<Icon name="alert" size={16} />
<span> .</span>
</div>
);
}
if (!rows.length) {
return (
<div className="lh-activity__empty" role="status">
<Icon name="session" size={18} />
<div>
<b>{emptyMessage}</b>
<p> .</p>
</div>
</div>
);
}
return (
<ul className="lh-session-list" aria-label="회기 기록 목록">
{rows.map((session) => {
const bucket = historyBucket(session);
const progress = stageProgressValue(session.stage);
return (
<li
className={surfaceClassName(`lh-session-card is-${bucket}`, {
variant: "interactive",
})}
key={session.session_id}
>
<div className="lh-session-card__status">
<span className="lh-session-card__dot" aria-hidden="true" />
<span>
<b>{sessionStatusLabel(session)}</b>
<small>{historyBucketLabel(session)}</small>
</span>
</div>
<div className="lh-session-card__body">
<div className="lh-session-card__title">
<span>{session.persona_code}</span>
<b>{shortPersonaName(session.persona_name)}</b>
</div>
{/* D3: "단계 · 턴 · 날짜"로 가운뎃점이 2개였다. 날짜는 별도 컬럼으로 분리 */}
<p>
<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}%` }} />
</span>
</div>
<div className="lh-session-actions">
{session.status === "active" ? (
<button
type="button"
className="lh-review-link"
onClick={() =>
navigate(`/learn/session/${session.session_id}`)
}
>
</button>
) : (
<button
type="button"
className="lh-review-link"
onClick={() =>
navigate(`/learn/session/${session.session_id}/review`)
}
>
{session.review_ready && !session.archived
? "리뷰"
: "기록"}
</button>
)}
<button
type="button"
className="lh-review-link lh-review-link--ghost"
onClick={() =>
navigate(`/learn/session/${session.persona_code}`)
}
>
</button>
{session.status === "ended" ? (
<button
type="button"
className="lh-review-link lh-review-link--ghost"
disabled={archiveBusyId === session.session_id}
onClick={() => void toggleArchiveSession(session)}
>
{archiveBusyId === session.session_id
? "처리 중"
: session.archived
? "복원"
: "보관"}
</button>
) : null}
</div>
</li>
);
})}
</ul>
);
};
const renderCompactSessionRows = (
rows: LearnerSessionSummary[],
emptyMessage: string,
mode: "review" | "recent",
) => {
if (sessionLoadState === "loading") {
return <p className="lh-muted"> .</p>;
}
if (sessionLoadState === "error") {
return (
<div className="lh-inline-alert" role="status">
<Icon name="alert" size={16} />
<span> .</span>
</div>
);
}
if (!rows.length) {
return <p className="lh-compact-empty">{emptyMessage}</p>;
}
return (
<ul className="lh-compact-list">
{rows.map((session) => (
<li
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
key={`${mode}-${session.session_id}`}
>
<span className="lh-compact-list__avatar" aria-hidden="true">
{shortPersonaName(session.persona_name).slice(0, 1)}
</span>
<span className="lh-compact-list__body">
<b>{shortPersonaName(session.persona_name)}</b>
{/* D3: 가운뎃점 2개였던 줄 — 날짜를 오른쪽 컬럼으로 분리 */}
<small>
<span>
{session.persona_code} · {session.stage}
</span>
<span>{sessionDateLabel(session.started_at)}</span>
</small>
</span>
{mode === "review" ? (
<button
type="button"
className="lh-compact-list__action"
onClick={() =>
navigate(`/learn/session/${session.session_id}/review`)
}
>
AI
</button>
) : (
<button
type="button"
className="lh-compact-list__status"
onClick={() =>
navigate(
session.status === "active"
? `/learn/session/${session.session_id}`
: `/learn/session/${session.session_id}/review`,
)
}
>
{sessionStatusLabel(session)}
</button>
)}
</li>
))}
</ul>
);
};
const renderPersonaList = () => {
if (loadState === "error") {
return (
<div className="lh-error" role="alert">
<Icon name="alert" size={19} />
<div>
<b> .</b>
<p> .</p>
</div>
</div>
);
}
return (
<div className="lh-personas" role="listbox" aria-label="연습 페르소나">
{loadState === "loading"
? Array.from({ length: 3 }).map((_, i) => (
<div
className={surfaceClassName("lh-persona is-skel", {
variant: "inset",
})}
key={i}
aria-hidden="true"
>
<span className="lh-persona__mark lh-skel__box" />
<span className="lh-persona__body">
<span className="lh-skel__line lh-skel__line--name" />
<span className="lh-skel__line lh-skel__line--meta" />
<span className="lh-skel__line lh-skel__line--sum" />
</span>
</div>
))
: personas.map((persona) => {
const usablePersona = isUsablePersona(persona);
const selectedPersona =
usablePersona && persona.code === selected?.code;
// D3: 이론이 2개면 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
const personaMeta = oneMiddot(
(DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) +
" · " +
theoryLine(persona),
);
// D3: 카드 이름줄도 "이름 · 학년"까지만 두고 주호소는 아랫줄로 내린다.
const personaTitle = splitPersonaTitle(persona.display_name);
return (
<button
key={persona.code}
type="button"
role="option"
aria-selected={selectedPersona}
aria-disabled={!usablePersona}
disabled={!usablePersona}
className={surfaceClassName(
`lh-persona ${selectedPersona ? "is-selected" : ""}`,
{ variant: "interactive" },
)}
onClick={() => {
if (usablePersona) setSelectedCode(persona.code);
}}
>
<span className="lh-persona__mark">{persona.code}</span>
<span className="lh-persona__body">
<span className="lh-persona__name">
{personaTitle.head}
</span>
{personaTitle.tail ? (
<span className="lh-persona__concern">
{personaTitle.tail}
</span>
) : null}
<span className="lh-persona__meta">
<span>{personaMeta}</span>
{/* D3: "· 사용 불가"를 이어 붙이면 가운뎃점이 늘어나 별도 칩으로 분리 */}
{usablePersona ? null : (
<b className="lh-persona__flag"> </b>
)}
</span>
<span className="lh-persona__summary">
{usablePersona
? persona.presenting_summary
: unavailablePersonaMessage(persona)}
</span>
</span>
{selectedPersona ? (
<span className="lh-persona__check" aria-hidden="true">
<Icon name="check" size={15} strokeWidth={2.4} />
</span>
) : null}
</button>
);
})}
</div>
);
};
return (
<AppShell
className={
view === "dashboard" ? "vg-shell--learner-dashboard" : undefined
}
contextLabel="학습 공간"
navRole="learner"
wide
>
<main className={rootClassName}>
{view === "dashboard" ? (
<>
<header className="lh-head">
<div className="lh-head__copy">
{/* D1: 단순 섹션 라벨이라 상태 dot 제거(대시보드 화면 dot 밀집 정리) */}
<Kicker dot={false}> </Kicker>
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
, ,
.
</p>
</div>
<div className="lh-head__tools">
<div className="lh-head__status" aria-live="polite">
<Icon name="users" size={17} />
{loadState === "ready"
? `${usablePersonas.length}명 · ${historyStatus}`
: "확인 중"}
</div>
<Button
className="lh-head__primary"
size="lg"
onClick={goPrimaryAction}
data-learner-primary-action
trailing={<Icon name="chevron-right" size={18} />}
>
{coach.action}
</Button>
</div>
</header>
<section className="lh-dashboard-status" aria-label="학습 현황">
{dashboardMetrics.map((metric) => (
<button
type="button"
className={surfaceClassName("lh-metric-card", {
variant: "interactive",
})}
key={metric.label}
onClick={() => navigate(metric.to)}
>
<span>{metric.label}</span>
<b>{metric.value}</b>
<p>{metric.meta}</p>
</button>
))}
</section>
{/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 CSS로 숨김 */}
<div
className="lh-tabs"
role="tablist"
aria-label="대시보드 영역 선택"
>
{DASHBOARD_TABS.map((tab, index) => (
<button
key={tab.id}
ref={(element) => {
dashTabRefs.current[index] = element;
}}
id={`lh-tab-${tab.id}`}
type="button"
role="tab"
aria-controls={`lh-panel-${tab.id}`}
aria-selected={dashTab === tab.id}
tabIndex={dashTab === tab.id ? 0 : -1}
className={dashTab === tab.id ? "is-active" : ""}
onClick={() => setDashTab(tab.id)}
onKeyDown={(event) => handleDashTabKeyDown(event, index)}
>
{tab.label}
</button>
))}
</div>
<section
className={`lh-dashboard-grid lh-grid--tab-${dashTab}`}
aria-label="오늘 학습 대시보드"
>
<section
id="lh-panel-today"
className={surfaceClassName("lh-work-cluster")}
role="tabpanel"
aria-labelledby="lh-tab-today"
tabIndex={0}
hidden={dashTab !== "today"}
>
<div className="lh-work-cluster__head">
<div>
{/* D1: 섹션 라벨 — 상태가 아니므로 dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2>, , .</h2>
</div>
</div>
<div className="lh-work-cluster__primary">
<article
className={surfaceClassName("lh-session-focus", {
variant: "inset",
})}
>
<div className="lh-session-focus__head">
<div>
{/* D1: 실제 진행 상태는 우측 status 배지가 맡으므로 dot 제거 */}
<Kicker dot={false}>
{spotlightSession
? "오늘 이어갈 회기"
: "첫 회기 준비"}
</Kicker>
<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">
{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={196}
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
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<dt></dt>
<dd>{spotlightSession?.stage ?? "선택 전"}</dd>
</div>
<div
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<dt> </dt>
<dd>{spotlightAffectLabel}</dd>
</div>
<div
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<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={surfaceClassName("lh-recap__quote", {
variant: "inset",
flat: true,
})}
>
<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={surfaceClassName("lh-coach-card", {
variant: "inset",
})}
aria-label="AI 코치"
>
<div>
{/* D1: 패널 이름표 — 상태가 아니므로 dot 제거 */}
<Kicker dot={false}>AI </Kicker>
<h2>{coach.title}</h2>
<p>{coach.body}</p>
</div>
<div className="lh-coach-hints" aria-label="코칭 힌트">
{coachHints.map((hint) => (
<span
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
key={hint}
>
<Icon name="check" size={14} />
{hint}
</span>
))}
</div>
<p className="lh-recap__coach">
{recapCoachLine(spotlightSession)}
</p>
</aside>
</div>
<div className="lh-work-cluster__secondary">
<article
className={surfaceClassName(
"lh-panel lh-dashboard-recommend",
{ variant: "inset" },
)}
>
<div className="lh-panel__head">
<div>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2>
{reviewCount > 0
? "리뷰 확인을 우선합니다."
: "연습 목표를 좁힙니다."}
</h2>
</div>
<span className="lh-panel__badge"> </span>
</div>
<div
className={surfaceClassName("lh-recommend-card", {
variant: "inset",
flat: true,
})}
>
<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={surfaceClassName(
"lh-panel lh-dashboard-feedback",
{ variant: "inset" },
)}
>
<div className="lh-panel__head">
<div>
{/* D1: 섹션 라벨 — 건수 상태는 우측 badge 가 표시한다 */}
<Kicker dot={false}> </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={surfaceClassName("lh-feedback-mini__row", {
variant: "interactive",
flat: true,
})}
onClick={() =>
navigate(
`/learn/session/${recentFeedback[0].session_id}/review`,
)
}
>
{/* D3: 가운뎃점 2개였던 줄 — 평가 점수는 오른쪽 컬럼으로 분리 */}
<b>
<span>
{recentFeedback[0].persona_code} ·{" "}
{recentFeedback[0].stage}
</span>
<span>{scoreLabel(recentFeedback[0].score)}</span>
</b>
<span>
{truncateText(recentFeedback[0].note, 86)}
</span>
</button>
) : (
<p> .</p>
)}
</div>
</article>
</div>
</section>
<aside
className="lh-dashboard-side"
aria-label="학습 기록과 성장 지표"
>
<div
id="lh-panel-records"
className="lh-dashboard-side__tabpanel"
role="tabpanel"
aria-labelledby="lh-tab-records"
tabIndex={0}
hidden={dashTab !== "records"}
>
<article
className={surfaceClassName("lh-panel lh-panel--records")}
>
<div className="lh-panel__head">
<div
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
<button
type="button"
onClick={() => navigate("/learn/history")}
>
</button>
</div>
{renderCompactSessionRows(
recentRecordRows,
"최근 기록이 없습니다.",
"recent",
)}
</article>
<article
className={surfaceClassName("lh-panel lh-panel--records")}
>
<div className="lh-panel__head">
<div
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
{/* D4: eyebrow "리뷰 대기"는 바로 아래 제목과 같은 말이라 삭제 */}
<h2> .</h2>
</div>
<button
type="button"
onClick={() => navigate("/learn/history")}
>
</button>
</div>
{renderCompactSessionRows(
reviewQueue,
"대기 중인 리뷰가 없습니다.",
"review",
)}
</article>
</div>
<div
id="lh-panel-growth"
className="lh-dashboard-side__tabpanel"
role="tabpanel"
aria-labelledby="lh-tab-growth"
tabIndex={0}
hidden={dashTab !== "growth"}
>
{weaknessInsights.length > 0 ? (
<article
className={surfaceClassName(
"lh-panel lh-weakness lh-panel--growth",
)}
>
<div className="lh-panel__head">
<div>
{/* D1: 평가 기록에서 파생된 "현재 신호"라 dot 유지 */}
<Kicker> </Kicker>
<h2> .</h2>
</div>
</div>
<div className="lh-weakness__list">
{weaknessInsights.map((item) => (
<button
type="button"
key={item.key}
onClick={() => navigate(item.to)}
>
<b>{item.label}</b>
<p>{item.detail}</p>
</button>
))}
</div>
</article>
) : null}
<article
className={surfaceClassName("lh-panel lh-panel--growth")}
>
<div className="lh-panel__head">
<div
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
</div>
<div
className="lh-rapport-list"
aria-label="페르소나별 라포 누적"
>
{personaProgressRows
.filter((row) => row.total > 0)
.slice(0, 3).length > 0 ? (
personaProgressRows
.filter((row) => row.total > 0)
.slice(0, 3)
.map((row) => (
<div className="lh-rapport-row" key={row.code}>
<span className="lh-rapport-row__name">
{row.name}
<small>{row.total}</small>
</span>
<span
className="lh-rapport-row__bar"
role="progressbar"
aria-valuenow={row.rapportPercent}
aria-valuemin={0}
aria-valuemax={100}
>
<i style={{ width: `${row.rapportPercent}%` }} />
</span>
<b>{row.rapportPercent}%</b>
</div>
))
) : (
<p className="lh-muted">
.
</p>
)}
</div>
<div className="lh-insight-list">
<div>
<span> </span>
<b>
{topPersona?.total
? "주호소 범위를 넓힐 차례"
: "첫 회기 이후 표시"}
</b>
<p>
.
</p>
</div>
</div>
</article>
{achievements.length > 0 ? (
<article
className={surfaceClassName(
"lh-panel lh-milestones lh-panel--growth",
)}
>
<div className="lh-panel__head">
<div>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
</div>
<div className="lh-milestones__list">
{achievements.map((item) => (
<span
key={item.id}
className={"lh-milestone is-" + item.state}
title={item.detail}
>
<Icon
name={
item.state === "done" ? "check" : "chevron-right"
}
size={13}
/>
{item.label}
</span>
))}
</div>
</article>
) : null}
</div>
</aside>
</section>
</>
) : null}
{view === "practice" ? (
<>
<header className="lh-head">
<div className="lh-head__copy">
{/* D4: eyebrow "학습 대상"은 아래 제목("연습할 내담자")과 같은 말이라 삭제 */}
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
.
.
</p>
</div>
<div className="lh-head__status" aria-live="polite">
<Icon name="users" size={17} />
{loadState === "ready"
? `${usablePersonas.length}`
: "확인 중"}
</div>
</header>
{voicePracticeRequested ? (
voicePracticeContext ? (
<section className="lh-voice-practice-intent" aria-labelledby="lh-voice-practice-title">
<div>
<Kicker> </Kicker>
<h2 id="lh-voice-practice-title">
{voicePracticeContext.sceneType
? VOICE_SCENE_LABEL[voicePracticeContext.sceneType]
: "선택한 장면"} .
</h2>
<p>
. .
</p>
</div>
<dl>
<div><dt></dt><dd>{PRACTICE_SOURCE_SESSION_LABEL}</dd></div>
<div><dt></dt><dd>{voicePracticeContext.sourceSceneId ?? "전체 회기"}</dd></div>
<div>
<dt></dt>
<dd>
{voicePracticeContext.sceneStartMs != null && voicePracticeContext.sceneEndMs != null
? `${Math.floor(voicePracticeContext.sceneStartMs / 1000)}${Math.ceil(voicePracticeContext.sceneEndMs / 1000)}`
: "전체 구간"}
</dd>
</div>
</dl>
</section>
) : (
<section className="lh-voice-practice-intent is-error" role="alert">
<div>
<Kicker> </Kicker>
<h2> .</h2>
<p> . .</p>
</div>
</section>
)
) : null}
{practiceLaunchRequested ? (
practiceLaunchIntent ? (
<section
className="lh-voice-practice-intent lh-practice-launch-intent"
aria-labelledby="lh-practice-launch-title"
>
<div>
<Kicker>
{practiceLaunchIntent.kind === "transfer" ? "전이 검증" : "처방 연습"}
</Kicker>
<h2 id="lh-practice-launch-title">
{PRACTICE_MODE_LABEL[practiceLaunchIntent.mode]} .
</h2>
<p>
.
, .
</p>
</div>
<details className="lh-practice-launch-intent__details">
<summary>
<span> </span>
<b>{practiceCriterionLabel(practiceLaunchIntent.criterionId)}</b>
<Icon name="chevron-right" size={16} />
</summary>
<dl>
<div>
<dt> </dt>
<dd>{PRACTICE_MODE_LABEL[practiceLaunchIntent.mode]}</dd>
</div>
<div>
<dt> </dt>
<dd>{PRACTICE_NOVELTY_LABEL[practiceLaunchIntent.novelty]}</dd>
</div>
<div>
<dt> </dt>
<dd>{practiceCriterionLabel(practiceLaunchIntent.criterionId)}</dd>
</div>
<div>
<dt></dt>
<dd>{PRACTICE_SOURCE_SESSION_LABEL}</dd>
</div>
{practiceLaunchIntent.kind === "transfer" ? (
<div>
<dt> </dt>
<dd> </dd>
</div>
) : null}
</dl>
</details>
</section>
) : (
<section className="lh-voice-practice-intent is-error" role="alert">
<div>
<Kicker> </Kicker>
<h2> .</h2>
<p>
. .
</p>
</div>
</section>
)
) : null}
<section className="lh-practice-layout" aria-label="학습 대상 선택">
<aside className="lh-list-pane">
<div className="lh-pane-head">
<Kicker> </Kicker>
<span>
{loadState === "ready" ? "내담자 목록" : "확인 중"}
</span>
</div>
{renderPersonaList()}
</aside>
<section
className="lh-preview"
aria-labelledby="lh-preview-title"
>
<div className="lh-preview__main">
<div className="lh-preview__hero">
<div className="lh-preview__identity">
<div className="lh-avatar" aria-hidden="true">
{personaCatalogUnavailable
? "!"
: personaInitial(selected)}
</div>
<div className="lh-preview__copy">
<span className="lh-preview__code">
{personaCatalogUnavailable
? "catalog"
: (selected?.code ?? "...")}
</span>
<h2 id="lh-preview-title">
{personaCatalogUnavailable ? (
"사용 가능한 내담자가 없습니다."
) : selected ? (
<>
{previewTitleParts.head}
{previewTitleParts.tail ? (
<span className="lh-title-tail">
{previewTitleParts.tail}
</span>
) : null}
</>
) : (
"내담자 정보를 불러오는 중"
)}
</h2>
{/* D3: 인적 정보도 가운뎃점 1개만 남기고 나머지는 쉼표 나열 */}
<p>
{personaCatalogUnavailable
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
: oneMiddot(demographicsLine(selected))}
</p>
</div>
</div>
<div className="lh-actions">
<span className="lh-actions__note">
.
</span>
<Button
size="lg"
onClick={startPractice}
disabled={
!selected ||
(voicePracticeRequested && !voicePracticeContext) ||
(practiceLaunchRequested && !practiceLaunchIntent)
}
trailing={<Icon name="chevron-right" size={18} />}
>
</Button>
</div>
</div>
<div
className={surfaceClassName("lh-summary", {
variant: "inset",
flat: true,
})}
>
<Kicker>
{personaCatalogUnavailable ? "카탈로그 상태" : "주호소"}
</Kicker>
<p>
{personaCatalogUnavailable
? "현재 서버 카탈로그가 신뢰 가능한 상태로 확인되지 않아 연습 시작을 막았습니다."
: (selected?.presenting_summary ??
"내담자 정보를 확인하고 있습니다.")}
</p>
</div>
<dl className="lh-facts">
<div>
<dt></dt>
<dd>{difficultyLine(selected)}</dd>
</div>
<div>
<dt></dt>
<dd>{theoryLine(selected)}</dd>
</div>
<div>
<dt></dt>
<dd>{selected?.voice_preset ?? "기본"}</dd>
</div>
</dl>
</div>
<section
className={surfaceClassName("lh-activity")}
aria-label="연습 기록"
>
<div className="lh-activity__stats">
<span
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<b>{historyReady ? activeCount : ""}</b>
<small> </small>
</span>
<span
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<b>{historyReady ? reviewCount : ""}</b>
<small> </small>
</span>
<span
className={surfaceClassName(undefined, {
variant: "inset",
flat: true,
})}
>
<b>{historyReady ? sessions.length : ""}</b>
<small> </small>
</span>
</div>
<div
className={surfaceClassName("lh-activity__recent", {
variant: "inset",
flat: true,
})}
>
<div className="lh-activity__head">
<Kicker> </Kicker>
<span>
{selected
? `${selected.code} · ${selectedSessions.length}`
: "-"}
</span>
</div>
{personaCatalogUnavailable ? (
<div className="lh-inline-alert" role="status">
<Icon name="alert" size={16} />
<span>
.
</span>
</div>
) : (
renderSessionList(selectedSessions)
)}
</div>
</section>
</section>
</section>
</>
) : null}
{view === "history" ? (
<>
<header className="lh-head">
<div className="lh-head__copy">
{/* D4: eyebrow "회기 기록"은 아래 제목과 같은 말이라 삭제 */}
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
, ,
.
</p>
</div>
<div className="lh-head__status" aria-live="polite">
<Icon name="review" size={17} />
{historyStatus}
</div>
</header>
<section
className={surfaceClassName("lh-history-overview")}
aria-label="기록 상태 요약"
>
{HISTORY_FILTERS.map((item) => (
<button
type="button"
className={surfaceClassName(
`lh-history-task ${historyFilter === item.value ? "is-selected" : ""}`,
{ variant: "interactive", flat: true },
)}
key={item.value}
aria-pressed={historyFilter === item.value}
onClick={() => setHistoryFilter(item.value)}
>
<span>{item.label}</span>
<b>{historyReady ? historyFilterCounts[item.value] : ""}</b>
<small>{item.desc}</small>
</button>
))}
</section>
<section className="lh-history-layout">
<section
className={surfaceClassName("lh-history-main")}
aria-label="회기 기록"
>
<div className="lh-history-main__head">
<div>
{/* D4: eyebrow "기록 목록"은 아래 제목("전체 회기"/필터명)과 겹쳐 삭제 */}
<h2>
{historyFilter === "all"
? "전체 회기"
: HISTORY_FILTERS.find(
(item) => item.value === historyFilter,
)?.label}
</h2>
</div>
<span>
{historyReady
? `${filteredHistorySessions.length}`
: historyStatus}
</span>
</div>
<div className="lh-history-tools">
<label className="lh-history-search">
<span> </span>
<input
value={historyQuery}
onChange={(event) => setHistoryQuery(event.target.value)}
placeholder="내담자, 단계, 상태"
aria-label="기록 검색"
/>
</label>
<div
className="lh-history-filter"
aria-label="기록 상태 필터"
>
{HISTORY_FILTERS.map((item) => (
<button
type="button"
key={item.value}
className={
historyFilter === item.value ? "is-selected" : ""
}
aria-pressed={historyFilter === item.value}
onClick={() => setHistoryFilter(item.value)}
>
{item.label}
</button>
))}
</div>
</div>
{archiveNotice ? (
<div
className="lh-inline-alert lh-inline-alert--archive"
role="status"
>
<Icon name="session" size={16} />
<span>{archiveNotice}</span>
</div>
) : null}
{renderSessionList(
filteredHistorySessions,
historyEmptyMessage(historyFilter, historyQuery),
)}
</section>
<aside className="lh-history-side" aria-label="기록 보조 정보">
<section
className={surfaceClassName("lh-archive-note", {
variant: "inset",
})}
>
<div className="lh-persona-progress__head">
<Kicker> </Kicker>
<span> </span>
</div>
<div className="lh-archive-note__rows">
<span>
<b> </b>
<small> </small>
</span>
<span>
<b> </b>
<small> </small>
</span>
<span>
<b></b>
<small> </small>
</span>
</div>
</section>
<section
className={surfaceClassName("lh-persona-progress", {
variant: "inset",
})}
aria-label="페르소나별 진행 정도"
>
<div className="lh-persona-progress__head">
<Kicker> </Kicker>
<span>
{historyReady
? `${personaProgressRows.length}`
: "확인 중"}
</span>
</div>
{sessionLoadState === "loading" ? (
<p className="lh-persona-progress__note">
.
</p>
) : personaProgressRows.length > 0 ? (
<div className="lh-persona-progress__rows">
{personaProgressRows.map((row) => {
const progressWidth = Math.round(
(row.total / maxPersonaSessionCount) * 100,
);
return (
<button
type="button"
className={surfaceClassName(
`lh-persona-progress__row ${
row.code === selected?.code ? "is-selected" : ""
}`,
{ variant: "interactive", flat: true },
)}
key={row.code}
onClick={() => {
setSelectedCode(row.code);
navigate("/learn/practice");
}}
>
<span className="lh-persona-progress__title">
<b>{row.code}</b>
<em>{row.name}</em>
</span>
{/* D3: 가운뎃점 3개였던 줄 — 진행/리뷰 건수를 별도 컬럼으로 쪼갠다 */}
<span className="lh-persona-progress__meta">
{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"
aria-hidden="true"
>
<span style={{ width: `${progressWidth}%` }} />
</span>
<span className="lh-persona-progress__latest">
{row.latest
? `${sessionStatusLabel(row.latest)} · ${row.latest.stage}`
: "첫 회기 전"}
</span>
</button>
);
})}
</div>
) : (
<p className="lh-persona-progress__note">
.
</p>
)}
</section>
</aside>
</section>
</>
) : null}
</main>
</AppShell>
);
}