From 5be9161234fad9c9f351037100d36d1e17c8bc96 Mon Sep 17 00:00:00 2001 From: Guy Tonye Date: Thu, 21 May 2026 14:28:12 -0400 Subject: [PATCH 01/10] chore: fix missing resident online courses component --- frontend/package.json | 3 + .../pages/learning/ResidentOnlineCourses.tsx | 114 ++++++++++++++++++ frontend/yarn.lock | 32 +++++ 3 files changed, 149 insertions(+) create mode 100644 frontend/src/pages/learning/ResidentOnlineCourses.tsx diff --git a/frontend/package.json b/frontend/package.json index 80706d92d..7b84c2df3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,10 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "embla-carousel-react": "^8.6.0", + "html2canvas": "^1.4.1", + "html2pdf.js": "^0.14.0", "input-otp": "^1.4.2", + "jspdf": "^4.2.1", "lucide-react": "^0.487.0", "moment": "^2.30.1", "next-themes": "^0.4.6", diff --git a/frontend/src/pages/learning/ResidentOnlineCourses.tsx b/frontend/src/pages/learning/ResidentOnlineCourses.tsx new file mode 100644 index 000000000..13067f77c --- /dev/null +++ b/frontend/src/pages/learning/ResidentOnlineCourses.tsx @@ -0,0 +1,114 @@ +import { Navigate, useLoaderData } from 'react-router-dom'; +import { useAuth, hasFeature } from '@/auth/useAuth'; +import { FeatureAccess } from '@/types'; +import type { UserCourses, ActivityMapData } from '@/types'; +import { PageHeader } from '@/components/shared'; +import { UserCoursesStatsGrid } from '@/components/student/UserCoursesStatsGrid'; +import { Card, CardContent } from '@/components/ui/card'; +import { BookOpen } from 'lucide-react'; + +interface LoaderData { + courses: UserCourses[]; + week_activity: ActivityMapData[]; +} + +export default function ResidentOnlineCourses() { + const { user } = useAuth(); + const loaderData = useLoaderData() as LoaderData | undefined; + + if (!user) return null; + + if (!hasFeature(user, FeatureAccess.ProviderAccess)) { + return ; + } + + const courses = loaderData?.courses ?? []; + const summary = { + num_completed: courses.filter((c) => c.course_progress >= 100).length, + num_in_progress: courses.filter( + (c) => c.course_progress > 0 && c.course_progress < 100 + ).length, + total_time: courses.reduce((sum, c) => sum + (c.total_time ?? 0), 0), + courses + }; + + return ( +
+ + + {courses.length > 0 && } + + {courses.length === 0 ? ( +
+ +

+ No online courses found. Courses from connected + providers will appear here. +

+
+ ) : ( +
+ {courses.map((course) => ( + + {course.thumbnail_url && ( + + )} + +
+

+ {course.provider_platform_name} +

+ + {course.course_name} + +
+ +
+
+ Progress + + {Math.floor(course.course_progress)} + % + +
+
+
+
+
+ + {course.grade && ( +

+ Grade:{' '} + + {course.grade} + +

+ )} + + + ))} +
+ )} +
+ ); +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b161458fc..06383535e 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2216,6 +2216,23 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +html2canvas@^1.0.0, html2canvas@^1.0.0-rc.5, html2canvas@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" + integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA== + dependencies: + css-line-break "^2.1.0" + text-segmentation "^1.0.3" + +html2pdf.js@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/html2pdf.js/-/html2pdf.js-0.14.0.tgz#dd2fdf2ee3036cb4c0d7c0d4606ee2da7c677e83" + integrity sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ== + dependencies: + dompurify "^3.3.1" + html2canvas "^1.0.0" + jspdf "^4.0.0" + husky@^9.1.6: version "9.1.7" resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d" @@ -2320,6 +2337,21 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +jspdf@^4.0.0, jspdf@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/jspdf/-/jspdf-4.2.1.tgz#6ba0d263999313f91f369ee80ecf235046b2acd8" + integrity sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ== + dependencies: + "@babel/runtime" "^7.28.6" + fast-png "^6.2.0" + fflate "^0.8.1" + optionalDependencies: + canvg "^3.0.11" + core-js "^3.6.0" + dompurify "^3.3.1" + html2canvas "^1.0.0-rc.5" + + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" From 3bb5fcb762791c544be1c074a63c8f9644cd62e8 Mon Sep 17 00:00:00 2001 From: adriennewu <78094183+adriennewu@users.noreply.github.com> Date: Wed, 27 May 2026 09:46:26 -0400 Subject: [PATCH 02/10] feat: enhance digital transcript entry flow with funnel support - Integrated funnel functionality in DigitalTranscriptEntryPage, allowing for a streamlined user experience when adding achievements. - Added back navigation handling that commits session rows before navigating back. - Updated DigitalTranscriptWysiwygEntry to support single-row sessions for funnel mode. - Improved UI components to conditionally render based on funnel state, enhancing usability. - Introduced new helper functions for managing session states and entries. --- .../DigitalTranscriptEntryPage.tsx | 192 +++++++ .../DigitalTranscriptHome.tsx | 464 +++++++++++++++++ .../DigitalTranscriptWysiwygEntry.tsx | 479 ++++++++++++++++++ 3 files changed, 1135 insertions(+) create mode 100644 frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx create mode 100644 frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx create mode 100644 frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx new file mode 100644 index 000000000..b8e614cd8 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx @@ -0,0 +1,192 @@ +import { useCallback, useRef, useState } from 'react'; +import { createPortal, flushSync } from 'react-dom'; +import { Download, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '@/auth/useAuth'; +import { Button } from '@/components/ui/button'; +import { useTranscriptDraft } from '@/hooks/useTranscriptDraft'; +import { cn } from '@/lib/utils'; +import { + downloadLearningRecordPdf, + learningRecordPdfFilename +} from '@/utils/downloadLearningRecordPdf'; +import { getDigitalTranscriptBasePath, setDigitalTranscriptStorageContext } from './digitalTranscriptRoutes'; +import { getLearningRecordFormVariant } from './learningRecordPrototypes'; +import { DigitalTranscriptWysiwygEntry } from './DigitalTranscriptWysiwygEntry'; +import { DigitalTranscriptBackLink, DigitalTranscriptShell, dtPageSurface } from './DigitalTranscriptShell'; +import { LearningRecordExportContent } from './LearningRecordExportContent'; +import { learningRecordResidentDisplayName } from './learningRecordResidentName'; +import { readLearningRecordExportRows } from './transcriptEntrySessionStorage'; +import type { TranscriptEntry } from '@/types/digital-transcript'; + +const digitalTranscriptBackLinkClassName = + 'group inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors hover:text-primary/80'; + +export default function DigitalTranscriptEntryPage() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const base = getDigitalTranscriptBasePath(pathname); + setDigitalTranscriptStorageContext(base); + const formVariant = getLearningRecordFormVariant(pathname); + const isFunnel = formVariant === 'funnel'; + const backCommitRef = useRef<(() => void) | null>(null); + const { hydrated, upsertCommittedEntry, deleteCommittedEntry, entries } = useTranscriptDraft(); + const { user } = useAuth(); + const residentName = learningRecordResidentDisplayName(user); + const [exportRows, setExportRows] = useState(() => + readLearningRecordExportRows() + ); + const [isExporting, setIsExporting] = useState(false); + const [exportActive, setExportActive] = useState(false); + const exportRootRef = useRef(null); + const liveExportRowsRef = useRef(exportRows); + liveExportRowsRef.current = exportRows; + + const canDownload = exportRows.length > 0 && !isExporting; + + const handleExportRowsChange = useCallback((rows: TranscriptEntry[]) => { + setExportRows(rows); + }, []); + + const handleRegisterBackCommit = useCallback((commit: () => void) => { + backCommitRef.current = commit; + }, []); + + const handleBack = useCallback(() => { + backCommitRef.current?.(); + navigate(base); + }, [navigate, base]); + + const handleDownload = useCallback(async () => { + const rows = + liveExportRowsRef.current.length > 0 + ? liveExportRowsRef.current + : readLearningRecordExportRows(); + if (rows.length === 0 || isExporting) return; + + setIsExporting(true); + flushSync(() => { + setExportActive(true); + setExportRows(rows); + }); + + try { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const root = exportRootRef.current; + if (!root) { + throw new Error('Export content not ready'); + } + + await downloadLearningRecordPdf( + root, + learningRecordPdfFilename(residentName) + ); + toast.success('Learning record downloaded'); + } catch (err) { + // #region agent log + fetch('http://127.0.0.1:7605/ingest/222c6233-433f-42b0-8e1b-e79b53b2d8b4',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'55a621'},body:JSON.stringify({sessionId:'55a621',location:'DigitalTranscriptEntryPage.tsx:catch',message:'PDF export error',data:{name:err instanceof Error?err.name:'unknown',msg:err instanceof Error?err.message:String(err)},timestamp:Date.now(),hypothesisId:'H-C'})}).catch(()=>{}); + // #endregion + console.error('Learning record PDF export failed:', err); + toast.error('Could not download PDF. Please try again.'); + } finally { + setExportActive(false); + setIsExporting(false); + } + }, [isExporting, residentName]); + + if (!hydrated) { + return ( + +
+ +

Loading your editor…

+
+
+ ); + } + + return ( +
+ {exportActive && + createPortal( +
+ +
, + document.body + )} +
+
+ {isFunnel ? ( + + ) : ( + Back + )} + +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx new file mode 100644 index 000000000..61b890fd8 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx @@ -0,0 +1,464 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; +import { ConfirmDialog, EmptyState, PageHeader } from '@/components/shared'; +import { useTranscriptDraft } from '@/hooks/useTranscriptDraft'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { + countAnsweredReflections, + reflectionSlotsTotal +} from '@/pages/student/digital-transcript/learningRecordDocumentModel'; +import { getDigitalTranscriptBasePath, setDigitalTranscriptStorageContext } from './digitalTranscriptRoutes'; +import { getLearningRecordFormVariant } from './learningRecordPrototypes'; +import { DigitalTranscriptEyebrow, DigitalTranscriptShell } from './DigitalTranscriptShell'; +import { TranscriptResumePreview } from './TranscriptResumePreview'; + +/** Decorative sample for the home CTA thumbnail (not persisted). */ +const ACHIEVEMENT_LOG_THUMBNAIL_SAMPLE: TranscriptEntry = { + id: '__home_thumb_sample__', + createdAt: '', + programName: 'Your next achievement', + completionDate: '2025-06-01', + topSkills: ['Study habits', 'Test strategies', 'Time management'], + whatMadeYouFinish: 'Checking off each milestone kept me going.', + confidence: '4', + pride: 'Sticking with it when the material felt impossible at first.', + goalConnection: 'A clear step toward licensing and steadier work.', + standoutMoment: 'Instructors who explained things with patience and respect.', + adviceToPeer: 'Use the tutor hours—you are not alone in the room.', + oneSentence: 'A program that helped me believe I could finish what I started.' +}; + +const FUNNEL_SUBTITLE = + "This is your personal record of the programs you've completed and the skills you've built. Each achievement you add is saved on this device — and when you're ready, you can export your record as a PDF to keep, share, or take with you."; + +const primaryCtaClassName = + 'bg-[#556830] text-white shadow-sm hover:bg-[#203622] sm:min-w-[11rem]'; + +function formatProgramCompletedDate(entry: TranscriptEntry): string { + if (!entry.completionDate.trim()) return '—'; + return new Date(entry.completionDate + 'T12:00:00').toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +function formatSavedOn(iso: string): string { + if (!iso.trim()) return '—'; + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +function savedOnDeviceLabel(count: number): string { + const word = count === 1 ? 'achievement' : 'achievements'; + return `You have ${count} ${word} saved on this device`; +} + +function ReadinessCell({ entry }: { entry: TranscriptEntry }) { + const filled = countAnsweredReflections(entry); + const total = reflectionSlotsTotal(); + const pct = Math.round((filled / total) * 100); + return ( +
+ + {filled}/{total} + +
+
+
+
+ ); +} + +interface SavedEntriesSectionProps { + entries: TranscriptEntry[]; + entriesNewestFirst: TranscriptEntry[]; + entryPath: string; + sectionHeading: ReactNode; + headerAction?: ReactNode; + emptyState: { title: string; description: string }; + onDeleteRequest: (entry: TranscriptEntry) => void; +} + +function SavedEntriesSection({ + entries, + entriesNewestFirst, + entryPath, + sectionHeading, + headerAction, + emptyState, + onDeleteRequest +}: SavedEntriesSectionProps) { + return ( +
+
+
{sectionHeading}
+ {headerAction} +
+ + + + {entries.length === 0 ? ( + + ) : ( + + + + + + + Program + + + Completed + + + Readiness + + + Logged + + + Edit + + + Delete + + + + + {entriesNewestFirst.map((entry) => ( + + +
+ {entry.programName.trim() || '—'} +
+

+ Completed {formatProgramCompletedDate(entry)} +

+
+ +
+

+ Logged {formatSavedOn(entry.createdAt)} +

+
+ + {formatProgramCompletedDate(entry)} + + + + + + {formatSavedOn(entry.createdAt)} + + + + + + + +
+ ))} +
+
+
+
+ )} +
+ ); +} + +export default function DigitalTranscriptHome() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const base = getDigitalTranscriptBasePath(pathname); + setDigitalTranscriptStorageContext(base); + const formVariant = getLearningRecordFormVariant(pathname); + const isFunnel = formVariant === 'funnel'; + const entryPath = `${base}/entry`; + const { entries, hydrated, hasDraft, deleteCommittedEntry } = useTranscriptDraft(); + const [deleteTarget, setDeleteTarget] = useState(null); + + const entriesNewestFirst = useMemo( + () => [...entries].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), + [entries] + ); + + if (!hydrated) { + return ( + +
+ +

Loading your record…

+
+
+ ); + } + + function handleStartNewClick() { + navigate(`${entryPath}?intent=new`); + } + + const primaryCtaLabel = entries.length === 0 ? 'Start logging' : 'Add achievement'; + + const cardTitle = + entries.length === 0 && !hasDraft ? 'Start logging' : 'Build your Achievements Record'; + + return ( + + {isFunnel ? ( + <> + + + + {savedOnDeviceLabel(entries.length)} + + } + headerAction={ + + } + emptyState={{ + title: 'No achievements logged yet', + description: + "Click 'Add achievement' to document your first program, skill, or learning. Your record builds from here." + }} + onDeleteRequest={setDeleteTarget} + /> + + ) : ( + <> + + + +
+
+ + + {cardTitle} + + + {hasDraft ? ( + <> + You have work in progress in the editor. Continue where you left + off, or add a new program achievement. + + ) : entries.length === 0 ? ( + <> + Open the editor and fill in what you did. Nothing appears on + this list until you tap Done. + + ) : ( + <> + You have saved {entries.length}{' '} + {entries.length === 1 ? 'achievement' : 'achievements'}. Add + another anytime. + + )} + + + + {hasDraft ? ( + <> + + + + ) : ( + <> + {entries.length > 0 && ( + <> + + + + )} + {entries.length === 0 && ( + + )} + + )} + +
+
+
+
+
+ +
+
+
+
+
+
+ + + Saved entries +

+ On this device +

+ + } + headerAction={ + entries.length > 0 ? ( +

+ {entries.length}{' '} + {entries.length === 1 ? 'achievement' : 'achievements'} +

+ ) : undefined + } + emptyState={{ + title: 'Nothing saved yet', + description: + 'When you finish editing and tap Done, your achievement appears here.' + }} + onDeleteRequest={setDeleteTarget} + /> + + )} + { + if (!open) setDeleteTarget(null); + }} + title="Remove this achievement?" + description={ + deleteTarget + ? `“${deleteTarget.programName.trim() || 'Untitled'}” will be removed from this device. This cannot be undone.` + : '' + } + confirmLabel="Delete" + cancelLabel="Cancel" + variant="destructive" + onConfirm={() => { + if (deleteTarget) { + deleteCommittedEntry(deleteTarget.id); + } + setDeleteTarget(null); + }} + /> +
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx new file mode 100644 index 000000000..40c63b0b1 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx @@ -0,0 +1,479 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Plus } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; +import { ConfirmDialog } from '@/components/shared'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { + cloneTranscriptEntry, + createEmptyTranscriptEntry, + dispatchEntrySessionUpdated, + entryHasExportableContent, + filterEntriesForExport, + readTranscriptEntriesFromStorage, + resolveInitialEntrySession, + sortEntriesNewestFirst, + syncSessionRowsAfterUpsert, + writeEntrySessionToStorage +} from '@/pages/student/digital-transcript/transcriptEntrySessionStorage'; +import type { TranscriptEntrySession } from '@/types/digital-transcript'; +import { AchievementsRecordPreview } from './AchievementsRecordPreview'; +import { AchievementRow } from './AchievementRow'; +import type { LearningRecordFormVariant } from './learningRecordPrototypes'; +import { TOP_SKILLS_MAX } from './transcriptReflectionConfig'; + +/** Newest uncommitted row with no answers yet — safe to reopen instead of duplicating. */ +function findReusableBlankDraftRow( + rows: TranscriptEntry[], + committedIds: Set +): TranscriptEntry | null { + for (const row of sortEntriesNewestFirst(rows)) { + if (committedIds.has(row.id)) continue; + if (!entryHasExportableContent(row)) return row; + } + return null; +} + +function ensureDraftEditorOpen( + session: TranscriptEntrySession, + committed: TranscriptEntry[] +): TranscriptEntrySession { + const committedIds = new Set(committed.map((e) => e.id)); + const reusable = findReusableBlankDraftRow(session.rows, committedIds); + if (reusable) { + return { + ...session, + expandedId: reusable.id, + lastPreviewId: reusable.id + }; + } + const row = createEmptyTranscriptEntry(); + return { + ...session, + rows: [row, ...session.rows], + expandedId: row.id, + lastPreviewId: row.id + }; +} + +/** Funnel editor: one achievement row per visit, form expanded. */ +function toFunnelSingleRowSession( + session: TranscriptEntrySession, + committed: TranscriptEntry[], + options: { intent?: boolean; edit?: string | null } +): TranscriptEntrySession { + if (options.intent) { + const opened = ensureDraftEditorOpen(session, committed); + const rowId = opened.expandedId ?? opened.rows[0]?.id; + const row = + opened.rows.find((r) => r.id === rowId) ?? + findReusableBlankDraftRow(opened.rows, new Set(committed.map((e) => e.id))) ?? + createEmptyTranscriptEntry(); + const cloned = cloneTranscriptEntry(row); + return { + ...opened, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; + } + + if (options.edit) { + const fromSession = session.rows.find((r) => r.id === options.edit); + const fromCommitted = committed.find((e) => e.id === options.edit); + const row = fromSession ?? fromCommitted; + if (row) { + const cloned = cloneTranscriptEntry(row); + return { + ...session, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; + } + } + + if (session.rows.length === 0) { + const opened = ensureDraftEditorOpen(session, committed); + const row = + opened.rows.find((r) => r.id === opened.expandedId) ?? + opened.rows[0] ?? + createEmptyTranscriptEntry(); + const cloned = cloneTranscriptEntry(row); + return { + ...opened, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; + } + + const preferredId = + session.expandedId ?? sortEntriesNewestFirst(session.rows)[0]?.id ?? null; + const row = + session.rows.find((r) => r.id === preferredId) ?? session.rows[0]; + const cloned = cloneTranscriptEntry(row); + return { + ...session, + rows: [cloned], + expandedId: cloned.id, + lastPreviewId: cloned.id + }; +} + +interface DigitalTranscriptWysiwygEntryProps { + base: string; + formVariant: LearningRecordFormVariant; + hydrated: boolean; + entries: TranscriptEntry[]; + upsertCommittedEntry: (entry: TranscriptEntry) => void; + deleteCommittedEntry: (id: string) => TranscriptEntrySession | null; + /** Live session rows for PDF export (includes in-progress autosaved work). */ + onExportRowsChange?: (rows: TranscriptEntry[]) => void; + /** Funnel: register Back handler that commits session rows before navigate. */ + onRegisterBackCommit?: (commit: () => void) => void; +} + +export function DigitalTranscriptWysiwygEntry({ + base: _base, + formVariant, + hydrated, + entries, + upsertCommittedEntry, + deleteCommittedEntry, + onExportRowsChange, + onRegisterBackCommit +}: DigitalTranscriptWysiwygEntryProps) { + const isFunnel = formVariant === 'funnel'; + const [searchParams, setSearchParams] = useSearchParams(); + const [session, setSession] = useState(null); + const [doneErrorRowId, setDoneErrorRowId] = useState(null); + const [deleteConfirmFor, setDeleteConfirmFor] = useState(null); + const baselinesRef = useRef>({}); + const prevExpandedIdRef = useRef(null); + const achievementListRef = useRef(null); + const sessionRef = useRef(null); + sessionRef.current = session; + + const committedIds = useMemo(() => new Set(entries.map((e) => e.id)), [entries]); + + const captureBaseline = useCallback((id: string, rows: TranscriptEntry[]) => { + const row = rows.find((r) => r.id === id); + if (row) baselinesRef.current[id] = cloneTranscriptEntry(row); + }, []); + + const bootstrapped = useRef(false); + + useEffect(() => { + if (!hydrated || bootstrapped.current) return; + bootstrapped.current = true; + + const edit = searchParams.get('edit'); + const intent = searchParams.get('intent') === 'new'; + + let s = resolveInitialEntrySession(); + const committed = readTranscriptEntriesFromStorage(); + + if (isFunnel) { + s = toFunnelSingleRowSession(s, committed, { + intent: intent || undefined, + edit: edit || null + }); + } else if (intent) { + s = ensureDraftEditorOpen(s, committed); + } else if (edit && s.rows.some((r) => r.id === edit)) { + s = { ...s, expandedId: edit, lastPreviewId: edit }; + } else if (s.rows.length === 0) { + s = ensureDraftEditorOpen(s, committed); + } + + if (edit || intent) { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete('edit'); + next.delete('intent'); + return next; + }, + { replace: true } + ); + } + + setSession(s); + writeEntrySessionToStorage(s); + dispatchEntrySessionUpdated(); + + if (s.expandedId) { + captureBaseline(s.expandedId, s.rows); + prevExpandedIdRef.current = s.expandedId; + } + }, [hydrated, searchParams, setSearchParams, captureBaseline, isFunnel]); + + const commitSessionRowsForBack = useCallback(() => { + const current = sessionRef.current; + if (!current) return; + let nextSession = current; + for (const row of current.rows) { + const saved: TranscriptEntry = { + ...row, + topSkills: row.topSkills.slice(0, TOP_SKILLS_MAX) + }; + upsertCommittedEntry(saved); + nextSession = syncSessionRowsAfterUpsert(nextSession, saved); + } + writeEntrySessionToStorage(nextSession); + dispatchEntrySessionUpdated(); + setSession(nextSession); + }, [upsertCommittedEntry]); + + useEffect(() => { + if (!isFunnel || !onRegisterBackCommit) return; + onRegisterBackCommit(commitSessionRowsForBack); + }, [isFunnel, onRegisterBackCommit, commitSessionRowsForBack]); + + useEffect(() => { + if (!session) return; + const id = session.expandedId; + if (id === prevExpandedIdRef.current) return; + prevExpandedIdRef.current = id; + if (id) captureBaseline(id, session.rows); + }, [session, session?.expandedId, captureBaseline]); + + useEffect(() => { + if (!session) return; + const t = window.setTimeout(() => { + writeEntrySessionToStorage(session); + dispatchEntrySessionUpdated(); + }, 400); + return () => window.clearTimeout(t); + }, [session]); + + useEffect(() => { + if (!session) { + onExportRowsChange?.([]); + return; + } + onExportRowsChange?.(filterEntriesForExport(session.rows)); + }, [session, onExportRowsChange]); + + const patchRow = useCallback((id: string, patch: Partial) => { + setSession((prev) => { + if (!prev) return prev; + const rows = prev.rows.map((r) => { + if (r.id !== id) return r; + const nextTop = patch.topSkills ?? r.topSkills; + return { ...r, ...patch, topSkills: nextTop }; + }); + const lastPreviewId = prev.expandedId === id ? id : prev.lastPreviewId; + return { ...prev, rows, lastPreviewId }; + }); + }, []); + + const handleToggleExpand = useCallback((id: string) => { + setSession((prev) => { + if (!prev) return prev; + if (prev.expandedId === id) { + return { ...prev, expandedId: null }; + } + return { ...prev, expandedId: id, lastPreviewId: id }; + }); + setDoneErrorRowId(null); + }, []); + + const handleAdd = useCallback(() => { + const row = createEmptyTranscriptEntry(); + setSession((prev) => { + if (!prev) return prev; + return { + ...prev, + rows: [row, ...prev.rows], + expandedId: row.id, + lastPreviewId: row.id + }; + }); + setDoneErrorRowId(null); + }, []); + + const isCommittedEntryId = useCallback((id: string) => { + return readTranscriptEntriesFromStorage().some((e) => e.id === id); + }, []); + + const handleCancel = useCallback( + (id: string) => { + const baseline = baselinesRef.current[id]; + setDoneErrorRowId(null); + setSession((prev) => { + if (!prev) return prev; + const committed = isCommittedEntryId(id); + const current = prev.rows.find((r) => r.id === id); + const restored = baseline ? cloneTranscriptEntry(baseline) : current ?? null; + if (!restored) { + return { ...prev, expandedId: null }; + } + let rows = prev.rows.map((r) => (r.id === id ? restored : r)); + if (!committed && !entryHasExportableContent(restored)) { + rows = rows.filter((r) => r.id !== id); + } + const lastPreviewId = + rows.length > 0 ? rows[rows.length - 1].id : null; + return { + ...prev, + rows, + expandedId: null, + lastPreviewId + }; + }); + }, + [isCommittedEntryId] + ); + + const handleDone = useCallback( + (id: string) => { + const row = sessionRef.current?.rows.find((r) => r.id === id); + if (!row) return; + const programOk = Boolean(row.programName.trim()); + const dateOk = Boolean(row.completionDate.trim()); + if (!programOk || !dateOk) { + setDoneErrorRowId(id); + return; + } + setDoneErrorRowId(null); + const saved: TranscriptEntry = { + ...row, + topSkills: row.topSkills.slice(0, TOP_SKILLS_MAX) + }; + upsertCommittedEntry(saved); + setSession((prev) => { + if (!prev) return prev; + const next = syncSessionRowsAfterUpsert(prev, saved); + return { ...next, expandedId: null }; + }); + baselinesRef.current[id] = cloneTranscriptEntry(saved); + }, + [upsertCommittedEntry] + ); + + const displayRows = useMemo( + () => (session ? sortEntriesNewestFirst(session.rows) : []), + [session] + ); + + const expandedId = session?.expandedId ?? null; + + useLayoutEffect(() => { + if (!expandedId || !achievementListRef.current) return; + const row = achievementListRef.current.querySelector( + `[data-achievement-id="${expandedId}"]` + ); + row?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [expandedId, displayRows.length]); + + if (!hydrated || !session) { + return ( +
+
+

Loading your editor…

+
+ ); + } + + return ( +
+ {/* + Scroll contract: + - Editor pane: header fixed; `transcript-achievement-list` scrolls vertically. + - Preview pane: `achievements-record-preview-scroll` scrolls vertically. + - Layout chain uses min-h-0 + overflow-hidden so panes do not share one page scroll. + */} +
+ + +
+ +
+
+ { + if (!open) setDeleteConfirmFor(null); + }} + title="Remove this achievement?" + description={ + deleteConfirmFor + ? `“${deleteConfirmFor.programName.trim() || 'Untitled'}” will be removed from this device. This cannot be undone.` + : '' + } + confirmLabel="Delete" + cancelLabel="Cancel" + variant="destructive" + onConfirm={() => { + const target = deleteConfirmFor; + setDeleteConfirmFor(null); + if (!target) return; + delete baselinesRef.current[target.id]; + const next = deleteCommittedEntry(target.id); + setSession(next ?? resolveInitialEntrySession()); + }} + /> +
+ ); +} From 66c223fdebc3b07cb27727aedccc3c7ec1ea50d0 Mon Sep 17 00:00:00 2001 From: adriennewu <78094183+adriennewu@users.noreply.github.com> Date: Wed, 27 May 2026 21:09:11 -0400 Subject: [PATCH 03/10] feat: enhance achievement form and digital transcript components for funnel support - Refactored AchievementForm and AchievementFormMetadata to support funnel step navigation and error handling. - Updated AchievementRow to conditionally render based on funnel state, integrating save and cancel functionalities. - Enhanced AchievementsRecordPreview to include download capabilities for funnel mode. - Introduced new props and handlers for managing funnel interactions across digital transcript components. - Improved UI consistency and user experience in the digital transcript entry flow. --- .../digital-transcript/AchievementForm.tsx | 96 ++++ .../AchievementFormMetadata.tsx | 73 ++++ .../digital-transcript/AchievementRow.tsx | 151 +++++++ .../AchievementsRecordPreview.tsx | 110 +++++ .../DigitalTranscriptEntryPage.tsx | 131 ++++-- .../DigitalTranscriptHome.tsx | 2 +- .../DigitalTranscriptWysiwygEntry.tsx | 324 +++++++++++--- .../LearningRecordExportContent.tsx | 121 ++++++ .../ReflectionStepField.tsx | 106 +++++ .../ReflectionTextField.tsx | 92 ++++ .../transcriptEntrySessionStorage.ts | 410 ++++++++++++++++++ .../transcriptReflectionConfig.ts | 408 +++++++++++++++++ frontend/src/routes/app-routes.tsx | 28 ++ 13 files changed, 1953 insertions(+), 99 deletions(-) create mode 100644 frontend/src/pages/student/digital-transcript/AchievementForm.tsx create mode 100644 frontend/src/pages/student/digital-transcript/AchievementFormMetadata.tsx create mode 100644 frontend/src/pages/student/digital-transcript/AchievementRow.tsx create mode 100644 frontend/src/pages/student/digital-transcript/AchievementsRecordPreview.tsx create mode 100644 frontend/src/pages/student/digital-transcript/LearningRecordExportContent.tsx create mode 100644 frontend/src/pages/student/digital-transcript/ReflectionStepField.tsx create mode 100644 frontend/src/pages/student/digital-transcript/ReflectionTextField.tsx create mode 100644 frontend/src/pages/student/digital-transcript/transcriptEntrySessionStorage.ts create mode 100644 frontend/src/pages/student/digital-transcript/transcriptReflectionConfig.ts diff --git a/frontend/src/pages/student/digital-transcript/AchievementForm.tsx b/frontend/src/pages/student/digital-transcript/AchievementForm.tsx new file mode 100644 index 000000000..cfb66d692 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementForm.tsx @@ -0,0 +1,96 @@ +import { Button } from '@/components/ui/button'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { AchievementFormMetadata } from './AchievementFormMetadata'; +import { ReflectionStepField } from './ReflectionStepField'; +import { + FUNNEL_FORM_STEP_COUNT, + FUNNEL_FORM_STEPS, + funnelStepFieldLabel, + type FunnelStepField, + type ReflectionAnswerKey +} from './transcriptReflectionConfig'; + +function isReflectionField(field: FunnelStepField): field is ReflectionAnswerKey { + return field !== 'programName' && field !== 'completionDate'; +} + +interface AchievementFormProps { + entry: TranscriptEntry; + onChange: (patch: Partial) => void; + showSaveErrors: boolean; + /** Controlled step index (lifted for Save validation jump to step 0). */ + activeStep: number; + onActiveStepChange: (step: number) => void; +} + +export function AchievementForm({ + entry, + onChange, + showSaveErrors, + activeStep, + onActiveStepChange +}: AchievementFormProps) { + const stepConfig = FUNNEL_FORM_STEPS[activeStep]; + const isFirstStep = activeStep === 0; + const isLastStep = activeStep === FUNNEL_FORM_STEP_COUNT - 1; + + return ( +
+ {stepConfig ? ( +

+ {stepConfig.title} +

+ ) : null} + + {stepConfig ? ( +
+ {isFirstStep ? ( + + ) : null} + + {stepConfig.fields.filter(isReflectionField).map((field) => { + if (!isReflectionField(field)) return null; + return ( + + ); + })} +
+ ) : null} + +
+ {!isFirstStep ? ( + + ) : null} + {!isLastStep ? ( + + ) : null} +
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementFormMetadata.tsx b/frontend/src/pages/student/digital-transcript/AchievementFormMetadata.tsx new file mode 100644 index 000000000..096eb7dad --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementFormMetadata.tsx @@ -0,0 +1,73 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { cn } from '@/lib/utils'; + +interface AchievementFormMetadataProps { + entry: TranscriptEntry; + onChange: (patch: Partial) => void; + showSaveErrors?: boolean; + /** Categories variant — alias for showSaveErrors. */ + showDoneErrors?: boolean; + /** When true, adds a bottom border to separate metadata from reflection sections. */ + showSectionDivider?: boolean; +} + +export function AchievementFormMetadata({ + entry, + onChange, + showSaveErrors, + showDoneErrors, + showSectionDivider = false +}: AchievementFormMetadataProps) { + const showErrors = showSaveErrors ?? showDoneErrors ?? false; + const programOk = Boolean(entry.programName.trim()); + const dateOk = Boolean(entry.completionDate.trim()); + + return ( +
+
+ + onChange({ programName: e.target.value })} + placeholder="e.g. GED prep, welding fundamentals" + aria-invalid={showErrors && !programOk} + className="h-10 border-border/80 bg-muted/40" + /> + {showErrors && !programOk ? ( +

+ {showSaveErrors + ? 'Please enter a program name to save your achievement.' + : 'Add a program or course name to continue.'} +

+ ) : null} +
+ +
+ + onChange({ completionDate: e.target.value })} + aria-invalid={showErrors && !dateOk} + className="h-10 border-border/80 bg-muted/40" + /> + {showErrors && !dateOk ? ( +

+ {showSaveErrors + ? 'Please add a completion date to save your achievement.' + : 'Add a completion date to continue.'} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementRow.tsx b/frontend/src/pages/student/digital-transcript/AchievementRow.tsx new file mode 100644 index 000000000..c1326e994 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementRow.tsx @@ -0,0 +1,151 @@ +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { + countEditorFormSlots, + editorFormSlotsTotal +} from './learningRecordDocumentModel'; +import type { LearningRecordFormVariant } from './learningRecordPrototypes'; +import { AchievementForm } from './AchievementForm'; +import { AchievementFormCategories } from './AchievementFormCategories'; + +function formatCompletedShort(iso: string): string { + if (!iso.trim()) return ''; + return new Date(`${iso}T12:00:00`).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); +} + +function collapsedDateLine(entry: TranscriptEntry): string { + const datePart = formatCompletedShort(entry.completionDate); + return datePart ? `Completed ${datePart}` : 'Completion date not set'; +} + +interface AchievementRowProps { + formVariant: LearningRecordFormVariant; + entry: TranscriptEntry; + isExpanded: boolean; + onToggleExpand: () => void; + onPatch: (patch: Partial) => void; + onCancel?: () => void; + onDone?: () => void; + showDoneErrors?: boolean; + showSaveErrors?: boolean; + showDelete?: boolean; + onDeleteRequest?: () => void; + activeStep?: number; + onActiveStepChange?: (step: number) => void; +} + +export function AchievementRow({ + formVariant, + entry, + isExpanded, + onToggleExpand, + onPatch, + onCancel, + onDone, + showDoneErrors = false, + showSaveErrors = false, + showDelete, + onDeleteRequest, + activeStep = 0, + onActiveStepChange +}: AchievementRowProps) { + if (formVariant === 'funnel') { + return ( +
+ {})} + /> +
+ ); + } + + const title = entry.programName.trim() || 'Untitled achievement'; + const filled = countEditorFormSlots(entry); + const total = editorFormSlotsTotal(); + const complete = filled === total; + const progressPct = Math.round((filled / total) * 100); + + return ( + + + +
+ {})} + onDone={onDone ?? (() => {})} + showDoneErrors={showDoneErrors} + showDelete={showDelete} + onDeleteRequest={onDeleteRequest} + /> +
+
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/AchievementsRecordPreview.tsx b/frontend/src/pages/student/digital-transcript/AchievementsRecordPreview.tsx new file mode 100644 index 000000000..d06fa7f2f --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/AchievementsRecordPreview.tsx @@ -0,0 +1,110 @@ +import { useLayoutEffect, useMemo, useRef } from 'react'; +import { Download, Loader2 } from 'lucide-react'; +import { useAuth } from '@/auth/useAuth'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { sortEntriesNewestFirst } from './transcriptEntrySessionStorage'; +import { LearningRecordExportContent } from './LearningRecordExportContent'; +import { learningRecordResidentDisplayName } from './learningRecordResidentName'; + +export interface FunnelDownloadProps { + onDownload: () => void; + canDownload: boolean; + isExporting: boolean; +} + +interface AchievementsRecordPreviewProps { + rows: TranscriptEntry[]; + anchorId: string | null; + variant?: 'default' | 'funnel'; + funnelDownload?: FunnelDownloadProps; +} + +export function AchievementsRecordPreview({ + rows, + anchorId, + variant = 'default', + funnelDownload +}: AchievementsRecordPreviewProps) { + const { user } = useAuth(); + const residentName = learningRecordResidentDisplayName(user); + const docRows = useMemo(() => sortEntriesNewestFirst(rows), [rows]); + const scrollRef = useRef(null); + + useLayoutEffect(() => { + if (!anchorId || !scrollRef.current) return; + const block = scrollRef.current.querySelector( + `[data-achievement-id="${anchorId}"]` + ); + block?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [anchorId, docRows.length]); + + const isFunnel = variant === 'funnel'; + + const previewContent = ( + + ); + + if (isFunnel) { + return ( + + {funnelDownload ? ( +
+ +
+ ) : null} +
+ {previewContent} +
+
+ ); + } + + return ( +
+
+ {previewContent} +
+
+ ); +} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx index b8e614cd8..062245d2a 100644 --- a/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptEntryPage.tsx @@ -4,6 +4,7 @@ import { Download, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '@/auth/useAuth'; +import { ConfirmDialog } from '@/components/shared'; import { Button } from '@/components/ui/button'; import { useTranscriptDraft } from '@/hooks/useTranscriptDraft'; import { cn } from '@/lib/utils'; @@ -13,7 +14,10 @@ import { } from '@/utils/downloadLearningRecordPdf'; import { getDigitalTranscriptBasePath, setDigitalTranscriptStorageContext } from './digitalTranscriptRoutes'; import { getLearningRecordFormVariant } from './learningRecordPrototypes'; -import { DigitalTranscriptWysiwygEntry } from './DigitalTranscriptWysiwygEntry'; +import { + DigitalTranscriptWysiwygEntry, + type FunnelToolbarHandlers +} from './DigitalTranscriptWysiwygEntry'; import { DigitalTranscriptBackLink, DigitalTranscriptShell, dtPageSurface } from './DigitalTranscriptShell'; import { LearningRecordExportContent } from './LearningRecordExportContent'; import { learningRecordResidentDisplayName } from './learningRecordResidentName'; @@ -30,7 +34,7 @@ export default function DigitalTranscriptEntryPage() { setDigitalTranscriptStorageContext(base); const formVariant = getLearningRecordFormVariant(pathname); const isFunnel = formVariant === 'funnel'; - const backCommitRef = useRef<(() => void) | null>(null); + const funnelToolbarRef = useRef(null); const { hydrated, upsertCommittedEntry, deleteCommittedEntry, entries } = useTranscriptDraft(); const { user } = useAuth(); const residentName = learningRecordResidentDisplayName(user); @@ -39,6 +43,7 @@ export default function DigitalTranscriptEntryPage() { ); const [isExporting, setIsExporting] = useState(false); const [exportActive, setExportActive] = useState(false); + const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); const exportRootRef = useRef(null); const liveExportRowsRef = useRef(exportRows); liveExportRowsRef.current = exportRows; @@ -49,15 +54,37 @@ export default function DigitalTranscriptEntryPage() { setExportRows(rows); }, []); - const handleRegisterBackCommit = useCallback((commit: () => void) => { - backCommitRef.current = commit; + const handleRegisterFunnelToolbar = useCallback((handlers: FunnelToolbarHandlers) => { + funnelToolbarRef.current = handlers; }, []); - const handleBack = useCallback(() => { - backCommitRef.current?.(); + const navigateHome = useCallback(() => { navigate(base); }, [navigate, base]); + const requestLeave = useCallback(() => { + if (!isFunnel) { + navigateHome(); + return; + } + if (funnelToolbarRef.current?.hasUnsavedChanges()) { + setLeaveConfirmOpen(true); + return; + } + navigateHome(); + }, [isFunnel, navigateHome]); + + const handleConfirmLeave = useCallback(() => { + setLeaveConfirmOpen(false); + funnelToolbarRef.current?.cancel(); + navigateHome(); + }, [navigateHome]); + + const handleSave = useCallback(() => { + const saved = funnelToolbarRef.current?.save() ?? false; + if (saved) navigateHome(); + }, [navigateHome]); + const handleDownload = useCallback(async () => { const rows = liveExportRowsRef.current.length > 0 @@ -87,9 +114,6 @@ export default function DigitalTranscriptEntryPage() { ); toast.success('Learning record downloaded'); } catch (err) { - // #region agent log - fetch('http://127.0.0.1:7605/ingest/222c6233-433f-42b0-8e1b-e79b53b2d8b4',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'55a621'},body:JSON.stringify({sessionId:'55a621',location:'DigitalTranscriptEntryPage.tsx:catch',message:'PDF export error',data:{name:err instanceof Error?err.name:'unknown',msg:err instanceof Error?err.message:String(err)},timestamp:Date.now(),hypothesisId:'H-C'})}).catch(()=>{}); - // #endregion console.error('Learning record PDF export failed:', err); toast.error('Could not download PDF. Please try again.'); } finally { @@ -143,7 +167,7 @@ export default function DigitalTranscriptEntryPage() { type="button" data-slot="digital-transcript-back" className={digitalTranscriptBackLinkClassName} - onClick={handleBack} + onClick={requestLeave} > Back )} - +
+ {isFunnel ? ( + <> + + + + ) : null} + {!isFunnel ? ( + + ) : null} +
void handleDownload(), + canDownload, + isExporting + } + : undefined + } />
+ {isFunnel ? ( + + ) : null}
); } diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx index 61b890fd8..49687f300 100644 --- a/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptHome.tsx @@ -282,7 +282,7 @@ export default function DigitalTranscriptHome() { } emptyState={{ - title: 'No achievements logged yet', + title: 'No achievements added yet', description: "Click 'Add achievement' to document your first program, skill, or learning. Your record builds from here." }} diff --git a/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx b/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx index 40c63b0b1..322bb1a9a 100644 --- a/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx +++ b/frontend/src/pages/student/digital-transcript/DigitalTranscriptWysiwygEntry.tsx @@ -2,12 +2,15 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { Plus } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; import { ConfirmDialog } from '@/components/shared'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; import type { TranscriptEntry } from '@/types/digital-transcript'; import { cloneTranscriptEntry, createEmptyTranscriptEntry, dispatchEntrySessionUpdated, entryHasExportableContent, + entryPayloadEqual, filterEntriesForExport, readTranscriptEntriesFromStorage, resolveInitialEntrySession, @@ -16,10 +19,19 @@ import { writeEntrySessionToStorage } from '@/pages/student/digital-transcript/transcriptEntrySessionStorage'; import type { TranscriptEntrySession } from '@/types/digital-transcript'; -import { AchievementsRecordPreview } from './AchievementsRecordPreview'; +import { + AchievementsRecordPreview, + type FunnelDownloadProps +} from './AchievementsRecordPreview'; import { AchievementRow } from './AchievementRow'; import type { LearningRecordFormVariant } from './learningRecordPrototypes'; -import { TOP_SKILLS_MAX } from './transcriptReflectionConfig'; +import { + countFunnelFieldsAnswered, + countFunnelStepFieldsAnswered, + FUNNEL_FORM_FIELD_TOTAL, + FUNNEL_FORM_STEPS, + TOP_SKILLS_MAX +} from './transcriptReflectionConfig'; /** Newest uncommitted row with no answers yet — safe to reopen instead of duplicating. */ function findReusableBlankDraftRow( @@ -120,8 +132,13 @@ function toFunnelSingleRowSession( }; } +export interface FunnelToolbarHandlers { + save: () => boolean; + cancel: () => void; + hasUnsavedChanges: () => boolean; +} + interface DigitalTranscriptWysiwygEntryProps { - base: string; formVariant: LearningRecordFormVariant; hydrated: boolean; entries: TranscriptEntry[]; @@ -129,24 +146,29 @@ interface DigitalTranscriptWysiwygEntryProps { deleteCommittedEntry: (id: string) => TranscriptEntrySession | null; /** Live session rows for PDF export (includes in-progress autosaved work). */ onExportRowsChange?: (rows: TranscriptEntry[]) => void; - /** Funnel: register Back handler that commits session rows before navigate. */ - onRegisterBackCommit?: (commit: () => void) => void; + /** Funnel: register Save / Cancel / unsaved-check for the entry toolbar. */ + onRegisterFunnelToolbar?: (handlers: FunnelToolbarHandlers) => void; + /** Funnel: PDF download wired from the entry page (rendered in the preview pane). */ + funnelDownload?: FunnelDownloadProps; } +export type { FunnelDownloadProps }; + export function DigitalTranscriptWysiwygEntry({ - base: _base, formVariant, hydrated, entries, upsertCommittedEntry, deleteCommittedEntry, onExportRowsChange, - onRegisterBackCommit + onRegisterFunnelToolbar, + funnelDownload }: DigitalTranscriptWysiwygEntryProps) { const isFunnel = formVariant === 'funnel'; const [searchParams, setSearchParams] = useSearchParams(); const [session, setSession] = useState(null); - const [doneErrorRowId, setDoneErrorRowId] = useState(null); + const [saveErrorRowId, setSaveErrorRowId] = useState(null); + const [activeStep, setActiveStep] = useState(0); const [deleteConfirmFor, setDeleteConfirmFor] = useState(null); const baselinesRef = useRef>({}); const prevExpandedIdRef = useRef(null); @@ -208,27 +230,89 @@ export function DigitalTranscriptWysiwygEntry({ } }, [hydrated, searchParams, setSearchParams, captureBaseline, isFunnel]); - const commitSessionRowsForBack = useCallback(() => { + const hasUnsavedChanges = useCallback((): boolean => { const current = sessionRef.current; - if (!current) return; - let nextSession = current; - for (const row of current.rows) { - const saved: TranscriptEntry = { - ...row, - topSkills: row.topSkills.slice(0, TOP_SKILLS_MAX) - }; - upsertCommittedEntry(saved); - nextSession = syncSessionRowsAfterUpsert(nextSession, saved); + if (!current?.expandedId) return false; + const row = current.rows.find((r) => r.id === current.expandedId); + if (!row) return false; + const baseline = baselinesRef.current[row.id]; + if (!baseline) return entryHasExportableContent(row); + return !entryPayloadEqual(row, baseline); + }, []); + + const discardActiveRow = useCallback(() => { + const current = sessionRef.current; + if (!current?.expandedId) return; + const id = current.expandedId; + const baseline = baselinesRef.current[id]; + const committed = readTranscriptEntriesFromStorage().some((e) => e.id === id); + const row = current.rows.find((r) => r.id === id); + const restored = baseline + ? cloneTranscriptEntry(baseline) + : row + ? cloneTranscriptEntry(row) + : null; + if (!restored) return; + + let rows = current.rows.map((r) => (r.id === id ? restored : r)); + if (!committed && !entryHasExportableContent(restored)) { + rows = rows.filter((r) => r.id !== id); } - writeEntrySessionToStorage(nextSession); + const expandedId = rows.some((r) => r.id === id) ? id : (rows[0]?.id ?? null); + const next: TranscriptEntrySession = { + ...current, + rows, + expandedId, + lastPreviewId: expandedId + }; + writeEntrySessionToStorage(next); dispatchEntrySessionUpdated(); - setSession(nextSession); + setSession(next); + }, []); + + const handleSave = useCallback((): boolean => { + const current = sessionRef.current; + const id = current?.expandedId; + if (!id) return false; + const row = current.rows.find((r) => r.id === id); + if (!row) return false; + + const programOk = Boolean(row.programName.trim()); + const dateOk = Boolean(row.completionDate.trim()); + if (!programOk || !dateOk) { + setSaveErrorRowId(id); + setActiveStep(0); + return false; + } + + setSaveErrorRowId(null); + const saved: TranscriptEntry = { + ...row, + topSkills: row.topSkills.slice(0, TOP_SKILLS_MAX) + }; + upsertCommittedEntry(saved); + setSession((prev) => { + if (!prev) return prev; + const next = syncSessionRowsAfterUpsert(prev, saved); + return { ...next, expandedId: saved.id, lastPreviewId: saved.id }; + }); + baselinesRef.current[id] = cloneTranscriptEntry(saved); + return true; }, [upsertCommittedEntry]); + const handleFunnelCancel = useCallback(() => { + setSaveErrorRowId(null); + discardActiveRow(); + }, [discardActiveRow]); + useEffect(() => { - if (!isFunnel || !onRegisterBackCommit) return; - onRegisterBackCommit(commitSessionRowsForBack); - }, [isFunnel, onRegisterBackCommit, commitSessionRowsForBack]); + if (!isFunnel || !onRegisterFunnelToolbar) return; + onRegisterFunnelToolbar({ + save: handleSave, + cancel: handleFunnelCancel, + hasUnsavedChanges + }); + }, [isFunnel, onRegisterFunnelToolbar, handleSave, handleFunnelCancel, hasUnsavedChanges]); useEffect(() => { if (!session) return; @@ -276,7 +360,7 @@ export function DigitalTranscriptWysiwygEntry({ } return { ...prev, expandedId: id, lastPreviewId: id }; }); - setDoneErrorRowId(null); + setSaveErrorRowId(null); }, []); const handleAdd = useCallback(() => { @@ -290,7 +374,7 @@ export function DigitalTranscriptWysiwygEntry({ lastPreviewId: row.id }; }); - setDoneErrorRowId(null); + setSaveErrorRowId(null); }, []); const isCommittedEntryId = useCallback((id: string) => { @@ -300,7 +384,7 @@ export function DigitalTranscriptWysiwygEntry({ const handleCancel = useCallback( (id: string) => { const baseline = baselinesRef.current[id]; - setDoneErrorRowId(null); + setSaveErrorRowId(null); setSession((prev) => { if (!prev) return prev; const committed = isCommittedEntryId(id); @@ -333,10 +417,10 @@ export function DigitalTranscriptWysiwygEntry({ const programOk = Boolean(row.programName.trim()); const dateOk = Boolean(row.completionDate.trim()); if (!programOk || !dateOk) { - setDoneErrorRowId(id); + setSaveErrorRowId(id); return; } - setDoneErrorRowId(null); + setSaveErrorRowId(null); const saved: TranscriptEntry = { ...row, topSkills: row.topSkills.slice(0, TOP_SKILLS_MAX) @@ -357,6 +441,8 @@ export function DigitalTranscriptWysiwygEntry({ [session] ); + const funnelEntry = isFunnel ? (displayRows[0] ?? null) : null; + const expandedId = session?.expandedId ?? null; useLayoutEffect(() => { @@ -387,6 +473,53 @@ export function DigitalTranscriptWysiwygEntry({ data-slot="transcript-wysiwyg-outer" className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden" > + {isFunnel && funnelEntry ? ( + +
+
+ {FUNNEL_FORM_STEPS.map((step, index) => { + const answered = countFunnelStepFieldsAnswered( + index, + funnelEntry + ); + const total = step.fields.length; + const fillPct = + total > 0 ? (answered / total) * 100 : 0; + const isActive = index === activeStep; + return ( +
+ + {step.title} ({answered}/{total}) + + + + +
+ ); + })} +
+

+ {countFunnelFieldsAnswered(funnelEntry)} / {FUNNEL_FORM_FIELD_TOTAL}{' '} + questions answered +

+
+
+ ) : null} {/* Scroll contract: - Editor pane: header fixed; `transcript-achievement-list` scrolls vertically. @@ -395,17 +528,54 @@ export function DigitalTranscriptWysiwygEntry({ */}
*]:min-h-0', + isFunnel + ? 'gap-4 bg-muted p-4 min-[900px]:grid-cols-2' + : 'bg-muted max-[899px]:grid-rows-[minmax(0,1fr)_minmax(0,1fr)] min-[900px]:grid-cols-[5fr_7fr]' + )} > - + )} + + {isFunnel ? ( + + ) : (
-
- {displayRows.map((entry) => ( - handleToggleExpand(entry.id)} - onPatch={(patch) => patchRow(entry.id, patch)} - onCancel={() => handleCancel(entry.id)} - onDone={() => handleDone(entry.id)} - showDoneErrors={doneErrorRowId === entry.id} - showDelete={committedIds.has(entry.id)} - onDeleteRequest={() => setDeleteConfirmFor(entry)} - /> - ))} -
+
- - -
- -
+ )}
+

+ Learning record +

+

{residentName}

+

{programsCompletedLabel(programCount)}

+ + ); +} + +export interface LearningRecordExportContentProps { + rows: TranscriptEntry[]; + residentName: string; + /** Dim non-focused achievements in the live preview */ + anchorId?: string | null; + className?: string; + /** PDF: only render sections that have answers (no skeletons or placeholders). */ + filledSectionsOnly?: boolean; + /** Live funnel preview: header is replaced by the download action in the preview pane. */ + hidePreviewHeader?: boolean; + /** Funnel live preview: parent card supplies padding and background. */ + embeddedLivePreview?: boolean; +} + +export const LearningRecordExportContent = forwardRef< + HTMLDivElement, + LearningRecordExportContentProps +>(function LearningRecordExportContent( + { + rows, + residentName, + anchorId = null, + className, + filledSectionsOnly = false, + hidePreviewHeader = false, + embeddedLivePreview = false + }, + ref +) { + const highlightAnchor = Boolean(anchorId) && !filledSectionsOnly; + const showPreviewHeader = !hidePreviewHeader; + const shellClassName = embeddedLivePreview + ? 'learning-record-pdf-export bg-transparent' + : 'learning-record-pdf-export learning-record-print-root bg-background px-4 py-5 sm:px-5'; + + if (rows.length === 0) { + return ( +
+ {showPreviewHeader ? ( + + ) : null} +

+ Add an achievement on the left to see your record here. +

+
+ ); + } + + return ( +
+ {showPreviewHeader ? ( + + ) : null} +
+ {rows.map((entry) => ( +
+ +
+ ))} +
+
+ ); +}); diff --git a/frontend/src/pages/student/digital-transcript/ReflectionStepField.tsx b/frontend/src/pages/student/digital-transcript/ReflectionStepField.tsx new file mode 100644 index 000000000..b59582913 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/ReflectionStepField.tsx @@ -0,0 +1,106 @@ +import type { TranscriptEntry } from '@/types/digital-transcript'; +import { ConfidenceSegmentedControl } from './ConfidenceSegmentedControl'; +import { ReflectionTextField } from './ReflectionTextField'; +import { + reflectionStepByKey, + FUNNEL_REFLECTION_TEXT_NUDGES, + type ReflectionAnswerKey, + type ReflectionTextFieldKey +} from './transcriptReflectionConfig'; +import { TopSkillsTagField } from './TopSkillsTagField'; + +interface ReflectionStepFieldProps { + entry: TranscriptEntry; + stepKey: ReflectionAnswerKey; + onChange: (patch: Partial) => void; + /** Funnel: render topSkills as a single paragraph stored in topSkills[0]. */ + skillsAsParagraph?: boolean; + labelOverride?: string; + useFunnelNudges?: boolean; +} + +type EntryTextFieldKey = Exclude; + +function textFieldKey(key: ReflectionAnswerKey): EntryTextFieldKey | null { + if (key === 'topSkills' || key === 'confidence') return null; + return key; +} + +export function ReflectionStepField({ + entry, + stepKey, + onChange, + skillsAsParagraph = false, + labelOverride, + useFunnelNudges = false +}: ReflectionStepFieldProps) { + const step = reflectionStepByKey(stepKey); + const idPrefix = `ach-${stepKey}-${entry.id}`; + const label = labelOverride ?? step?.editorLabel ?? ''; + + if (stepKey === 'topSkills' && skillsAsParagraph) { + const value = entry.topSkills[0] ?? ''; + return ( + + onChange({ topSkills: v.trim() ? [v.trim()] : [] }) + } + fieldKey="topSkillsParagraph" + nudge={ + useFunnelNudges + ? FUNNEL_REFLECTION_TEXT_NUDGES.topSkillsParagraph + : undefined + } + /> + ); + } + + if (!step) return null; + + if (step.kind === 'tags') { + return ( + onChange({ topSkills })} + /> + ); + } + + if (step.kind === 'confidence') { + return ( +
+
+ {label} +
+ onChange({ confidence: v })} + labelledBy={idPrefix} + /> +
+ ); + } + + const fieldKey = textFieldKey(stepKey); + if (!fieldKey) return null; + + const value = entry[fieldKey]; + const funnelNudge = useFunnelNudges ? FUNNEL_REFLECTION_TEXT_NUDGES[fieldKey] : undefined; + + return ( + onChange({ [fieldKey]: v })} + fieldKey={fieldKey} + nudge={funnelNudge} + /> + ); +} diff --git a/frontend/src/pages/student/digital-transcript/ReflectionTextField.tsx b/frontend/src/pages/student/digital-transcript/ReflectionTextField.tsx new file mode 100644 index 000000000..ac022df35 --- /dev/null +++ b/frontend/src/pages/student/digital-transcript/ReflectionTextField.tsx @@ -0,0 +1,92 @@ +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { + getReflectionNudgeTone, + NUDGE_TONE_CLASSES, + nudgeTrackFillRatio, + type ReflectionTextFieldKey, + type ReflectionTextNudge, + REFLECTION_TEXT_NUDGES +} from './transcriptReflectionConfig'; + +interface ReflectionTextFieldProps { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + fieldKey: ReflectionTextFieldKey; + nudge?: ReflectionTextNudge; +} + +export function ReflectionTextField({ + id, + label, + value, + onChange, + fieldKey, + nudge: nudgeOverride +}: ReflectionTextFieldProps) { + const nudge = nudgeOverride ?? REFLECTION_TEXT_NUDGES[fieldKey]; + const len = value.length; + const tone = getReflectionNudgeTone(len, nudge); + const toneCls = NUDGE_TONE_CLASSES[tone]; + const fill = nudgeTrackFillRatio(len, nudge); + const slot = id.replace('wysiwyg-', 'transcript-'); + + function handleChange(next: string) { + if (next.length <= nudge.maxLength) onChange(next); + else onChange(next.slice(0, nudge.maxLength)); + } + + return ( +
+ +
+