2163 lines
83 KiB
TypeScript
2163 lines
83 KiB
TypeScript
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>
|
||
);
|
||
}
|