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([]); const [sessions, setSessions] = useState([]); const [dashboard, setDashboard] = useState( null, ); const [selectedCode, setSelectedCode] = useState(""); const [loadState, setLoadState] = useState("loading"); const [sessionLoadState, setSessionLoadState] = useState("loading"); const [dashboardLoadState, setDashboardLoadState] = useState("loading"); const [recapDetail, setRecapDetail] = useState( null, ); const [recapLoadState, setRecapLoadState] = useState("loading"); const [historyFilter, setHistoryFilter] = useState("all"); const [historyQuery, setHistoryQuery] = useState(""); const [archiveBusyId, setArchiveBusyId] = useState(null); const [archiveNotice, setArchiveNotice] = useState(null); // 중간 폭(≤1180px)에서는 대시보드 섹션을 상단 가로 탭으로 전환한다(2026-07-15 소유자 피드백). // 데스크톱(>1180px)은 2컬럼 그리드 유지 — 탭 바는 CSS로 숨겨지고 이 상태는 무시된다. const [dashTab, setDashTab] = useState("today"); const dashTabRefs = useRef>([]); function handleDashTabKeyDown( event: ReactKeyboardEvent, 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

저장된 연습 기록을 확인하고 있습니다.

; } if (sessionLoadState === "error") { return (
연습 기록을 불러오지 못했습니다.
); } if (!rows.length) { return (
{emptyMessage}

첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.

); } return (
    {rows.map((session) => { const bucket = historyBucket(session); const progress = stageProgressValue(session.stage); return (
  • {session.persona_code} {shortPersonaName(session.persona_name)}
    {/* D3: "단계 · 턴 · 날짜"로 가운뎃점이 2개였다. 날짜는 별도 컬럼으로 분리 */}

    {session.stage} 단계 · {session.turn_count}턴 {sessionDateLabel(session.started_at)}

    {session.status === "active" ? ( ) : ( )} {session.status === "ended" ? ( ) : null}
  • ); })}
); }; const renderCompactSessionRows = ( rows: LearnerSessionSummary[], emptyMessage: string, mode: "review" | "recent", ) => { if (sessionLoadState === "loading") { return

기록을 확인하고 있습니다.

; } if (sessionLoadState === "error") { return (
기록을 불러오지 못했습니다.
); } if (!rows.length) { return

{emptyMessage}

; } return (
    {rows.map((session) => (
  • {shortPersonaName(session.persona_name)} {/* D3: 가운뎃점 2개였던 줄 — 날짜를 오른쪽 컬럼으로 분리 */} {session.persona_code} · {session.stage} {sessionDateLabel(session.started_at)} {mode === "review" ? ( ) : ( )}
  • ))}
); }; const renderPersonaList = () => { if (loadState === "error") { return (
내담자 목록을 불러오지 못했습니다.

잠시 뒤 다시 시도해 주세요.

); } return (
{loadState === "loading" ? Array.from({ length: 3 }).map((_, i) => ( )) : 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 ( ); })}
); }; return (
{view === "dashboard" ? ( <>
{/* D1: 단순 섹션 라벨이라 상태 dot 제거(대시보드 화면 dot 밀집 정리) */} 학습 대시보드

오늘 이어갈 회기를 먼저 봅니다.

진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서 정리합니다.

{loadState === "ready" ? `${usablePersonas.length}명 · ${historyStatus}` : "확인 중"}
{dashboardMetrics.map((metric) => ( ))}
{/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 CSS로 숨김 */}
{DASHBOARD_TABS.map((tab, index) => ( ))}