@burdenoff/microfe-buildmyiq 2026.804.1 → 2026.804.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/buildmyiq/hooks/useBuildmyiqApi.d.ts +21 -3
- package/dist/buildmyiq/hooks/useBuildmyiqApi.d.ts.map +1 -1
- package/dist/buildmyiq/hooks/useBuildmyiqApi.js +116 -62
- package/dist/buildmyiq/hooks/useBuildmyiqApi.js.map +1 -1
- package/dist/buildmyiq/pages/DashboardPage.d.ts.map +1 -1
- package/dist/buildmyiq/pages/DashboardPage.js +3 -4
- package/dist/buildmyiq/pages/DashboardPage.js.map +1 -1
- package/dist/buildmyiq/pages/assessments/TakeAssessmentPage.d.ts.map +1 -1
- package/dist/buildmyiq/pages/assessments/TakeAssessmentPage.js +1 -2
- package/dist/buildmyiq/pages/assessments/TakeAssessmentPage.js.map +1 -1
- package/dist/buildmyiq/pages/catalog/CourseDetailPage.d.ts.map +1 -1
- package/dist/buildmyiq/pages/catalog/CourseDetailPage.js +14 -15
- package/dist/buildmyiq/pages/catalog/CourseDetailPage.js.map +1 -1
- package/dist/buildmyiq/pages/certificates/CertificatesPage.d.ts.map +1 -1
- package/dist/buildmyiq/pages/certificates/CertificatesPage.js +175 -175
- package/dist/buildmyiq/pages/certificates/CertificatesPage.js.map +1 -1
- package/dist/buildmyiq/pages/learning/MyLearningPage.js +2 -4
- package/dist/buildmyiq/pages/learning/MyLearningPage.js.map +1 -1
- package/dist/buildmyiq/pages/paths/PathsPage.d.ts.map +1 -1
- package/dist/buildmyiq/pages/paths/PathsPage.js +1 -2
- package/dist/buildmyiq/pages/paths/PathsPage.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CertificatesPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/certificates/CertificatesPage.tsx"],"sourcesContent":["/**\n * CertificatesPage — the credentialing surface for the workspace.\n * @module buildmyiq/pages/certificates\n *\n * The primary page of the Certificates section. Lists real `BuiCertificate`\n * rows (issued / expired / revoked) for the workspace, scoped by a \"Mine / All\"\n * learner toggle and an optional course filter. Facilitators can issue a new\n * certificate and revoke an issued one. Every value is derived from live `bui*`\n * data — no fabricated learners, scores, or serials.\n *\n * Reads: `buiListCertificates` (useBuiCertificates), `buiListCourses` (useBuiCourses).\n * Writes: `buiIssueCertificate` (useBuiIssueCertificate),\n * `buiRevokeCertificate` (useBuiRevokeCertificate).\n */\n\nimport { useMemo, useState, type FC } from 'react';\nimport { Plus, Award, Ban, Download, Search, ShieldCheck, CalendarClock } from 'lucide-react';\nimport {\n Button,\n Label,\n Input,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n MetricsCard,\n StatusBadge,\n DataTable,\n type Column,\n FilterTabs,\n type FilterOption,\n LoadingView,\n ErrorView,\n EmptyState,\n FileLink,\n Avatar,\n Pagination,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiCertificates,\n useBuiCourses,\n useBuiIssueCertificate,\n useBuiRevokeCertificate,\n} from '../../hooks';\nimport { formatDate, formatPercent } from '../../utils/formatters';\nimport type { BuiCertificate } from '../../types';\nimport { BuiPermissionButton } from '../../components/BuiPermissionButton';\n\ntype ScopeFilter = 'MINE' | 'ALL';\ntype CourseFilter = string | 'ALL';\n\nconst PAGE_SIZE = 8;\n\n/** Convert a native `<input type=\"date\">` value (YYYY-MM-DD) to a DateTime, or undefined. */\nconst toDateTime = (value: string): string | undefined => {\n const trimmed = value.trim();\n if (!trimmed) return undefined;\n const d = new Date(trimmed);\n return Number.isNaN(d.getTime()) ? undefined : d.toISOString();\n};\n\n/** A collision-resistant, human-legible default serial for a new credential. */\nconst generateSerial = (): string => `BMIQ-${Date.now().toString(36).toUpperCase()}`;\n\ninterface IssueFormState {\n courseId: string;\n learnerId: string;\n serial: string;\n finalScore: string;\n expiresAt: string;\n}\n\nexport const CertificatesPage: FC = () => {\n const { currentUser } = useBuildmyiq();\n const selfId = currentUser?.id;\n\n const [scope, setScope] = useState<ScopeFilter>(selfId ? 'MINE' : 'ALL');\n const [courseFilter, setCourseFilter] = useState<CourseFilter>('ALL');\n const [search, setSearch] = useState('');\n const [page, setPage] = useState(0);\n const [dialogOpen, setDialogOpen] = useState(false);\n const [formError, setFormError] = useState<string | null>(null);\n const [actionError, setActionError] = useState<string | null>(null);\n const [revokingId, setRevokingId] = useState<string | null>(null);\n\n const makeEmptyForm = (): IssueFormState => ({\n courseId: '',\n learnerId: selfId ?? '',\n serial: generateSerial(),\n finalScore: '',\n expiresAt: '',\n });\n const [form, setForm] = useState<IssueFormState>(makeEmptyForm);\n\n // Course lookup: powers the course filter, the issue-form course picker, and\n // the courseId → title mapping in the table.\n const coursesQuery = useBuiCourses({ limit: 100 });\n const courses = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const courseTitleById = useMemo(() => {\n const map = new Map<string, string>();\n for (const c of courses) map.set(c.id, c.title);\n return map;\n }, [courses]);\n\n const learnerId = scope === 'MINE' ? selfId : undefined;\n const courseId = courseFilter === 'ALL' ? undefined : courseFilter;\n\n const certsQuery = useBuiCertificates(courseId, learnerId);\n const issueCert = useBuiIssueCertificate();\n const revokeCert = useBuiRevokeCertificate();\n\n const rows = useMemo(() => certsQuery.data ?? [], [certsQuery.data]);\n\n // Client-side search over the loaded scope (serial / course / learner id).\n const filtered = useMemo(() => {\n const q = search.trim().toLowerCase();\n if (!q) return rows;\n return rows.filter((c) => {\n const course = (courseTitleById.get(c.courseId) ?? '').toLowerCase();\n const learner = c.learnerId === selfId ? 'you me mine' : c.learnerId.toLowerCase();\n return c.serial.toLowerCase().includes(q) || course.includes(q) || learner.includes(q);\n });\n }, [rows, search, courseTitleById, selfId]);\n\n // Honest KPI counts across the loaded scope (before search narrowing).\n const summary = useMemo(() => {\n let issued = 0;\n let expired = 0;\n let revoked = 0;\n for (const c of rows) {\n if (c.status === 'ISSUED') issued += 1;\n else if (c.status === 'EXPIRED') expired += 1;\n else if (c.status === 'REVOKED') revoked += 1;\n }\n return { total: rows.length, issued, expired, revoked };\n }, [rows]);\n\n const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));\n\n // Derive an in-range page (avoids a setState-in-effect clamp loop): if filters/\n // search shrink the result set below the current page, fall back to the last page.\n const safePage = Math.min(page, pageCount - 1);\n\n const pageRows = useMemo(\n () => filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE),\n [filtered, safePage]\n );\n\n const scopeOptions: FilterOption<ScopeFilter>[] = [\n { value: 'MINE', label: 'Mine' },\n { value: 'ALL', label: 'All' },\n ];\n\n const handleRevoke = async (cert: BuiCertificate): Promise<void> => {\n if (\n typeof window !== 'undefined' &&\n !window.confirm(`Revoke certificate ${cert.serial}? This cannot be undone.`)\n ) {\n return;\n }\n setActionError(null);\n setRevokingId(cert.id);\n try {\n await revokeCert.mutateAsync(cert.id);\n } catch (err) {\n setActionError(err instanceof Error ? err.message : 'Failed to revoke certificate.');\n } finally {\n setRevokingId(null);\n }\n };\n\n const columns: Column<BuiCertificate>[] = [\n {\n key: 'serial',\n header: 'Serial',\n sortValue: (c) => c.serial.toLowerCase(),\n render: (c) => (\n <div className=\"flex items-center gap-3\">\n <div className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-[var(--color-accent-primary)]\">\n <Award className=\"h-4 w-4\" />\n </div>\n <span className=\"min-w-0 truncate font-mono text-xs font-semibold text-text-primary\">\n {c.serial}\n </span>\n </div>\n ),\n },\n {\n key: 'course',\n header: 'Course',\n hideOnMobile: true,\n sortValue: (c) => (courseTitleById.get(c.courseId) ?? '').toLowerCase(),\n render: (c) => (\n <span className=\"text-text-secondary\">{courseTitleById.get(c.courseId) ?? '—'}</span>\n ),\n },\n {\n key: 'learner',\n header: 'Learner',\n sortValue: (c) => (c.learnerId === selfId ? '' : c.learnerId),\n render: (c) => {\n const isSelf = c.learnerId === selfId;\n const name = isSelf\n ? (currentUser?.name ?? currentUser?.username ?? currentUser?.email ?? null)\n : null;\n return (\n <div className=\"flex items-center gap-2.5\">\n <Avatar size=\"sm\" seed={c.learnerId} name={name} />\n {isSelf ? (\n <span className=\"font-medium text-text-primary\">You</span>\n ) : (\n <span className=\"font-mono text-xs text-text-tertiary\" title={c.learnerId}>\n {c.learnerId.slice(0, 8)}…\n </span>\n )}\n </div>\n );\n },\n },\n {\n key: 'status',\n header: 'Status',\n render: (c) => <StatusBadge status={c.status} />,\n },\n {\n key: 'issuedAt',\n header: 'Issued',\n hideOnMobile: true,\n sortValue: (c) => c.issuedAt,\n render: (c) => <span className=\"text-text-secondary\">{formatDate(c.issuedAt)}</span>,\n },\n {\n key: 'expiresAt',\n header: 'Expires',\n hideOnMobile: true,\n sortValue: (c) => c.expiresAt ?? '',\n render: (c) => <span className=\"text-text-tertiary\">{formatDate(c.expiresAt)}</span>,\n },\n {\n key: 'finalScore',\n header: 'Score',\n align: 'right',\n hideOnMobile: true,\n sortValue: (c) => c.finalScore ?? -1,\n render: (c) => (\n <span className=\"font-medium tabular-nums text-text-primary\">\n {c.finalScore != null ? formatPercent(c.finalScore) : '—'}\n </span>\n ),\n },\n {\n key: 'file',\n header: 'PDF',\n hideOnMobile: true,\n render: (c) =>\n c.file ? <FileLink fileId={c.file.id} /> : <span className=\"text-text-tertiary\">—</span>,\n },\n {\n key: 'actions',\n header: '',\n align: 'right',\n render: (c) =>\n c.status === 'ISSUED' ? (\n <BuiPermissionButton\n resource=\"buildmyiq-certificate\"\n action=\"update\"\n deniedBehavior=\"hide\"\n >\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"text-status-error-text hover:text-status-error-text\"\n disabled={revokingId === c.id}\n onClick={() => void handleRevoke(c)}\n >\n <Ban className=\"mr-1.5 h-4 w-4\" />\n {revokingId === c.id ? 'Revoking…' : 'Revoke'}\n </Button>\n </BuiPermissionButton>\n ) : null,\n },\n ];\n\n const openDialog = (): void => {\n setForm(makeEmptyForm());\n setFormError(null);\n setDialogOpen(true);\n };\n\n const closeDialog = (): void => {\n setDialogOpen(false);\n setForm(makeEmptyForm());\n setFormError(null);\n };\n\n const handleIssue = async (): Promise<void> => {\n if (!form.courseId) {\n setFormError('Please choose a course for this certificate.');\n return;\n }\n if (!form.learnerId.trim()) {\n setFormError('A learner (user ID) is required.');\n return;\n }\n if (!form.serial.trim()) {\n setFormError('A certificate serial is required.');\n return;\n }\n const scoreNum = form.finalScore.trim() ? Number(form.finalScore) : undefined;\n if (scoreNum !== undefined && (!Number.isFinite(scoreNum) || scoreNum < 0)) {\n setFormError('Final score must be a non-negative number.');\n return;\n }\n const expiresIso = toDateTime(form.expiresAt);\n\n setFormError(null);\n try {\n await issueCert.mutateAsync({\n courseId: form.courseId,\n learnerId: form.learnerId.trim(),\n serial: form.serial.trim(),\n finalScore: scoreNum,\n expiresAt: expiresIso,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to issue certificate.');\n }\n };\n\n const hasCourses = courses.length > 0;\n const isFiltered = scope === 'MINE' || courseFilter !== 'ALL' || search.trim().length > 0;\n const showSummary = !certsQuery.isLoading && !certsQuery.isError && summary.total > 0;\n\n return (\n <PageLayout\n title=\"Certificates\"\n description=\"Verifiable credentials issued to learners on course completion.\"\n titleGradient\n aurora\n actions={\n <BuiPermissionButton resource=\"buildmyiq-certificate\" action=\"create\" deniedBehavior=\"hide\">\n <Button onClick={openDialog} disabled={!hasCourses}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Issue certificate\n </Button>\n </BuiPermissionButton>\n }\n >\n {showSummary && (\n <div className=\"mb-5 grid grid-cols-2 gap-4 sm:grid-cols-4\">\n <MetricsCard\n icon={<Award className=\"h-5 w-5\" />}\n value={summary.total}\n label=\"Certificates\"\n featured\n />\n <MetricsCard\n icon={<ShieldCheck className=\"h-5 w-5\" />}\n value={summary.issued}\n label=\"Issued\"\n accent=\"success\"\n />\n <MetricsCard\n icon={<CalendarClock className=\"h-5 w-5\" />}\n value={summary.expired}\n label=\"Expired\"\n />\n <MetricsCard\n icon={<Ban className=\"h-5 w-5\" />}\n value={summary.revoked}\n label=\"Revoked\"\n accent=\"error\"\n />\n </div>\n )}\n\n <div className=\"mb-5 flex flex-wrap items-center gap-3\">\n <FilterTabs\n options={scopeOptions}\n value={scope}\n onChange={(v) => {\n setScope(v);\n setPage(0);\n }}\n />\n <Select\n value={courseFilter}\n onValueChange={(v) => {\n setCourseFilter(v as CourseFilter);\n setPage(0);\n }}\n >\n <SelectTrigger className=\"w-56\" aria-label=\"Filter by course\">\n <SelectValue placeholder=\"All courses\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"ALL\">All courses</SelectItem>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <div className=\"relative w-full sm:ml-auto sm:w-64\">\n <Search className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary\" />\n <Input\n value={search}\n onChange={(e) => {\n setSearch(e.target.value);\n setPage(0);\n }}\n placeholder=\"Search certificates…\"\n aria-label=\"Search certificates\"\n className=\"pl-9\"\n />\n </div>\n </div>\n\n {actionError && (\n <p className=\"mb-4 rounded-lg bg-status-error-bg-subtle px-3 py-2 text-sm text-status-error-text\">\n {actionError}\n </p>\n )}\n\n {certsQuery.isLoading ? (\n <LoadingView label=\"Loading certificates…\" />\n ) : certsQuery.isError ? (\n <ErrorView message=\"Couldn't load certificates.\" onRetry={() => certsQuery.refetch()} />\n ) : filtered.length === 0 ? (\n <EmptyState\n illustration={isFiltered ? 'empty-search' : 'onboarding'}\n icon={<Award className=\"h-10 w-10\" />}\n title={isFiltered ? 'No certificates match these filters' : 'No certificates issued yet'}\n description={\n isFiltered\n ? search.trim().length > 0\n ? 'No credentials match your search. Try a different serial, course, or learner.'\n : scope === 'MINE'\n ? \"You haven't earned a certificate for this selection yet.\"\n : 'No certificates have been issued for this course yet.'\n : hasCourses\n ? 'Issue a certificate to credential a learner who has completed a course.'\n : 'Publish a course first, then issue certificates to learners who complete it.'\n }\n action={\n !isFiltered && hasCourses ? (\n <BuiPermissionButton\n resource=\"buildmyiq-certificate\"\n action=\"create\"\n deniedBehavior=\"hide\"\n >\n <Button onClick={openDialog}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Issue certificate\n </Button>\n </BuiPermissionButton>\n ) : undefined\n }\n />\n ) : (\n <>\n <DataTable\n columns={columns}\n rows={pageRows}\n rowKey={(c) => c.id}\n defaultSort={{ key: 'issuedAt', dir: 'desc' }}\n />\n <Pagination\n page={safePage}\n pageSize={PAGE_SIZE}\n itemCount={pageRows.length}\n hasMore={(safePage + 1) * PAGE_SIZE < filtered.length}\n onPageChange={setPage}\n />\n </>\n )}\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (!open) closeDialog();\n else setDialogOpen(true);\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Issue certificate</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Credential a learner for completing a course. The serial is auto-generated but\n editable.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <form\n onSubmit={(e) => {\n e.preventDefault();\n void handleIssue();\n }}\n >\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-course\">Course *</Label>\n <Select\n value={form.courseId}\n onValueChange={(v) => setForm((prev) => ({ ...prev, courseId: v }))}\n >\n <SelectTrigger\n id=\"cert-course\"\n className=\"data-[placeholder]:text-text-secondary!\"\n >\n <SelectValue placeholder=\"Select a course\" />\n </SelectTrigger>\n <SelectContent>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-learner\">Learner (user ID) *</Label>\n <Input\n id=\"cert-learner\"\n value={form.learnerId}\n onChange={(e) => setForm((prev) => ({ ...prev, learnerId: e.target.value }))}\n placeholder=\"Defaults to you\"\n />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-serial\">Serial *</Label>\n <Input\n id=\"cert-serial\"\n value={form.serial}\n onChange={(e) => setForm((prev) => ({ ...prev, serial: e.target.value }))}\n placeholder=\"BMIQ-XXXXXX\"\n />\n </div>\n\n <div className=\"grid gap-2 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-score\">Final score</Label>\n <Input\n id=\"cert-score\"\n type=\"number\"\n min=\"0\"\n step=\"0.1\"\n value={form.finalScore}\n onChange={(e) => setForm((prev) => ({ ...prev, finalScore: e.target.value }))}\n placeholder=\"e.g. 92\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-expires\">Expires</Label>\n <Input\n id=\"cert-expires\"\n type=\"date\"\n value={form.expiresAt}\n onChange={(e) => setForm((prev) => ({ ...prev, expiresAt: e.target.value }))}\n />\n </div>\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button type=\"submit\" disabled={issueCert.isPending}>\n <Download className=\"mr-1.5 h-4 w-4\" />\n {issueCert.isPending ? 'Issuing…' : 'Issue certificate'}\n </Button>\n </ResponsiveDialogFooter>\n </form>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CertificatesPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/certificates/CertificatesPage.tsx"],"sourcesContent":["/**\n * CertificatesPage — the credentialing surface for the workspace.\n * @module buildmyiq/pages/certificates\n *\n * The primary page of the Certificates section. Lists real `BuiCertificate`\n * rows (issued / expired / revoked) for the workspace, scoped by a \"Mine / All\"\n * learner toggle and an optional course filter. Facilitators can issue a new\n * certificate and revoke an issued one. Every value is derived from live `bui*`\n * data — no fabricated learners, scores, or serials.\n *\n * Reads: `buiListCertificates` (useBuiCertificates), `buiListCourses` (useBuiCourses).\n * Writes: `buiIssueCertificate` (useBuiIssueCertificate),\n * `buiRevokeCertificate` (useBuiRevokeCertificate).\n */\n\nimport { useMemo, useState, type FC } from 'react';\nimport { Plus, Award, Ban, Download, Search, ShieldCheck, CalendarClock } from 'lucide-react';\nimport {\n Button,\n Label,\n Input,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n MetricsCard,\n StatusBadge,\n DataTable,\n type Column,\n FilterTabs,\n type FilterOption,\n LoadingView,\n ErrorView,\n EmptyState,\n FileLink,\n Avatar,\n Pagination,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiCertificates,\n useBuiMyCertificates,\n useBuiCourses,\n useBuiIssueCertificate,\n useBuiRevokeCertificate,\n} from '../../hooks';\nimport { formatDate, formatPercent } from '../../utils/formatters';\nimport type { BuiCertificate } from '../../types';\nimport { BuiPermissionButton } from '../../components/BuiPermissionButton';\n\ntype ScopeFilter = 'MINE' | 'ALL';\ntype CourseFilter = string | 'ALL';\n\nconst PAGE_SIZE = 8;\n\n/** Convert a native `<input type=\"date\">` value (YYYY-MM-DD) to a DateTime, or undefined. */\nconst toDateTime = (value: string): string | undefined => {\n const trimmed = value.trim();\n if (!trimmed) return undefined;\n const d = new Date(trimmed);\n return Number.isNaN(d.getTime()) ? undefined : d.toISOString();\n};\n\n/** A collision-resistant, human-legible default serial for a new credential. */\nconst generateSerial = (): string => `BMIQ-${Date.now().toString(36).toUpperCase()}`;\n\ninterface IssueFormState {\n courseId: string;\n learnerId: string;\n serial: string;\n finalScore: string;\n expiresAt: string;\n}\n\nexport const CertificatesPage: FC = () => {\n const { currentUser } = useBuildmyiq();\n const selfId = currentUser?.id;\n\n const [scope, setScope] = useState<ScopeFilter>(selfId ? 'MINE' : 'ALL');\n const [courseFilter, setCourseFilter] = useState<CourseFilter>('ALL');\n const [search, setSearch] = useState('');\n const [page, setPage] = useState(0);\n const [dialogOpen, setDialogOpen] = useState(false);\n const [formError, setFormError] = useState<string | null>(null);\n const [actionError, setActionError] = useState<string | null>(null);\n const [revokingId, setRevokingId] = useState<string | null>(null);\n\n const makeEmptyForm = (): IssueFormState => ({\n courseId: '',\n learnerId: selfId ?? '',\n serial: generateSerial(),\n finalScore: '',\n expiresAt: '',\n });\n const [form, setForm] = useState<IssueFormState>(makeEmptyForm);\n\n // Course lookup: powers the course filter, the issue-form course picker, and\n // the courseId → title mapping in the table.\n const coursesQuery = useBuiCourses({ limit: 100 });\n const courses = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const courseTitleById = useMemo(() => {\n const map = new Map<string, string>();\n for (const c of courses) map.set(c.id, c.title);\n return map;\n }, [courses]);\n\n const courseId = courseFilter === 'ALL' ? undefined : courseFilter;\n\n // MINE → self-scoped read of the caller's own certificates; ALL → the\n // workspace-wide instructor list. Both hooks always mount; only the active\n // scope fetches (FERPA hardening removed the learnerId arg from the list).\n const mineCertsQuery = useBuiMyCertificates(courseId, scope === 'MINE');\n const allCertsQuery = useBuiCertificates(courseId, scope === 'ALL');\n const certsQuery = scope === 'MINE' ? mineCertsQuery : allCertsQuery;\n const issueCert = useBuiIssueCertificate();\n const revokeCert = useBuiRevokeCertificate();\n\n const rows = useMemo(() => certsQuery.data ?? [], [certsQuery.data]);\n\n // Client-side search over the loaded scope (serial / course / learner id).\n const filtered = useMemo(() => {\n const q = search.trim().toLowerCase();\n if (!q) return rows;\n return rows.filter((c) => {\n const course = (courseTitleById.get(c.courseId) ?? '').toLowerCase();\n const learner = c.learnerId === selfId ? 'you me mine' : c.learnerId.toLowerCase();\n return c.serial.toLowerCase().includes(q) || course.includes(q) || learner.includes(q);\n });\n }, [rows, search, courseTitleById, selfId]);\n\n // Honest KPI counts across the loaded scope (before search narrowing).\n const summary = useMemo(() => {\n let issued = 0;\n let expired = 0;\n let revoked = 0;\n for (const c of rows) {\n if (c.status === 'ISSUED') issued += 1;\n else if (c.status === 'EXPIRED') expired += 1;\n else if (c.status === 'REVOKED') revoked += 1;\n }\n return { total: rows.length, issued, expired, revoked };\n }, [rows]);\n\n const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));\n\n // Derive an in-range page (avoids a setState-in-effect clamp loop): if filters/\n // search shrink the result set below the current page, fall back to the last page.\n const safePage = Math.min(page, pageCount - 1);\n\n const pageRows = useMemo(\n () => filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE),\n [filtered, safePage]\n );\n\n const scopeOptions: FilterOption<ScopeFilter>[] = [\n { value: 'MINE', label: 'Mine' },\n { value: 'ALL', label: 'All' },\n ];\n\n const handleRevoke = async (cert: BuiCertificate): Promise<void> => {\n if (\n typeof window !== 'undefined' &&\n !window.confirm(`Revoke certificate ${cert.serial}? This cannot be undone.`)\n ) {\n return;\n }\n setActionError(null);\n setRevokingId(cert.id);\n try {\n await revokeCert.mutateAsync(cert.id);\n } catch (err) {\n setActionError(err instanceof Error ? err.message : 'Failed to revoke certificate.');\n } finally {\n setRevokingId(null);\n }\n };\n\n const columns: Column<BuiCertificate>[] = [\n {\n key: 'serial',\n header: 'Serial',\n sortValue: (c) => c.serial.toLowerCase(),\n render: (c) => (\n <div className=\"flex items-center gap-3\">\n <div className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-[var(--color-accent-primary)]\">\n <Award className=\"h-4 w-4\" />\n </div>\n <span className=\"min-w-0 truncate font-mono text-xs font-semibold text-text-primary\">\n {c.serial}\n </span>\n </div>\n ),\n },\n {\n key: 'course',\n header: 'Course',\n hideOnMobile: true,\n sortValue: (c) => (courseTitleById.get(c.courseId) ?? '').toLowerCase(),\n render: (c) => (\n <span className=\"text-text-secondary\">{courseTitleById.get(c.courseId) ?? '—'}</span>\n ),\n },\n {\n key: 'learner',\n header: 'Learner',\n sortValue: (c) => (c.learnerId === selfId ? '' : c.learnerId),\n render: (c) => {\n const isSelf = c.learnerId === selfId;\n const name = isSelf\n ? (currentUser?.name ?? currentUser?.username ?? currentUser?.email ?? null)\n : null;\n return (\n <div className=\"flex items-center gap-2.5\">\n <Avatar size=\"sm\" seed={c.learnerId} name={name} />\n {isSelf ? (\n <span className=\"font-medium text-text-primary\">You</span>\n ) : (\n <span className=\"font-mono text-xs text-text-tertiary\" title={c.learnerId}>\n {c.learnerId.slice(0, 8)}…\n </span>\n )}\n </div>\n );\n },\n },\n {\n key: 'status',\n header: 'Status',\n render: (c) => <StatusBadge status={c.status} />,\n },\n {\n key: 'issuedAt',\n header: 'Issued',\n hideOnMobile: true,\n sortValue: (c) => c.issuedAt,\n render: (c) => <span className=\"text-text-secondary\">{formatDate(c.issuedAt)}</span>,\n },\n {\n key: 'expiresAt',\n header: 'Expires',\n hideOnMobile: true,\n sortValue: (c) => c.expiresAt ?? '',\n render: (c) => <span className=\"text-text-tertiary\">{formatDate(c.expiresAt)}</span>,\n },\n {\n key: 'finalScore',\n header: 'Score',\n align: 'right',\n hideOnMobile: true,\n sortValue: (c) => c.finalScore ?? -1,\n render: (c) => (\n <span className=\"font-medium tabular-nums text-text-primary\">\n {c.finalScore != null ? formatPercent(c.finalScore) : '—'}\n </span>\n ),\n },\n {\n key: 'file',\n header: 'PDF',\n hideOnMobile: true,\n render: (c) =>\n c.file ? <FileLink fileId={c.file.id} /> : <span className=\"text-text-tertiary\">—</span>,\n },\n {\n key: 'actions',\n header: '',\n align: 'right',\n render: (c) =>\n c.status === 'ISSUED' ? (\n <BuiPermissionButton\n resource=\"buildmyiq-certificate\"\n action=\"update\"\n deniedBehavior=\"hide\"\n >\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"text-status-error-text hover:text-status-error-text\"\n disabled={revokingId === c.id}\n onClick={() => void handleRevoke(c)}\n >\n <Ban className=\"mr-1.5 h-4 w-4\" />\n {revokingId === c.id ? 'Revoking…' : 'Revoke'}\n </Button>\n </BuiPermissionButton>\n ) : null,\n },\n ];\n\n const openDialog = (): void => {\n setForm(makeEmptyForm());\n setFormError(null);\n setDialogOpen(true);\n };\n\n const closeDialog = (): void => {\n setDialogOpen(false);\n setForm(makeEmptyForm());\n setFormError(null);\n };\n\n const handleIssue = async (): Promise<void> => {\n if (!form.courseId) {\n setFormError('Please choose a course for this certificate.');\n return;\n }\n if (!form.learnerId.trim()) {\n setFormError('A learner (user ID) is required.');\n return;\n }\n if (!form.serial.trim()) {\n setFormError('A certificate serial is required.');\n return;\n }\n const scoreNum = form.finalScore.trim() ? Number(form.finalScore) : undefined;\n if (scoreNum !== undefined && (!Number.isFinite(scoreNum) || scoreNum < 0)) {\n setFormError('Final score must be a non-negative number.');\n return;\n }\n const expiresIso = toDateTime(form.expiresAt);\n\n setFormError(null);\n try {\n await issueCert.mutateAsync({\n courseId: form.courseId,\n learnerId: form.learnerId.trim(),\n serial: form.serial.trim(),\n finalScore: scoreNum,\n expiresAt: expiresIso,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to issue certificate.');\n }\n };\n\n const hasCourses = courses.length > 0;\n const isFiltered = scope === 'MINE' || courseFilter !== 'ALL' || search.trim().length > 0;\n const showSummary = !certsQuery.isLoading && !certsQuery.isError && summary.total > 0;\n\n return (\n <PageLayout\n title=\"Certificates\"\n description=\"Verifiable credentials issued to learners on course completion.\"\n titleGradient\n aurora\n actions={\n <BuiPermissionButton resource=\"buildmyiq-certificate\" action=\"create\" deniedBehavior=\"hide\">\n <Button onClick={openDialog} disabled={!hasCourses}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Issue certificate\n </Button>\n </BuiPermissionButton>\n }\n >\n {showSummary && (\n <div className=\"mb-5 grid grid-cols-2 gap-4 sm:grid-cols-4\">\n <MetricsCard\n icon={<Award className=\"h-5 w-5\" />}\n value={summary.total}\n label=\"Certificates\"\n featured\n />\n <MetricsCard\n icon={<ShieldCheck className=\"h-5 w-5\" />}\n value={summary.issued}\n label=\"Issued\"\n accent=\"success\"\n />\n <MetricsCard\n icon={<CalendarClock className=\"h-5 w-5\" />}\n value={summary.expired}\n label=\"Expired\"\n />\n <MetricsCard\n icon={<Ban className=\"h-5 w-5\" />}\n value={summary.revoked}\n label=\"Revoked\"\n accent=\"error\"\n />\n </div>\n )}\n\n <div className=\"mb-5 flex flex-wrap items-center gap-3\">\n <FilterTabs\n options={scopeOptions}\n value={scope}\n onChange={(v) => {\n setScope(v);\n setPage(0);\n }}\n />\n <Select\n value={courseFilter}\n onValueChange={(v) => {\n setCourseFilter(v as CourseFilter);\n setPage(0);\n }}\n >\n <SelectTrigger className=\"w-56\" aria-label=\"Filter by course\">\n <SelectValue placeholder=\"All courses\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"ALL\">All courses</SelectItem>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <div className=\"relative w-full sm:ml-auto sm:w-64\">\n <Search className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary\" />\n <Input\n value={search}\n onChange={(e) => {\n setSearch(e.target.value);\n setPage(0);\n }}\n placeholder=\"Search certificates…\"\n aria-label=\"Search certificates\"\n className=\"pl-9\"\n />\n </div>\n </div>\n\n {actionError && (\n <p className=\"mb-4 rounded-lg bg-status-error-bg-subtle px-3 py-2 text-sm text-status-error-text\">\n {actionError}\n </p>\n )}\n\n {certsQuery.isLoading ? (\n <LoadingView label=\"Loading certificates…\" />\n ) : certsQuery.isError ? (\n <ErrorView message=\"Couldn't load certificates.\" onRetry={() => certsQuery.refetch()} />\n ) : filtered.length === 0 ? (\n <EmptyState\n illustration={isFiltered ? 'empty-search' : 'onboarding'}\n icon={<Award className=\"h-10 w-10\" />}\n title={isFiltered ? 'No certificates match these filters' : 'No certificates issued yet'}\n description={\n isFiltered\n ? search.trim().length > 0\n ? 'No credentials match your search. Try a different serial, course, or learner.'\n : scope === 'MINE'\n ? \"You haven't earned a certificate for this selection yet.\"\n : 'No certificates have been issued for this course yet.'\n : hasCourses\n ? 'Issue a certificate to credential a learner who has completed a course.'\n : 'Publish a course first, then issue certificates to learners who complete it.'\n }\n action={\n !isFiltered && hasCourses ? (\n <BuiPermissionButton\n resource=\"buildmyiq-certificate\"\n action=\"create\"\n deniedBehavior=\"hide\"\n >\n <Button onClick={openDialog}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Issue certificate\n </Button>\n </BuiPermissionButton>\n ) : undefined\n }\n />\n ) : (\n <>\n <DataTable\n columns={columns}\n rows={pageRows}\n rowKey={(c) => c.id}\n defaultSort={{ key: 'issuedAt', dir: 'desc' }}\n />\n <Pagination\n page={safePage}\n pageSize={PAGE_SIZE}\n itemCount={pageRows.length}\n hasMore={(safePage + 1) * PAGE_SIZE < filtered.length}\n onPageChange={setPage}\n />\n </>\n )}\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (!open) closeDialog();\n else setDialogOpen(true);\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Issue certificate</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Credential a learner for completing a course. The serial is auto-generated but\n editable.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <form\n onSubmit={(e) => {\n e.preventDefault();\n void handleIssue();\n }}\n >\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-course\">Course *</Label>\n <Select\n value={form.courseId}\n onValueChange={(v) => setForm((prev) => ({ ...prev, courseId: v }))}\n >\n <SelectTrigger\n id=\"cert-course\"\n className=\"data-[placeholder]:text-text-secondary!\"\n >\n <SelectValue placeholder=\"Select a course\" />\n </SelectTrigger>\n <SelectContent>\n {courses.map((c) => (\n <SelectItem key={c.id} value={c.id}>\n {c.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-learner\">Learner (user ID) *</Label>\n <Input\n id=\"cert-learner\"\n value={form.learnerId}\n onChange={(e) => setForm((prev) => ({ ...prev, learnerId: e.target.value }))}\n placeholder=\"Defaults to you\"\n />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-serial\">Serial *</Label>\n <Input\n id=\"cert-serial\"\n value={form.serial}\n onChange={(e) => setForm((prev) => ({ ...prev, serial: e.target.value }))}\n placeholder=\"BMIQ-XXXXXX\"\n />\n </div>\n\n <div className=\"grid gap-2 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-score\">Final score</Label>\n <Input\n id=\"cert-score\"\n type=\"number\"\n min=\"0\"\n step=\"0.1\"\n value={form.finalScore}\n onChange={(e) => setForm((prev) => ({ ...prev, finalScore: e.target.value }))}\n placeholder=\"e.g. 92\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"cert-expires\">Expires</Label>\n <Input\n id=\"cert-expires\"\n type=\"date\"\n value={form.expiresAt}\n onChange={(e) => setForm((prev) => ({ ...prev, expiresAt: e.target.value }))}\n />\n </div>\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button type=\"submit\" disabled={issueCert.isPending}>\n <Download className=\"mr-1.5 h-4 w-4\" />\n {issueCert.isPending ? 'Issuing…' : 'Issue certificate'}\n </Button>\n </ResponsiveDialogFooter>\n </form>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
@@ -10,7 +10,7 @@ import { DataTable as d } from "../../components/DataTable.js";
|
|
|
10
10
|
import { Tabs as f } from "../../components/Tabs.js";
|
|
11
11
|
import { MiniBar as p } from "../../components/MiniBar.js";
|
|
12
12
|
import { ProgressRing as m, Sparkline as h } from "../../components/Viz.js";
|
|
13
|
-
import {
|
|
13
|
+
import { useBuiCourses as g, useBuiMyCertificates as ee, useBuiMyEnrollments as te, useBuiMySubmissions as ne } from "../../hooks/useBuildmyiqApi.js";
|
|
14
14
|
import "../../hooks/index.js";
|
|
15
15
|
import { FileLink as _ } from "../../components/FileLink.js";
|
|
16
16
|
import "../../components/index.js";
|
|
@@ -21,11 +21,9 @@ import { Button as k, GlassCard as A } from "@burdenoff/fe-libs/ui";
|
|
|
21
21
|
//#region src/buildmyiq/pages/learning/MyLearningPage.tsx
|
|
22
22
|
var oe = new Set(["ENROLLED", "IN_PROGRESS"]), se = (e) => e.passed == null ? "text-text-primary" : e.passed ? "text-status-success-text" : "text-status-error-text", j = () => {
|
|
23
23
|
let { currentUser: j, navigateTo: M } = e(), N = j?.id, [P, F] = y("in_progress"), I = te({
|
|
24
|
-
learnerId: N,
|
|
25
24
|
limit: 100,
|
|
26
25
|
enabled: !!N
|
|
27
|
-
}), L =
|
|
28
|
-
learnerId: N,
|
|
26
|
+
}), L = g({ limit: 100 }), R = ee(void 0, !!N), z = ne({
|
|
29
27
|
limit: 50,
|
|
30
28
|
enabled: !!N
|
|
31
29
|
}), B = v(() => [...z.data?.items ?? []].sort((e, t) => (t.submittedAt ?? t.createdAt).localeCompare(e.submittedAt ?? e.createdAt)), [z.data]), V = v(() => I.data?.items ?? [], [I.data]), H = v(() => L.data?.items ?? [], [L.data]), U = v(() => R.data ?? [], [R.data]), W = v(() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MyLearningPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/learning/MyLearningPage.tsx"],"sourcesContent":["/**\n * MyLearningPage — the caller's personal learning surface.\n * @module buildmyiq/pages/learning\n *\n * Renders the current learner's real enrollments (`buiListEnrollments` filtered\n * by `learnerId`) grouped by progress, their assessment results\n * (`buiListSubmissions`), and the credentials they have earned\n * (`buiListCertificates`). Course titles + covers are resolved from\n * `buiListCourses` (id → course map, falling back to \"Course\"). Every list\n * carries an honest loading / error / empty state — no fabricated rows.\n */\n\nimport type { FC } from 'react';\nimport { useMemo, useState } from 'react';\nimport {\n GraduationCap,\n Award,\n ArrowRight,\n PlayCircle,\n ClipboardCheck,\n CheckCircle2,\n XCircle,\n Users,\n Calendar,\n TrendingUp,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n Tabs,\n StatusBadge,\n MiniBar,\n EmptyState,\n LoadingView,\n ErrorView,\n Section,\n FileLink,\n DataTable,\n ProgressRing,\n Sparkline,\n} from '../../components';\nimport type { TabItem, Column } from '../../components';\nimport { cn } from '../../utils/cn';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiEnrollments,\n useBuiCourses,\n useBuiCertificates,\n useBuiSubmissions,\n} from '../../hooks';\nimport { formatRelativeTime, formatDate, formatDateTime } from '../../utils/formatters';\nimport type { BuiEnrollment, BuiCourse, BuiCertificate, BuiSubmission } from '../../types';\n\ntype LearningTab = 'in_progress' | 'completed' | 'all';\n\nconst IN_PROGRESS_STATUSES = new Set(['ENROLLED', 'IN_PROGRESS']);\n\nconst scoreClass = (s: BuiSubmission): string =>\n s.passed == null\n ? 'text-text-primary'\n : s.passed\n ? 'text-status-success-text'\n : 'text-status-error-text';\n\nexport const MyLearningPage: FC = () => {\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const [tab, setTab] = useState<LearningTab>('in_progress');\n\n const enrollmentsQuery = useBuiEnrollments({\n learnerId,\n limit: 100,\n enabled: Boolean(learnerId),\n });\n const coursesQuery = useBuiCourses({ limit: 100 });\n const certsQuery = useBuiCertificates(undefined, learnerId);\n const submissionsQuery = useBuiSubmissions({\n learnerId,\n limit: 50,\n enabled: Boolean(learnerId),\n });\n const submissions = useMemo(\n () =>\n [...(submissionsQuery.data?.items ?? [])].sort((a, b) =>\n (b.submittedAt ?? b.createdAt).localeCompare(a.submittedAt ?? a.createdAt)\n ),\n [submissionsQuery.data]\n );\n\n const enrollments: BuiEnrollment[] = useMemo(\n () => enrollmentsQuery.data?.items ?? [],\n [enrollmentsQuery.data]\n );\n const courses: BuiCourse[] = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const certificates: BuiCertificate[] = useMemo(() => certsQuery.data ?? [], [certsQuery.data]);\n\n const courseById = useMemo(() => {\n const map = new Map<string, BuiCourse>();\n for (const c of courses) map.set(c.id, c);\n return map;\n }, [courses]);\n\n const enrollmentById = useMemo(() => {\n const map = new Map<string, BuiEnrollment>();\n for (const e of enrollments) map.set(e.id, e);\n return map;\n }, [enrollments]);\n\n const titleFor = (courseId: string): string => courseById.get(courseId)?.title ?? 'Course';\n\n /** Resolve a submission's course via its enrollment (falls back honestly). */\n const courseTitleForSubmission = (s: BuiSubmission): string => {\n const enr = s.enrollmentId ? enrollmentById.get(s.enrollmentId) : undefined;\n return enr ? titleFor(enr.courseId) : 'Assessment';\n };\n\n const inProgress = useMemo(\n () => enrollments.filter((e) => IN_PROGRESS_STATUSES.has(e.status)),\n [enrollments]\n );\n const completed = useMemo(\n () => enrollments.filter((e) => e.status === 'COMPLETED'),\n [enrollments]\n );\n\n const visible = useMemo(() => {\n const rows = tab === 'in_progress' ? inProgress : tab === 'completed' ? completed : enrollments;\n return [...rows].sort((a, b) =>\n (b.lastActiveAt ?? b.enrolledAt).localeCompare(a.lastActiveAt ?? a.enrolledAt)\n );\n }, [tab, inProgress, completed, enrollments]);\n\n const sortedCertificates = useMemo(\n () => [...certificates].sort((a, b) => b.issuedAt.localeCompare(a.issuedAt)),\n [certificates]\n );\n\n // Derived score summary (real graded submissions only) — powers the trend strip.\n const graded = useMemo(() => submissions.filter((s) => s.score != null), [submissions]);\n const scoreTrend = useMemo(() => [...graded].reverse().map((s) => s.score as number), [graded]);\n const avgScore =\n graded.length > 0\n ? Math.round(graded.reduce((sum, s) => sum + (s.score ?? 0), 0) / graded.length)\n : null;\n const passedCount = useMemo(\n () => submissions.filter((s) => s.passed === true).length,\n [submissions]\n );\n\n const tabs: TabItem<LearningTab>[] = [\n { value: 'in_progress', label: 'In progress', count: inProgress.length },\n { value: 'completed', label: 'Completed', count: completed.length },\n { value: 'all', label: 'All', count: enrollments.length },\n ];\n\n const isLoading = Boolean(learnerId) && (enrollmentsQuery.isLoading || coursesQuery.isLoading);\n\n const renderEnrollments = () => {\n if (!learnerId) {\n return (\n <EmptyState\n illustration=\"empty-people\"\n title=\"Sign in to see your learning\"\n description=\"Your enrollments and progress appear here once you are signed in to the workspace.\"\n />\n );\n }\n if (isLoading) return <LoadingView label=\"Loading your enrollments…\" />;\n if (enrollmentsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your learning\"\n message={\n enrollmentsQuery.error instanceof Error ? enrollmentsQuery.error.message : undefined\n }\n onRetry={() => enrollmentsQuery.refetch()}\n />\n );\n }\n if (enrollments.length === 0) {\n return (\n <EmptyState\n illustration=\"onboarding\"\n title=\"You're not enrolled in anything yet\"\n description=\"Browse the catalog and enrol in a course to start learning.\"\n action={<Button onClick={() => navigateTo('catalog')}>Explore the catalog</Button>}\n />\n );\n }\n if (visible.length === 0) {\n const label =\n tab === 'in_progress'\n ? 'No courses in progress'\n : tab === 'completed'\n ? 'No completed courses yet'\n : 'No courses';\n return (\n <EmptyState\n illustration=\"empty-data\"\n title={label}\n description={\n tab === 'completed'\n ? 'Finish a course to see it here — and earn a certificate.'\n : 'Switch tabs or enrol in a new course to keep learning.'\n }\n />\n );\n }\n return (\n <div className=\"grid grid-cols-1 gap-4 xl:grid-cols-2\">\n {visible.map((e) => {\n const course = courseById.get(e.courseId);\n const cover = course?.cover?.downloadUrl ?? null;\n const isCompleted = e.status === 'COMPLETED';\n const pct = Math.round(e.progressPct ?? 0);\n return (\n <GlassCard\n key={e.id}\n treatment=\"plain\"\n className=\"group flex flex-col overflow-hidden transition-shadow hover:shadow-lg sm:flex-row\"\n >\n {/* Media tile — real cover when available, soft accent fallback; play\n affordance + a live progress ring badge. */}\n <div className=\"relative aspect-video w-full shrink-0 overflow-hidden bg-[var(--color-accent-soft)] sm:aspect-auto sm:w-44\">\n {cover ? (\n <img\n src={cover}\n alt=\"\"\n className=\"h-full w-full object-cover transition-transform duration-500 group-hover:scale-105\"\n />\n ) : null}\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <span className=\"flex h-11 w-11 items-center justify-center rounded-full bg-bg-surface/85 text-text-primary shadow-sm backdrop-blur transition-transform group-hover:scale-110\">\n <PlayCircle className=\"h-6 w-6\" />\n </span>\n </div>\n <div className=\"absolute bottom-2 left-2 rounded-full bg-bg-surface/85 p-0.5 shadow-sm backdrop-blur\">\n <ProgressRing\n value={pct}\n size={38}\n stroke={5}\n color={\n isCompleted ? 'var(--color-status-success-text)' : 'var(--color-accent-teal)'\n }\n />\n </div>\n </div>\n\n {/* Content */}\n <div className=\"flex min-w-0 flex-1 flex-col gap-2 p-4\">\n <div className=\"flex flex-wrap items-start gap-2\">\n <p className=\"min-w-0 flex-1 truncate font-semibold text-text-primary\">\n {titleFor(e.courseId)}\n </p>\n <StatusBadge status={e.status} />\n </div>\n\n {(course?.level || course?.skill?.name || e.cohort?.name) && (\n <div className=\"flex flex-wrap items-center gap-2 text-xs text-text-secondary\">\n {course?.level && <StatusBadge status={course.level} />}\n {course?.skill?.name && (\n <span className=\"rounded-full bg-bg-sunken px-2 py-0.5\">\n {course.skill.name}\n </span>\n )}\n {e.cohort?.name && (\n <span className=\"inline-flex items-center gap-1\">\n <Users className=\"h-3.5 w-3.5\" /> {e.cohort.name}\n </span>\n )}\n </div>\n )}\n\n <div className=\"mt-1 flex items-center gap-2\">\n <MiniBar\n value={e.progressPct}\n barClassName={\n isCompleted\n ? 'bg-[var(--color-status-success-text)]'\n : 'bg-[var(--color-accent-primary)]'\n }\n className=\"flex-1\"\n />\n <span className=\"shrink-0 text-xs font-semibold tabular-nums text-text-secondary\">\n {pct}%\n </span>\n </div>\n\n <p className=\"text-xs text-text-secondary\">\n {isCompleted && e.completedAt\n ? `Completed ${formatRelativeTime(e.completedAt)}`\n : `Active ${formatRelativeTime(e.lastActiveAt ?? e.enrolledAt)}`}\n </p>\n\n <div className=\"mt-auto flex flex-wrap gap-2 pt-2\">\n <Button\n size=\"sm\"\n className=\"max-sm:flex-1\"\n onClick={() => navigateTo(`learning/${e.id}`)}\n >\n {isCompleted ? 'Review' : 'Continue'}\n <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"max-sm:flex-1\"\n onClick={() => navigateTo(`catalog/${e.courseId}`)}\n >\n Overview\n </Button>\n </div>\n </div>\n </GlassCard>\n );\n })}\n </div>\n );\n };\n\n const resultColumns: Column<BuiSubmission>[] = [\n {\n key: 'assessment',\n header: 'Assessment',\n render: (s) => (\n <div className=\"flex min-w-0 items-center gap-3\">\n <span className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)]\">\n <ClipboardCheck className=\"h-4 w-4 text-text-primary\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate font-medium text-text-primary\">{courseTitleForSubmission(s)}</p>\n <p className=\"text-xs text-text-secondary\">Attempt {s.attempt}</p>\n </div>\n </div>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n render: (s) => <StatusBadge status={s.status} />,\n },\n {\n key: 'result',\n header: 'Result',\n render: (s) =>\n s.passed == null ? (\n <span className=\"text-text-tertiary\">—</span>\n ) : (\n <span\n className={cn(\n 'inline-flex items-center gap-1.5 text-sm font-medium',\n s.passed ? 'text-status-success-text' : 'text-status-error-text'\n )}\n >\n {s.passed ? <CheckCircle2 className=\"h-4 w-4\" /> : <XCircle className=\"h-4 w-4\" />}\n {s.passed ? 'Passed' : 'Not passed'}\n </span>\n ),\n },\n {\n key: 'feedback',\n header: 'Feedback',\n hideOnMobile: true,\n render: (s) =>\n s.feedback ? (\n <p className=\"line-clamp-2 max-w-[18rem] text-sm text-text-secondary\">{s.feedback}</p>\n ) : (\n <span className=\"text-text-tertiary\">—</span>\n ),\n },\n {\n key: 'submitted',\n header: 'Submitted',\n hideOnMobile: true,\n sortValue: (s) => s.submittedAt ?? s.createdAt,\n render: (s) => (\n <span className=\"whitespace-nowrap text-sm text-text-secondary\">\n {formatDateTime(s.submittedAt ?? s.createdAt)}\n </span>\n ),\n },\n {\n key: 'score',\n header: 'Score',\n align: 'right',\n sortValue: (s) => s.score ?? -1,\n render: (s) => (\n <span className={cn('text-lg font-bold tabular-nums', scoreClass(s))}>\n {s.score != null ? `${s.score}%` : '—'}\n </span>\n ),\n },\n ];\n\n const renderResults = () => {\n if (!learnerId) return null;\n if (submissionsQuery.isLoading) return <LoadingView label=\"Loading your results…\" />;\n if (submissionsQuery.isError)\n return (\n <ErrorView\n title=\"Couldn't load your results\"\n message={\n submissionsQuery.error instanceof Error ? submissionsQuery.error.message : undefined\n }\n onRetry={() => submissionsQuery.refetch()}\n />\n );\n if (submissions.length === 0)\n return (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No assessment results yet\"\n description=\"Take a course assessment and your score and feedback will appear here.\"\n />\n );\n return (\n <div className=\"flex flex-col gap-3\">\n {avgScore != null && (\n <GlassCard\n treatment=\"metric\"\n className=\"flex flex-wrap items-center justify-between gap-4 p-4\"\n >\n <div className=\"flex items-center gap-3\">\n <ProgressRing value={avgScore} size={54} />\n <div>\n <p className=\"text-sm font-semibold text-text-primary\">Average score</p>\n <p className=\"text-xs text-text-secondary\">\n {passedCount} of {graded.length} passed · {graded.length} graded\n </p>\n </div>\n </div>\n {scoreTrend.length >= 2 && (\n <div className=\"flex items-center gap-2 text-xs text-text-secondary\">\n <TrendingUp className=\"h-4 w-4 text-status-success-text\" />\n <Sparkline data={scoreTrend} width={128} height={36} />\n <span>score trend</span>\n </div>\n )}\n </GlassCard>\n )}\n <DataTable\n columns={resultColumns}\n rows={submissions}\n rowKey={(s) => s.id}\n defaultSort={{ key: 'submitted', dir: 'desc' }}\n />\n </div>\n );\n };\n\n const renderCertificates = () => {\n if (!learnerId) return null;\n if (certsQuery.isLoading) return <LoadingView label=\"Loading your certificates…\" />;\n if (certsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your certificates\"\n message={certsQuery.error instanceof Error ? certsQuery.error.message : undefined}\n onRetry={() => certsQuery.refetch()}\n />\n );\n }\n if (sortedCertificates.length === 0) {\n return (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No certificates yet\"\n description=\"Complete a credentialed course to earn a verifiable certificate.\"\n />\n );\n }\n return (\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3\">\n {sortedCertificates.map((cert) => (\n <GlassCard\n key={cert.id}\n treatment=\"plain\"\n className=\"flex flex-col gap-3 p-4 transition-shadow hover:shadow-lg\"\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[color-mix(in_srgb,var(--color-status-warning-text)_16%,transparent)]\">\n <Award className=\"h-6 w-6 text-status-warning-text\" />\n </div>\n <StatusBadge status={cert.status} />\n </div>\n <div className=\"min-w-0\">\n <p className=\"truncate font-semibold text-text-primary\">{titleFor(cert.courseId)}</p>\n <div className=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-secondary\">\n <span className=\"font-mono\">{cert.serial}</span>\n {cert.finalScore != null && (\n <span className=\"inline-flex items-center gap-1 text-status-success-text\">\n <CheckCircle2 className=\"h-3.5 w-3.5\" /> {cert.finalScore}%\n </span>\n )}\n </div>\n </div>\n <div className=\"mt-auto flex flex-wrap items-center justify-between gap-2 border-t border-border-seam/60 pt-3 text-xs text-text-secondary\">\n <span className=\"inline-flex items-center gap-1.5\">\n <Calendar className=\"h-3.5 w-3.5\" /> Issued {formatDate(cert.issuedAt)}\n </span>\n {cert.file ? <FileLink fileId={cert.file.id} /> : null}\n </div>\n </GlassCard>\n ))}\n </div>\n );\n };\n\n return (\n <PageLayout\n title=\"My Learning\"\n description=\"Your enrollments, progress and the credentials you've earned.\"\n titleGradient\n aurora\n >\n <div className=\"flex flex-col gap-6\">\n <Section title=\"Courses\" description=\"Everything you're enrolled in.\">\n {learnerId && enrollments.length > 0 && (\n <div className=\"mb-4\">\n <Tabs items={tabs} value={tab} onChange={setTab} />\n </div>\n )}\n {renderEnrollments()}\n </Section>\n\n <Section\n title=\"Assessment results\"\n description=\"Your submitted assessments, scores and educator feedback.\"\n >\n {renderResults()}\n </Section>\n\n <Section\n title=\"Certificates\"\n description=\"Verifiable credentials issued for the courses you've completed.\"\n action={\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('certificates')}>\n <GraduationCap className=\"mr-1 h-4 w-4\" /> All certificates\n </Button>\n }\n >\n {renderCertificates()}\n </Section>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"MyLearningPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/learning/MyLearningPage.tsx"],"sourcesContent":["/**\n * MyLearningPage — the caller's personal learning surface.\n * @module buildmyiq/pages/learning\n *\n * Renders the current learner's real enrollments (`buiListEnrollments` filtered\n * by `learnerId`) grouped by progress, their assessment results\n * (`buiListSubmissions`), and the credentials they have earned\n * (`buiListCertificates`). Course titles + covers are resolved from\n * `buiListCourses` (id → course map, falling back to \"Course\"). Every list\n * carries an honest loading / error / empty state — no fabricated rows.\n */\n\nimport type { FC } from 'react';\nimport { useMemo, useState } from 'react';\nimport {\n GraduationCap,\n Award,\n ArrowRight,\n PlayCircle,\n ClipboardCheck,\n CheckCircle2,\n XCircle,\n Users,\n Calendar,\n TrendingUp,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n Tabs,\n StatusBadge,\n MiniBar,\n EmptyState,\n LoadingView,\n ErrorView,\n Section,\n FileLink,\n DataTable,\n ProgressRing,\n Sparkline,\n} from '../../components';\nimport type { TabItem, Column } from '../../components';\nimport { cn } from '../../utils/cn';\nimport { useBuildmyiq } from '../../context';\nimport {\n useBuiMyEnrollments,\n useBuiCourses,\n useBuiMyCertificates,\n useBuiMySubmissions,\n} from '../../hooks';\nimport { formatRelativeTime, formatDate, formatDateTime } from '../../utils/formatters';\nimport type { BuiEnrollment, BuiCourse, BuiCertificate, BuiSubmission } from '../../types';\n\ntype LearningTab = 'in_progress' | 'completed' | 'all';\n\nconst IN_PROGRESS_STATUSES = new Set(['ENROLLED', 'IN_PROGRESS']);\n\nconst scoreClass = (s: BuiSubmission): string =>\n s.passed == null\n ? 'text-text-primary'\n : s.passed\n ? 'text-status-success-text'\n : 'text-status-error-text';\n\nexport const MyLearningPage: FC = () => {\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const [tab, setTab] = useState<LearningTab>('in_progress');\n\n // Self-scoped `buiMy*` reads — the learner's own records, identity from the\n // authenticated actor (FERPA/IDOR hardening removed the learnerId arg).\n const enrollmentsQuery = useBuiMyEnrollments({\n limit: 100,\n enabled: Boolean(learnerId),\n });\n const coursesQuery = useBuiCourses({ limit: 100 });\n const certsQuery = useBuiMyCertificates(undefined, Boolean(learnerId));\n const submissionsQuery = useBuiMySubmissions({\n limit: 50,\n enabled: Boolean(learnerId),\n });\n const submissions = useMemo(\n () =>\n [...(submissionsQuery.data?.items ?? [])].sort((a, b) =>\n (b.submittedAt ?? b.createdAt).localeCompare(a.submittedAt ?? a.createdAt)\n ),\n [submissionsQuery.data]\n );\n\n const enrollments: BuiEnrollment[] = useMemo(\n () => enrollmentsQuery.data?.items ?? [],\n [enrollmentsQuery.data]\n );\n const courses: BuiCourse[] = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const certificates: BuiCertificate[] = useMemo(() => certsQuery.data ?? [], [certsQuery.data]);\n\n const courseById = useMemo(() => {\n const map = new Map<string, BuiCourse>();\n for (const c of courses) map.set(c.id, c);\n return map;\n }, [courses]);\n\n const enrollmentById = useMemo(() => {\n const map = new Map<string, BuiEnrollment>();\n for (const e of enrollments) map.set(e.id, e);\n return map;\n }, [enrollments]);\n\n const titleFor = (courseId: string): string => courseById.get(courseId)?.title ?? 'Course';\n\n /** Resolve a submission's course via its enrollment (falls back honestly). */\n const courseTitleForSubmission = (s: BuiSubmission): string => {\n const enr = s.enrollmentId ? enrollmentById.get(s.enrollmentId) : undefined;\n return enr ? titleFor(enr.courseId) : 'Assessment';\n };\n\n const inProgress = useMemo(\n () => enrollments.filter((e) => IN_PROGRESS_STATUSES.has(e.status)),\n [enrollments]\n );\n const completed = useMemo(\n () => enrollments.filter((e) => e.status === 'COMPLETED'),\n [enrollments]\n );\n\n const visible = useMemo(() => {\n const rows = tab === 'in_progress' ? inProgress : tab === 'completed' ? completed : enrollments;\n return [...rows].sort((a, b) =>\n (b.lastActiveAt ?? b.enrolledAt).localeCompare(a.lastActiveAt ?? a.enrolledAt)\n );\n }, [tab, inProgress, completed, enrollments]);\n\n const sortedCertificates = useMemo(\n () => [...certificates].sort((a, b) => b.issuedAt.localeCompare(a.issuedAt)),\n [certificates]\n );\n\n // Derived score summary (real graded submissions only) — powers the trend strip.\n const graded = useMemo(() => submissions.filter((s) => s.score != null), [submissions]);\n const scoreTrend = useMemo(() => [...graded].reverse().map((s) => s.score as number), [graded]);\n const avgScore =\n graded.length > 0\n ? Math.round(graded.reduce((sum, s) => sum + (s.score ?? 0), 0) / graded.length)\n : null;\n const passedCount = useMemo(\n () => submissions.filter((s) => s.passed === true).length,\n [submissions]\n );\n\n const tabs: TabItem<LearningTab>[] = [\n { value: 'in_progress', label: 'In progress', count: inProgress.length },\n { value: 'completed', label: 'Completed', count: completed.length },\n { value: 'all', label: 'All', count: enrollments.length },\n ];\n\n const isLoading = Boolean(learnerId) && (enrollmentsQuery.isLoading || coursesQuery.isLoading);\n\n const renderEnrollments = () => {\n if (!learnerId) {\n return (\n <EmptyState\n illustration=\"empty-people\"\n title=\"Sign in to see your learning\"\n description=\"Your enrollments and progress appear here once you are signed in to the workspace.\"\n />\n );\n }\n if (isLoading) return <LoadingView label=\"Loading your enrollments…\" />;\n if (enrollmentsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your learning\"\n message={\n enrollmentsQuery.error instanceof Error ? enrollmentsQuery.error.message : undefined\n }\n onRetry={() => enrollmentsQuery.refetch()}\n />\n );\n }\n if (enrollments.length === 0) {\n return (\n <EmptyState\n illustration=\"onboarding\"\n title=\"You're not enrolled in anything yet\"\n description=\"Browse the catalog and enrol in a course to start learning.\"\n action={<Button onClick={() => navigateTo('catalog')}>Explore the catalog</Button>}\n />\n );\n }\n if (visible.length === 0) {\n const label =\n tab === 'in_progress'\n ? 'No courses in progress'\n : tab === 'completed'\n ? 'No completed courses yet'\n : 'No courses';\n return (\n <EmptyState\n illustration=\"empty-data\"\n title={label}\n description={\n tab === 'completed'\n ? 'Finish a course to see it here — and earn a certificate.'\n : 'Switch tabs or enrol in a new course to keep learning.'\n }\n />\n );\n }\n return (\n <div className=\"grid grid-cols-1 gap-4 xl:grid-cols-2\">\n {visible.map((e) => {\n const course = courseById.get(e.courseId);\n const cover = course?.cover?.downloadUrl ?? null;\n const isCompleted = e.status === 'COMPLETED';\n const pct = Math.round(e.progressPct ?? 0);\n return (\n <GlassCard\n key={e.id}\n treatment=\"plain\"\n className=\"group flex flex-col overflow-hidden transition-shadow hover:shadow-lg sm:flex-row\"\n >\n {/* Media tile — real cover when available, soft accent fallback; play\n affordance + a live progress ring badge. */}\n <div className=\"relative aspect-video w-full shrink-0 overflow-hidden bg-[var(--color-accent-soft)] sm:aspect-auto sm:w-44\">\n {cover ? (\n <img\n src={cover}\n alt=\"\"\n className=\"h-full w-full object-cover transition-transform duration-500 group-hover:scale-105\"\n />\n ) : null}\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <span className=\"flex h-11 w-11 items-center justify-center rounded-full bg-bg-surface/85 text-text-primary shadow-sm backdrop-blur transition-transform group-hover:scale-110\">\n <PlayCircle className=\"h-6 w-6\" />\n </span>\n </div>\n <div className=\"absolute bottom-2 left-2 rounded-full bg-bg-surface/85 p-0.5 shadow-sm backdrop-blur\">\n <ProgressRing\n value={pct}\n size={38}\n stroke={5}\n color={\n isCompleted ? 'var(--color-status-success-text)' : 'var(--color-accent-teal)'\n }\n />\n </div>\n </div>\n\n {/* Content */}\n <div className=\"flex min-w-0 flex-1 flex-col gap-2 p-4\">\n <div className=\"flex flex-wrap items-start gap-2\">\n <p className=\"min-w-0 flex-1 truncate font-semibold text-text-primary\">\n {titleFor(e.courseId)}\n </p>\n <StatusBadge status={e.status} />\n </div>\n\n {(course?.level || course?.skill?.name || e.cohort?.name) && (\n <div className=\"flex flex-wrap items-center gap-2 text-xs text-text-secondary\">\n {course?.level && <StatusBadge status={course.level} />}\n {course?.skill?.name && (\n <span className=\"rounded-full bg-bg-sunken px-2 py-0.5\">\n {course.skill.name}\n </span>\n )}\n {e.cohort?.name && (\n <span className=\"inline-flex items-center gap-1\">\n <Users className=\"h-3.5 w-3.5\" /> {e.cohort.name}\n </span>\n )}\n </div>\n )}\n\n <div className=\"mt-1 flex items-center gap-2\">\n <MiniBar\n value={e.progressPct}\n barClassName={\n isCompleted\n ? 'bg-[var(--color-status-success-text)]'\n : 'bg-[var(--color-accent-primary)]'\n }\n className=\"flex-1\"\n />\n <span className=\"shrink-0 text-xs font-semibold tabular-nums text-text-secondary\">\n {pct}%\n </span>\n </div>\n\n <p className=\"text-xs text-text-secondary\">\n {isCompleted && e.completedAt\n ? `Completed ${formatRelativeTime(e.completedAt)}`\n : `Active ${formatRelativeTime(e.lastActiveAt ?? e.enrolledAt)}`}\n </p>\n\n <div className=\"mt-auto flex flex-wrap gap-2 pt-2\">\n <Button\n size=\"sm\"\n className=\"max-sm:flex-1\"\n onClick={() => navigateTo(`learning/${e.id}`)}\n >\n {isCompleted ? 'Review' : 'Continue'}\n <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"max-sm:flex-1\"\n onClick={() => navigateTo(`catalog/${e.courseId}`)}\n >\n Overview\n </Button>\n </div>\n </div>\n </GlassCard>\n );\n })}\n </div>\n );\n };\n\n const resultColumns: Column<BuiSubmission>[] = [\n {\n key: 'assessment',\n header: 'Assessment',\n render: (s) => (\n <div className=\"flex min-w-0 items-center gap-3\">\n <span className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)]\">\n <ClipboardCheck className=\"h-4 w-4 text-text-primary\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate font-medium text-text-primary\">{courseTitleForSubmission(s)}</p>\n <p className=\"text-xs text-text-secondary\">Attempt {s.attempt}</p>\n </div>\n </div>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n render: (s) => <StatusBadge status={s.status} />,\n },\n {\n key: 'result',\n header: 'Result',\n render: (s) =>\n s.passed == null ? (\n <span className=\"text-text-tertiary\">—</span>\n ) : (\n <span\n className={cn(\n 'inline-flex items-center gap-1.5 text-sm font-medium',\n s.passed ? 'text-status-success-text' : 'text-status-error-text'\n )}\n >\n {s.passed ? <CheckCircle2 className=\"h-4 w-4\" /> : <XCircle className=\"h-4 w-4\" />}\n {s.passed ? 'Passed' : 'Not passed'}\n </span>\n ),\n },\n {\n key: 'feedback',\n header: 'Feedback',\n hideOnMobile: true,\n render: (s) =>\n s.feedback ? (\n <p className=\"line-clamp-2 max-w-[18rem] text-sm text-text-secondary\">{s.feedback}</p>\n ) : (\n <span className=\"text-text-tertiary\">—</span>\n ),\n },\n {\n key: 'submitted',\n header: 'Submitted',\n hideOnMobile: true,\n sortValue: (s) => s.submittedAt ?? s.createdAt,\n render: (s) => (\n <span className=\"whitespace-nowrap text-sm text-text-secondary\">\n {formatDateTime(s.submittedAt ?? s.createdAt)}\n </span>\n ),\n },\n {\n key: 'score',\n header: 'Score',\n align: 'right',\n sortValue: (s) => s.score ?? -1,\n render: (s) => (\n <span className={cn('text-lg font-bold tabular-nums', scoreClass(s))}>\n {s.score != null ? `${s.score}%` : '—'}\n </span>\n ),\n },\n ];\n\n const renderResults = () => {\n if (!learnerId) return null;\n if (submissionsQuery.isLoading) return <LoadingView label=\"Loading your results…\" />;\n if (submissionsQuery.isError)\n return (\n <ErrorView\n title=\"Couldn't load your results\"\n message={\n submissionsQuery.error instanceof Error ? submissionsQuery.error.message : undefined\n }\n onRetry={() => submissionsQuery.refetch()}\n />\n );\n if (submissions.length === 0)\n return (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No assessment results yet\"\n description=\"Take a course assessment and your score and feedback will appear here.\"\n />\n );\n return (\n <div className=\"flex flex-col gap-3\">\n {avgScore != null && (\n <GlassCard\n treatment=\"metric\"\n className=\"flex flex-wrap items-center justify-between gap-4 p-4\"\n >\n <div className=\"flex items-center gap-3\">\n <ProgressRing value={avgScore} size={54} />\n <div>\n <p className=\"text-sm font-semibold text-text-primary\">Average score</p>\n <p className=\"text-xs text-text-secondary\">\n {passedCount} of {graded.length} passed · {graded.length} graded\n </p>\n </div>\n </div>\n {scoreTrend.length >= 2 && (\n <div className=\"flex items-center gap-2 text-xs text-text-secondary\">\n <TrendingUp className=\"h-4 w-4 text-status-success-text\" />\n <Sparkline data={scoreTrend} width={128} height={36} />\n <span>score trend</span>\n </div>\n )}\n </GlassCard>\n )}\n <DataTable\n columns={resultColumns}\n rows={submissions}\n rowKey={(s) => s.id}\n defaultSort={{ key: 'submitted', dir: 'desc' }}\n />\n </div>\n );\n };\n\n const renderCertificates = () => {\n if (!learnerId) return null;\n if (certsQuery.isLoading) return <LoadingView label=\"Loading your certificates…\" />;\n if (certsQuery.isError) {\n return (\n <ErrorView\n title=\"Couldn't load your certificates\"\n message={certsQuery.error instanceof Error ? certsQuery.error.message : undefined}\n onRetry={() => certsQuery.refetch()}\n />\n );\n }\n if (sortedCertificates.length === 0) {\n return (\n <EmptyState\n illustration=\"empty-data\"\n title=\"No certificates yet\"\n description=\"Complete a credentialed course to earn a verifiable certificate.\"\n />\n );\n }\n return (\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3\">\n {sortedCertificates.map((cert) => (\n <GlassCard\n key={cert.id}\n treatment=\"plain\"\n className=\"flex flex-col gap-3 p-4 transition-shadow hover:shadow-lg\"\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[color-mix(in_srgb,var(--color-status-warning-text)_16%,transparent)]\">\n <Award className=\"h-6 w-6 text-status-warning-text\" />\n </div>\n <StatusBadge status={cert.status} />\n </div>\n <div className=\"min-w-0\">\n <p className=\"truncate font-semibold text-text-primary\">{titleFor(cert.courseId)}</p>\n <div className=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-secondary\">\n <span className=\"font-mono\">{cert.serial}</span>\n {cert.finalScore != null && (\n <span className=\"inline-flex items-center gap-1 text-status-success-text\">\n <CheckCircle2 className=\"h-3.5 w-3.5\" /> {cert.finalScore}%\n </span>\n )}\n </div>\n </div>\n <div className=\"mt-auto flex flex-wrap items-center justify-between gap-2 border-t border-border-seam/60 pt-3 text-xs text-text-secondary\">\n <span className=\"inline-flex items-center gap-1.5\">\n <Calendar className=\"h-3.5 w-3.5\" /> Issued {formatDate(cert.issuedAt)}\n </span>\n {cert.file ? <FileLink fileId={cert.file.id} /> : null}\n </div>\n </GlassCard>\n ))}\n </div>\n );\n };\n\n return (\n <PageLayout\n title=\"My Learning\"\n description=\"Your enrollments, progress and the credentials you've earned.\"\n titleGradient\n aurora\n >\n <div className=\"flex flex-col gap-6\">\n <Section title=\"Courses\" description=\"Everything you're enrolled in.\">\n {learnerId && enrollments.length > 0 && (\n <div className=\"mb-4\">\n <Tabs items={tabs} value={tab} onChange={setTab} />\n </div>\n )}\n {renderEnrollments()}\n </Section>\n\n <Section\n title=\"Assessment results\"\n description=\"Your submitted assessments, scores and educator feedback.\"\n >\n {renderResults()}\n </Section>\n\n <Section\n title=\"Certificates\"\n description=\"Verifiable credentials issued for the courses you've completed.\"\n action={\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('certificates')}>\n <GraduationCap className=\"mr-1 h-4 w-4\" /> All certificates\n </Button>\n }\n >\n {renderCertificates()}\n </Section>\n </div>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PathsPage.d.ts","sourceRoot":"","sources":["../../../../src/buildmyiq/pages/paths/PathsPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC;AAuChC,eAAO,MAAM,SAAS,EAAE,
|
|
1
|
+
{"version":3,"file":"PathsPage.d.ts","sourceRoot":"","sources":["../../../../src/buildmyiq/pages/paths/PathsPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC;AAuChC,eAAO,MAAM,SAAS,EAAE,EAmMvB,CAAC"}
|
|
@@ -7,7 +7,7 @@ import { StatusBadge as i } from "../../components/StatusBadge.js";
|
|
|
7
7
|
import { EmptyState as a } from "../../components/EmptyState.js";
|
|
8
8
|
import { ErrorView as o, LoadingView as s } from "../../components/StateViews.js";
|
|
9
9
|
import { ProgressRing as c } from "../../components/Viz.js";
|
|
10
|
-
import { useBuiCourses as l,
|
|
10
|
+
import { useBuiCourses as l, useBuiMyEnrollments as u, useBuiSkills as d } from "../../hooks/useBuildmyiqApi.js";
|
|
11
11
|
import "../../hooks/index.js";
|
|
12
12
|
import "../../components/index.js";
|
|
13
13
|
import { useMemo as f } from "react";
|
|
@@ -25,7 +25,6 @@ var C = {
|
|
|
25
25
|
status: "PUBLISHED",
|
|
26
26
|
limit: 100
|
|
27
27
|
}), k = u({
|
|
28
|
-
learnerId: E,
|
|
29
28
|
limit: 100,
|
|
30
29
|
enabled: !!E
|
|
31
30
|
}), A = D.data ?? [], j = f(() => O.data?.items ?? [], [O.data]), M = f(() => k.data?.items ?? [], [k.data]), N = f(() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PathsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/paths/PathsPage.tsx"],"sourcesContent":["/**\n * PathsPage — \"Skill Journeys\": published courses grouped by the skill they\n * build, ordered into a guided progression, with the learner's LIVE progress\n * across each course.\n * @module buildmyiq/pages/paths\n *\n * DERIVED, not a new backend entity: a \"journey\" for a skill = the published\n * courses whose `skillId` is that skill, ordered by level (Beginner → Expert)\n * then title. Each node's state comes from the caller's real enrollment for that\n * course (completed / in-progress with %/ not started). No locking is invented —\n * \"not started\" is honest. No fabricated data.\n */\n\nimport type { FC } from 'react';\nimport { useMemo } from 'react';\nimport { CheckCircle2, PlayCircle, Circle, ArrowRight, Clock, Layers } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n LoadingView,\n ErrorView,\n EmptyState,\n StatusBadge,\n ProgressRing,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiSkills, useBuiCourses, useBuiEnrollments } from '../../hooks';\nimport { cn } from '../../utils/cn';\nimport { formatHours } from '../../utils/formatters';\nimport type { BuiCourse, BuiEnrollment, BuiSkill } from '../../types';\n\nconst LEVEL_ORDER: Record<string, number> = {\n BEGINNER: 0,\n INTERMEDIATE: 1,\n ADVANCED: 2,\n EXPERT: 3,\n};\n\ninterface JourneyNode {\n course: BuiCourse;\n status: 'completed' | 'in_progress' | 'not_started';\n progressPct: number;\n enrollmentId?: string;\n}\n\ninterface Journey {\n skill: BuiSkill;\n nodes: JourneyNode[];\n totalHours: number;\n overallPct: number;\n}\n\nexport const PathsPage: FC = () => {\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const skillsQuery = useBuiSkills();\n const coursesQuery = useBuiCourses({ status: 'PUBLISHED', limit: 100 });\n const enrollmentsQuery = useBuiEnrollments({\n learnerId,\n limit: 100,\n enabled: Boolean(learnerId),\n });\n\n const skills = skillsQuery.data ?? [];\n const courses: BuiCourse[] = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const enrollments: BuiEnrollment[] = useMemo(\n () => enrollmentsQuery.data?.items ?? [],\n [enrollmentsQuery.data]\n );\n\n const journeys = useMemo<Journey[]>(() => {\n const enrollByCourse = new Map<string, BuiEnrollment>();\n for (const e of enrollments) {\n // keep the most recent enrollment per course\n const prev = enrollByCourse.get(e.courseId);\n if (!prev || e.enrolledAt > prev.enrolledAt) enrollByCourse.set(e.courseId, e);\n }\n const bySkill = new Map<string, BuiCourse[]>();\n for (const c of courses) {\n if (!c.skillId) continue;\n const list = bySkill.get(c.skillId) ?? [];\n list.push(c);\n bySkill.set(c.skillId, list);\n }\n const result: Journey[] = [];\n for (const skill of skills) {\n const list = bySkill.get(skill.id);\n if (!list || list.length === 0) continue;\n const ordered = [...list].sort(\n (a, b) =>\n (LEVEL_ORDER[a.level] ?? 9) - (LEVEL_ORDER[b.level] ?? 9) ||\n a.title.localeCompare(b.title)\n );\n const nodes: JourneyNode[] = ordered.map((course) => {\n const enr = enrollByCourse.get(course.id);\n const status: JourneyNode['status'] = !enr\n ? 'not_started'\n : enr.status === 'COMPLETED'\n ? 'completed'\n : 'in_progress';\n return {\n course,\n status,\n progressPct: enr?.progressPct ?? 0,\n enrollmentId: enr?.id,\n };\n });\n const totalHours = ordered.reduce((s, c) => s + (c.estimatedHours ?? 0), 0);\n const overallPct = Math.round(\n nodes.reduce((s, n) => s + (n.status === 'completed' ? 100 : n.progressPct), 0) /\n (nodes.length || 1)\n );\n result.push({ skill, nodes, totalHours, overallPct });\n }\n // Journeys with the most progress / courses first.\n return result.sort((a, b) => b.overallPct - a.overallPct || b.nodes.length - a.nodes.length);\n }, [skills, courses, enrollments]);\n\n const isLoading = skillsQuery.isLoading || coursesQuery.isLoading;\n\n const goToCourse = (n: JourneyNode): void => {\n if (n.enrollmentId && n.status !== 'not_started') navigateTo(`learning/${n.enrollmentId}`);\n else navigateTo(`catalog/${n.course.id}`);\n };\n\n return (\n <PageLayout\n title=\"Skill Journeys\"\n description=\"Guided, multi-course paths that build a skill to mastery — with your live progress across each step.\"\n titleGradient\n aurora\n >\n {isLoading ? (\n <LoadingView label=\"Mapping your skill journeys…\" />\n ) : skillsQuery.isError || coursesQuery.isError ? (\n <ErrorView title=\"Couldn't load journeys\" onRetry={() => coursesQuery.refetch()} />\n ) : journeys.length === 0 ? (\n <EmptyState\n illustration=\"onboarding\"\n title=\"No skill journeys yet\"\n description=\"Journeys appear once published courses are mapped to a skill. Add a skill to a course in Authoring.\"\n action={<Button onClick={() => navigateTo('catalog')}>Browse the catalog</Button>}\n />\n ) : (\n <div className=\"flex flex-col gap-6\">\n {journeys.map((journey) => (\n <GlassCard key={journey.skill.id} treatment=\"plain\" className=\"p-5 sm:p-6\">\n {/* Journey header */}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"flex items-center gap-4\">\n <ProgressRing\n value={journey.overallPct}\n size={64}\n color=\"var(--color-accent-teal)\"\n />\n <div>\n <div className=\"flex flex-wrap items-center gap-2\">\n <h2 className=\"text-lg font-semibold text-text-primary\">\n Become skilled in {journey.skill.name}\n </h2>\n <StatusBadge status={journey.skill.category} />\n </div>\n <p className=\"mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm text-text-secondary\">\n <span className=\"inline-flex items-center gap-1\">\n <Layers className=\"h-3.5 w-3.5\" /> {journey.nodes.length} courses\n </span>\n {journey.totalHours > 0 && (\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"h-3.5 w-3.5\" /> {formatHours(journey.totalHours)}\n </span>\n )}\n <span>· {journey.overallPct}% complete</span>\n </p>\n </div>\n </div>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"shrink-0 max-sm:w-full\"\n onClick={() => {\n const next =\n journey.nodes.find((n) => n.status === 'in_progress') ??\n journey.nodes.find((n) => n.status === 'not_started') ??\n journey.nodes[0];\n if (next) goToCourse(next);\n }}\n >\n Continue path <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </div>\n\n {/* Node stepper — horizontal scroll on small screens */}\n <div className=\"mt-5 overflow-x-auto\">\n <ol className=\"flex min-w-max items-stretch gap-0\">\n {journey.nodes.map((node, i) => (\n <li key={node.course.id} className=\"flex items-stretch\">\n <button\n type=\"button\"\n onClick={() => goToCourse(node)}\n className={cn(\n 'group flex w-56 flex-col gap-2 rounded-xl border p-3 text-left transition-colors',\n node.status === 'completed'\n ? 'border-[color-mix(in_srgb,var(--color-status-success-text)_40%,transparent)] bg-status-success-bg-subtle'\n : node.status === 'in_progress'\n ? 'border-accent bg-[var(--color-accent-soft)]'\n : 'border-border-seam bg-bg-surface hover:bg-bg-sunken'\n )}\n >\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-text-secondary\">\n Step {i + 1}\n </span>\n {node.status === 'completed' ? (\n <CheckCircle2 className=\"h-5 w-5 text-status-success-text\" />\n ) : node.status === 'in_progress' ? (\n <PlayCircle className=\"h-5 w-5 text-[var(--color-accent-teal)]\" />\n ) : (\n <Circle className=\"h-5 w-5 text-[var(--color-text-tertiary)]\" />\n )}\n </div>\n <p className=\"line-clamp-2 text-sm font-medium text-text-primary\">\n {node.course.title}\n </p>\n <div className=\"mt-auto flex items-center justify-between\">\n <StatusBadge status={node.course.level} />\n {node.status === 'in_progress' && (\n <span className=\"text-xs tabular-nums text-text-secondary\">\n {node.progressPct}%\n </span>\n )}\n </div>\n </button>\n {i < journey.nodes.length - 1 && (\n <div className=\"flex w-6 items-center justify-center\">\n <ArrowRight className=\"h-4 w-4 text-[var(--color-text-tertiary)]\" />\n </div>\n )}\n </li>\n ))}\n </ol>\n </div>\n </GlassCard>\n ))}\n </div>\n )}\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"PathsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/paths/PathsPage.tsx"],"sourcesContent":["/**\n * PathsPage — \"Skill Journeys\": published courses grouped by the skill they\n * build, ordered into a guided progression, with the learner's LIVE progress\n * across each course.\n * @module buildmyiq/pages/paths\n *\n * DERIVED, not a new backend entity: a \"journey\" for a skill = the published\n * courses whose `skillId` is that skill, ordered by level (Beginner → Expert)\n * then title. Each node's state comes from the caller's real enrollment for that\n * course (completed / in-progress with %/ not started). No locking is invented —\n * \"not started\" is honest. No fabricated data.\n */\n\nimport type { FC } from 'react';\nimport { useMemo } from 'react';\nimport { CheckCircle2, PlayCircle, Circle, ArrowRight, Clock, Layers } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n PageLayout,\n LoadingView,\n ErrorView,\n EmptyState,\n StatusBadge,\n ProgressRing,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiSkills, useBuiCourses, useBuiMyEnrollments } from '../../hooks';\nimport { cn } from '../../utils/cn';\nimport { formatHours } from '../../utils/formatters';\nimport type { BuiCourse, BuiEnrollment, BuiSkill } from '../../types';\n\nconst LEVEL_ORDER: Record<string, number> = {\n BEGINNER: 0,\n INTERMEDIATE: 1,\n ADVANCED: 2,\n EXPERT: 3,\n};\n\ninterface JourneyNode {\n course: BuiCourse;\n status: 'completed' | 'in_progress' | 'not_started';\n progressPct: number;\n enrollmentId?: string;\n}\n\ninterface Journey {\n skill: BuiSkill;\n nodes: JourneyNode[];\n totalHours: number;\n overallPct: number;\n}\n\nexport const PathsPage: FC = () => {\n const { currentUser, navigateTo } = useBuildmyiq();\n const learnerId = currentUser?.id;\n\n const skillsQuery = useBuiSkills();\n const coursesQuery = useBuiCourses({ status: 'PUBLISHED', limit: 100 });\n const enrollmentsQuery = useBuiMyEnrollments({\n limit: 100,\n enabled: Boolean(learnerId),\n });\n\n const skills = skillsQuery.data ?? [];\n const courses: BuiCourse[] = useMemo(() => coursesQuery.data?.items ?? [], [coursesQuery.data]);\n const enrollments: BuiEnrollment[] = useMemo(\n () => enrollmentsQuery.data?.items ?? [],\n [enrollmentsQuery.data]\n );\n\n const journeys = useMemo<Journey[]>(() => {\n const enrollByCourse = new Map<string, BuiEnrollment>();\n for (const e of enrollments) {\n // keep the most recent enrollment per course\n const prev = enrollByCourse.get(e.courseId);\n if (!prev || e.enrolledAt > prev.enrolledAt) enrollByCourse.set(e.courseId, e);\n }\n const bySkill = new Map<string, BuiCourse[]>();\n for (const c of courses) {\n if (!c.skillId) continue;\n const list = bySkill.get(c.skillId) ?? [];\n list.push(c);\n bySkill.set(c.skillId, list);\n }\n const result: Journey[] = [];\n for (const skill of skills) {\n const list = bySkill.get(skill.id);\n if (!list || list.length === 0) continue;\n const ordered = [...list].sort(\n (a, b) =>\n (LEVEL_ORDER[a.level] ?? 9) - (LEVEL_ORDER[b.level] ?? 9) ||\n a.title.localeCompare(b.title)\n );\n const nodes: JourneyNode[] = ordered.map((course) => {\n const enr = enrollByCourse.get(course.id);\n const status: JourneyNode['status'] = !enr\n ? 'not_started'\n : enr.status === 'COMPLETED'\n ? 'completed'\n : 'in_progress';\n return {\n course,\n status,\n progressPct: enr?.progressPct ?? 0,\n enrollmentId: enr?.id,\n };\n });\n const totalHours = ordered.reduce((s, c) => s + (c.estimatedHours ?? 0), 0);\n const overallPct = Math.round(\n nodes.reduce((s, n) => s + (n.status === 'completed' ? 100 : n.progressPct), 0) /\n (nodes.length || 1)\n );\n result.push({ skill, nodes, totalHours, overallPct });\n }\n // Journeys with the most progress / courses first.\n return result.sort((a, b) => b.overallPct - a.overallPct || b.nodes.length - a.nodes.length);\n }, [skills, courses, enrollments]);\n\n const isLoading = skillsQuery.isLoading || coursesQuery.isLoading;\n\n const goToCourse = (n: JourneyNode): void => {\n if (n.enrollmentId && n.status !== 'not_started') navigateTo(`learning/${n.enrollmentId}`);\n else navigateTo(`catalog/${n.course.id}`);\n };\n\n return (\n <PageLayout\n title=\"Skill Journeys\"\n description=\"Guided, multi-course paths that build a skill to mastery — with your live progress across each step.\"\n titleGradient\n aurora\n >\n {isLoading ? (\n <LoadingView label=\"Mapping your skill journeys…\" />\n ) : skillsQuery.isError || coursesQuery.isError ? (\n <ErrorView title=\"Couldn't load journeys\" onRetry={() => coursesQuery.refetch()} />\n ) : journeys.length === 0 ? (\n <EmptyState\n illustration=\"onboarding\"\n title=\"No skill journeys yet\"\n description=\"Journeys appear once published courses are mapped to a skill. Add a skill to a course in Authoring.\"\n action={<Button onClick={() => navigateTo('catalog')}>Browse the catalog</Button>}\n />\n ) : (\n <div className=\"flex flex-col gap-6\">\n {journeys.map((journey) => (\n <GlassCard key={journey.skill.id} treatment=\"plain\" className=\"p-5 sm:p-6\">\n {/* Journey header */}\n <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n <div className=\"flex items-center gap-4\">\n <ProgressRing\n value={journey.overallPct}\n size={64}\n color=\"var(--color-accent-teal)\"\n />\n <div>\n <div className=\"flex flex-wrap items-center gap-2\">\n <h2 className=\"text-lg font-semibold text-text-primary\">\n Become skilled in {journey.skill.name}\n </h2>\n <StatusBadge status={journey.skill.category} />\n </div>\n <p className=\"mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm text-text-secondary\">\n <span className=\"inline-flex items-center gap-1\">\n <Layers className=\"h-3.5 w-3.5\" /> {journey.nodes.length} courses\n </span>\n {journey.totalHours > 0 && (\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"h-3.5 w-3.5\" /> {formatHours(journey.totalHours)}\n </span>\n )}\n <span>· {journey.overallPct}% complete</span>\n </p>\n </div>\n </div>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n className=\"shrink-0 max-sm:w-full\"\n onClick={() => {\n const next =\n journey.nodes.find((n) => n.status === 'in_progress') ??\n journey.nodes.find((n) => n.status === 'not_started') ??\n journey.nodes[0];\n if (next) goToCourse(next);\n }}\n >\n Continue path <ArrowRight className=\"ml-1 h-4 w-4\" />\n </Button>\n </div>\n\n {/* Node stepper — horizontal scroll on small screens */}\n <div className=\"mt-5 overflow-x-auto\">\n <ol className=\"flex min-w-max items-stretch gap-0\">\n {journey.nodes.map((node, i) => (\n <li key={node.course.id} className=\"flex items-stretch\">\n <button\n type=\"button\"\n onClick={() => goToCourse(node)}\n className={cn(\n 'group flex w-56 flex-col gap-2 rounded-xl border p-3 text-left transition-colors',\n node.status === 'completed'\n ? 'border-[color-mix(in_srgb,var(--color-status-success-text)_40%,transparent)] bg-status-success-bg-subtle'\n : node.status === 'in_progress'\n ? 'border-accent bg-[var(--color-accent-soft)]'\n : 'border-border-seam bg-bg-surface hover:bg-bg-sunken'\n )}\n >\n <div className=\"flex items-center justify-between\">\n <span className=\"text-xs font-medium text-text-secondary\">\n Step {i + 1}\n </span>\n {node.status === 'completed' ? (\n <CheckCircle2 className=\"h-5 w-5 text-status-success-text\" />\n ) : node.status === 'in_progress' ? (\n <PlayCircle className=\"h-5 w-5 text-[var(--color-accent-teal)]\" />\n ) : (\n <Circle className=\"h-5 w-5 text-[var(--color-text-tertiary)]\" />\n )}\n </div>\n <p className=\"line-clamp-2 text-sm font-medium text-text-primary\">\n {node.course.title}\n </p>\n <div className=\"mt-auto flex items-center justify-between\">\n <StatusBadge status={node.course.level} />\n {node.status === 'in_progress' && (\n <span className=\"text-xs tabular-nums text-text-secondary\">\n {node.progressPct}%\n </span>\n )}\n </div>\n </button>\n {i < journey.nodes.length - 1 && (\n <div className=\"flex w-6 items-center justify-center\">\n <ArrowRight className=\"h-4 w-4 text-[var(--color-text-tertiary)]\" />\n </div>\n )}\n </li>\n ))}\n </ol>\n </div>\n </GlassCard>\n ))}\n </div>\n )}\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|