@burdenoff/microfe-buildmyiq 2026.709.1 → 2026.709.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/components/Avatar.js.map +1 -1
- package/dist/buildmyiq/components/Viz.js.map +1 -1
- package/dist/buildmyiq/pages/assessments/AssessmentDetailPage.js.map +1 -1
- package/dist/buildmyiq/pages/assessments/TakeAssessmentPage.js.map +1 -1
- package/dist/buildmyiq/pages/authoring/CourseBuilderPage.js.map +1 -1
- package/dist/buildmyiq/pages/certificates/CertificatesPage.js.map +1 -1
- package/dist/buildmyiq/pages/insights/InsightsPage.js.map +1 -1
- package/dist/buildmyiq/pages/learning/MyLearningPage.js.map +1 -1
- package/dist/buildmyiq/pages/paths/PathsPage.js.map +1 -1
- package/dist/buildmyiq/pages/skills/SkillsPage.js.map +1 -1
- package/dist/buildmyiq/pages/submissions/SubmissionsPage.js +4 -8
- package/dist/buildmyiq/pages/submissions/SubmissionsPage.js.map +1 -1
- package/package.json +1 -1
|
@@ -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(\n () => [...graded].reverse().map((s) => s.score as number),\n [graded]\n );\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(() => submissions.filter((s) => s.passed === true).length, [submissions]);\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\n ? 'var(--color-status-success-text)'\n : '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\">\n {courseTitleForSubmission(s)}\n </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 ? (\n <CheckCircle2 className=\"h-4 w-4\" />\n ) : (\n <XCircle className=\"h-4 w-4\" />\n )}\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 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 +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) => (LEVEL_ORDER[a.level] ?? 9) - (LEVEL_ORDER[b.level] ?? 9) || 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 value={journey.overallPct} size={64} color=\"var(--color-accent-teal)\" />\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\">Step {i + 1}</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, 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":";;;;;;;;;;;;;;;;;AA+BA,IAAM,IAAsC;CAC1C,UAAU;CACV,cAAc;CACd,UAAU;CACV,QAAQ;CACT,EAgBY,UAAsB;CACjC,IAAM,EAAE,gBAAa,kBAAe,GAAc,EAC5C,IAAY,GAAa,IAEzB,IAAc,GAAc,EAC5B,IAAe,EAAc;EAAE,QAAQ;EAAa,OAAO;EAAK,CAAC,EACjE,IAAmB,EAAkB;EACzC;EACA,OAAO;EACP,SAAS,EAAQ;EAClB,CAAC,EAEI,IAAS,EAAY,QAAQ,EAAE,EAC/B,IAAuB,QAAc,EAAa,MAAM,SAAS,EAAE,EAAE,CAAC,EAAa,KAAK,CAAC,EACzF,IAA+B,QAC7B,EAAiB,MAAM,SAAS,EAAE,EACxC,CAAC,EAAiB,KAAK,CACxB,EAEK,IAAW,QAAyB;EACxC,IAAM,oBAAiB,IAAI,KAA4B;AACvD,OAAK,IAAM,KAAK,GAAa;GAE3B,IAAM,IAAO,EAAe,IAAI,EAAE,SAAS;AAC3C,IAAI,CAAC,KAAQ,EAAE,aAAa,EAAK,eAAY,EAAe,IAAI,EAAE,UAAU,EAAE;;EAEhF,IAAM,oBAAU,IAAI,KAA0B;AAC9C,OAAK,IAAM,KAAK,GAAS;AACvB,OAAI,CAAC,EAAE,QAAS;GAChB,IAAM,IAAO,EAAQ,IAAI,EAAE,QAAQ,IAAI,EAAE;AAEzC,GADA,EAAK,KAAK,EAAE,EACZ,EAAQ,IAAI,EAAE,SAAS,EAAK;;EAE9B,IAAM,IAAoB,EAAE;AAC5B,OAAK,IAAM,KAAS,GAAQ;GAC1B,IAAM,IAAO,EAAQ,IAAI,EAAM,GAAG;AAClC,OAAI,CAAC,KAAQ,EAAK,WAAW,EAAG;GAChC,IAAM,IAAU,CAAC,GAAG,EAAK,CAAC,MACvB,GAAG,OACD,EAAY,EAAE,UAAU,MAAM,EAAY,EAAE,UAAU,MACvD,EAAE,MAAM,cAAc,EAAE,MAAM,CACjC,EACK,IAAuB,EAAQ,KAAK,MAAW;IACnD,IAAM,IAAM,EAAe,IAAI,EAAO,GAAG;AAMzC,WAAO;KACL;KACA,QAPqC,IAEnC,EAAI,WAAW,cACb,cACA,gBAHF;KAOF,aAAa,GAAK,eAAe;KACjC,cAAc,GAAK;KACpB;KACD,EACI,IAAa,EAAQ,QAAQ,GAAG,MAAM,KAAK,EAAE,kBAAkB,IAAI,EAAE,EACrE,IAAa,KAAK,MACtB,EAAM,QAAQ,GAAG,MAAM,KAAK,EAAE,WAAW,cAAc,MAAM,EAAE,cAAc,EAAE,IAC5E,EAAM,UAAU,GACpB;AACD,KAAO,KAAK;IAAE;IAAO;IAAO;IAAY;IAAY,CAAC;;AAGvD,SAAO,EAAO,MAAM,GAAG,MAAM,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,SAAS,EAAE,MAAM,OAAO;IAC3F;EAAC;EAAQ;EAAS;EAAY,CAAC,EAE5B,IAAY,EAAY,aAAa,EAAa,WAElD,KAAc,MAAyB;AAC3C,EAAI,EAAE,gBAAgB,EAAE,WAAW,gBAAe,EAAW,YAAY,EAAE,eAAe,GACrF,EAAW,WAAW,EAAE,OAAO,KAAK;;AAG3C,QACE,kBAAC,GAAD;EACE,OAAM;EACN,aAAY;EACZ,eAAA;EACA,QAAA;YAEC,IACC,kBAAC,GAAD,EAAa,OAAM,gCAAiC,CAAA,GAClD,EAAY,WAAW,EAAa,UACtC,kBAAC,GAAD;GAAW,OAAM;GAAyB,eAAe,EAAa,SAAS;GAAI,CAAA,GACjF,EAAS,WAAW,IACtB,kBAAC,GAAD;GACE,cAAa;GACb,OAAM;GACN,aAAY;GACZ,QAAQ,kBAAC,GAAD;IAAQ,eAAe,EAAW,UAAU;cAAE;IAA2B,CAAA;GACjF,CAAA,GAEF,kBAAC,OAAD;GAAK,WAAU;aACZ,EAAS,KAAK,MACb,kBAAC,GAAD;IAAkC,WAAU;IAAQ,WAAU;cAA9D,CAEE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,GAAD;OACE,OAAO,EAAQ;OACf,MAAM;OACN,OAAM;OACN,CAAA,EACF,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,MAAD;QAAI,WAAU;kBAAd,CAAwD,sBACnC,EAAQ,MAAM,KAC9B;WACL,kBAAC,GAAD,EAAa,QAAQ,EAAQ,MAAM,UAAY,CAAA,CAC3C;UACN,kBAAC,KAAD;OAAG,WAAU;iBAAb;QACE,kBAAC,QAAD;SAAM,WAAU;mBAAhB;UACE,kBAAC,GAAD,EAAQ,WAAU,eAAgB,CAAA;;UAAE,EAAQ,MAAM;UAAO;UACpD;;QACN,EAAQ,aAAa,KACpB,kBAAC,QAAD;SAAM,WAAU;mBAAhB;UACE,kBAAC,GAAD,EAAO,WAAU,eAAgB,CAAA;;UAAE,EAAY,EAAQ,WAAW;UAC7D;;QAET,kBAAC,QAAD,EAAA,UAAA;SAAM;SAAG,EAAQ;SAAW;SAAiB,EAAA,CAAA;QAC3C;SACA,EAAA,CAAA,CACF;SACN,kBAAC,GAAD;MACE,SAAQ;MACR,MAAK;MACL,WAAU;MACV,eAAe;OACb,IAAM,IACJ,EAAQ,MAAM,MAAM,MAAM,EAAE,WAAW,cAAc,IACrD,EAAQ,MAAM,MAAM,MAAM,EAAE,WAAW,cAAc,IACrD,EAAQ,MAAM;AAChB,OAAI,KAAM,EAAW,EAAK;;gBAT9B,CAWC,kBACe,kBAAC,GAAD,EAAY,WAAU,gBAAiB,CAAA,CAC9C;QACL;QAGN,kBAAC,OAAD;KAAK,WAAU;eACb,kBAAC,MAAD;MAAI,WAAU;gBACX,EAAQ,MAAM,KAAK,GAAM,MACxB,kBAAC,MAAD;OAAyB,WAAU;iBAAnC,CACE,kBAAC,UAAD;QACE,MAAK;QACL,eAAe,EAAW,EAAK;QAC/B,WAAW,EACT,oFACA,EAAK,WAAW,cACZ,6GACA,EAAK,WAAW,gBACd,gDACA,sDACP;kBAVH;SAYE,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CAA0D,SAClD,IAAI,EACL;cACN,EAAK,WAAW,cACf,kBAAC,GAAD,EAAc,WAAU,oCAAqC,CAAA,GAC3D,EAAK,WAAW,gBAClB,kBAAC,GAAD,EAAY,WAAU,2CAA4C,CAAA,GAElE,kBAAC,GAAD,EAAQ,WAAU,6CAA8C,CAAA,CAE9D;;SACN,kBAAC,KAAD;UAAG,WAAU;oBACV,EAAK,OAAO;UACX,CAAA;SACJ,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,GAAD,EAAa,QAAQ,EAAK,OAAO,OAAS,CAAA,EACzC,EAAK,WAAW,iBACf,kBAAC,QAAD;WAAM,WAAU;qBAAhB,CACG,EAAK,aAAY,IACb;aAEL;;SACC;WACR,IAAI,EAAQ,MAAM,SAAS,KAC1B,kBAAC,OAAD;QAAK,WAAU;kBACb,kBAAC,GAAD,EAAY,WAAU,6CAA8C,CAAA;QAChE,CAAA,CAEL;SA1CI,EAAK,OAAO,GA0ChB,CACL;MACC,CAAA;KACD,CAAA,CACI;MA/FI,EAAQ,MAAM,GA+FlB,CACZ;GACE,CAAA;EAEG,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SkillsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/skills/SkillsPage.tsx"],"sourcesContent":["/**\n * SkillsPage — the workspace skills taxonomy.\n * @module buildmyiq/pages/skills\n *\n * The competency vocabulary that courses map to (`BuiCourse.skillId → BuiSkill`).\n * Reads the taxonomy via `useBuiSkills(category?)` — the category filter is applied\n * server-side — and writes new competencies via `useBuiCreateSkill`. A small KPI\n * summary (total / active / inactive) is derived from the loaded list, so it is\n * honest for whichever category is currently in view. Every tile renders REAL\n * `bui*` data; the empty state is honest.\n */\n\nimport { useMemo, useState } from 'react';\nimport type { FC } from 'react';\nimport type { LucideIcon } from 'lucide-react';\nimport {\n Plus,\n Sparkles,\n BarChart3,\n Code2,\n Briefcase,\n Palette,\n Brain,\n Users,\n Languages,\n} from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n Label,\n Textarea,\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 ProgressRing,\n FilterTabs,\n EmptyState,\n LoadingView,\n ErrorView,\n type FilterOption,\n} from '../../components';\nimport { useBuiSkills, useBuiCreateSkill } from '../../hooks';\nimport { formatEnumLabel, formatRelativeTime } from '../../utils/formatters';\nimport { SKILL_CATEGORIES } from '../../types';\nimport type { BuiSkill, BuiSkillCategory } from '../../types';\n\ntype CategoryFilter = BuiSkillCategory | 'ALL';\n\nconst CATEGORY_FILTERS: FilterOption<CategoryFilter>[] = [\n { value: 'ALL', label: 'All' },\n ...SKILL_CATEGORIES.map((c) => ({ value: c, label: formatEnumLabel(c) })),\n];\n\n/**\n * Per-category icon + semantic-token colour. Distinct hues drawn ONLY from the\n * Boff UI 2.0 accent/status palette (theme-safe in light + dark, no raw colours).\n */\nconst CATEGORY_VISUAL: Record<BuiSkillCategory, { icon: LucideIcon; color: string }> = {\n TECHNICAL: { icon: Code2, color: 'var(--color-accent-teal)' },\n BUSINESS: { icon: Briefcase, color: 'var(--color-accent-blue)' },\n CREATIVE: { icon: Palette, color: 'var(--color-status-warning-text)' },\n COGNITIVE: { icon: Brain, color: 'var(--color-accent-purple)' },\n SOCIAL: { icon: Users, color: 'var(--color-status-success-text)' },\n LANGUAGE: { icon: Languages, color: 'var(--color-accent-indigo)' },\n};\n\ninterface CreateFormState {\n name: string;\n category: BuiSkillCategory;\n description: string;\n}\n\nconst EMPTY_FORM: CreateFormState = {\n name: '',\n category: 'TECHNICAL',\n description: '',\n};\n\n/** A single competency tile — category-coloured icon, badge, description, state. */\nconst SkillTile: FC<{ skill: BuiSkill }> = ({ skill }) => {\n const visual = CATEGORY_VISUAL[skill.category];\n const Icon = visual.icon;\n return (\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-3 p-5\">\n <div className=\"flex items-start gap-3\">\n <span\n aria-hidden=\"true\"\n className=\"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl\"\n style={{\n backgroundColor: `color-mix(in srgb, ${visual.color} 15%, transparent)`,\n color: visual.color,\n }}\n >\n <Icon className=\"h-5 w-5\" />\n </span>\n <div className=\"flex min-w-0 flex-1 items-start justify-between gap-2\">\n <h3 className=\"min-w-0 break-words font-semibold leading-snug text-text-primary\">\n {skill.name}\n </h3>\n <span\n className=\"inline-flex shrink-0 items-center rounded-full border px-2.5 py-0.5 text-xs font-medium\"\n style={{\n color: visual.color,\n borderColor: `color-mix(in srgb, ${visual.color} 40%, transparent)`,\n }}\n >\n {formatEnumLabel(skill.category)}\n </span>\n </div>\n </div>\n\n {skill.description ? (\n <p className=\"line-clamp-3 text-sm text-text-secondary\">{skill.description}</p>\n ) : (\n <p className=\"text-sm italic text-text-secondary/70\">No description provided.</p>\n )}\n\n <div className=\"mt-auto flex items-center justify-between gap-3 border-t border-border-seam/60 pt-3\">\n <span\n className={\n skill.isActive\n ? 'inline-flex items-center gap-1.5 text-xs font-medium text-status-success-text'\n : 'inline-flex items-center gap-1.5 text-xs font-medium text-text-secondary'\n }\n >\n <span\n aria-hidden=\"true\"\n className={\n skill.isActive\n ? 'size-1.5 rounded-full bg-status-success-text'\n : 'size-1.5 rounded-full bg-text-secondary'\n }\n />\n {skill.isActive ? 'Active' : 'Inactive'}\n </span>\n <span className=\"text-xs text-text-tertiary\">Added {formatRelativeTime(skill.createdAt)}</span>\n </div>\n </GlassCard>\n );\n};\n\nexport const SkillsPage: FC = () => {\n const [category, setCategory] = useState<CategoryFilter>('ALL');\n const [dialogOpen, setDialogOpen] = useState(false);\n const [form, setForm] = useState<CreateFormState>(EMPTY_FORM);\n const [formError, setFormError] = useState<string | null>(null);\n\n const { data, isLoading, isError, refetch } = useBuiSkills(\n category === 'ALL' ? undefined : category\n );\n const createSkill = useBuiCreateSkill();\n\n const skills = useMemo<BuiSkill[]>(() => data ?? [], [data]);\n\n // KPIs are derived from the loaded (optionally category-filtered) list.\n const summary = useMemo(() => {\n const total = skills.length;\n const active = skills.filter((s) => s.isActive).length;\n const activeRatio = total > 0 ? Math.round((active / total) * 100) : 0;\n return { total, active, inactive: total - active, activeRatio };\n }, [skills]);\n\n const scopeLabel = category === 'ALL' ? 'Skills' : `${formatEnumLabel(category)} skills`;\n const showSummary = !isLoading && !isError && summary.total > 0;\n\n const resetForm = () => {\n setForm(EMPTY_FORM);\n setFormError(null);\n };\n\n const closeDialog = () => {\n setDialogOpen(false);\n resetForm();\n };\n\n const handleCreate = async () => {\n const name = form.name.trim();\n if (!name) {\n setFormError('A skill name is required.');\n return;\n }\n try {\n await createSkill.mutateAsync({\n name,\n category: form.category,\n description: form.description.trim() || undefined,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to create skill.');\n }\n };\n\n return (\n <PageLayout\n title=\"Skills\"\n description=\"The competency taxonomy your courses map to — organised by category.\"\n titleGradient\n aurora\n actions={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n >\n <div className=\"flex flex-col gap-6\">\n {/* Summary strip — scoped to the loaded list. */}\n {showSummary && (\n <GlassCard\n treatment=\"glass\"\n glow\n className=\"flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between\"\n >\n <div className=\"flex flex-wrap items-center gap-x-6 gap-y-3\">\n <div className=\"flex items-center gap-3\">\n <span className=\"flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--color-accent-soft)] text-[var(--color-accent-primary)]\">\n <BarChart3 className=\"h-5 w-5\" />\n </span>\n <span className=\"flex items-baseline gap-1.5\">\n <span className=\"text-2xl font-bold tabular-nums text-text-primary\">\n {summary.total}\n </span>\n <span className=\"text-sm text-text-secondary\">{scopeLabel}</span>\n </span>\n </div>\n <span aria-hidden=\"true\" className=\"hidden h-8 w-px bg-border-seam sm:block\" />\n <span className=\"inline-flex items-center gap-2 text-sm\">\n <span className=\"size-2 rounded-full bg-status-success-text\" />\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {summary.active}\n </span>\n <span className=\"text-text-secondary\">Active</span>\n </span>\n <span className=\"inline-flex items-center gap-2 text-sm\">\n <span className=\"size-2 rounded-full bg-text-tertiary\" />\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {summary.inactive}\n </span>\n <span className=\"text-text-secondary\">Inactive</span>\n </span>\n </div>\n <div className=\"flex items-center gap-3 self-start sm:self-auto\">\n <ProgressRing value={summary.activeRatio} size={52} stroke={5} />\n <span className=\"text-xs leading-tight text-text-secondary\">\n of the taxonomy\n <br />\n is active\n </span>\n </div>\n </GlassCard>\n )}\n\n <FilterTabs options={CATEGORY_FILTERS} value={category} onChange={setCategory} />\n\n {isLoading ? (\n <LoadingView label=\"Loading skills…\" />\n ) : isError ? (\n <ErrorView\n title=\"Couldn't load skills\"\n message=\"The skills taxonomy failed to load.\"\n onRetry={() => refetch()}\n />\n ) : skills.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<Sparkles className=\"h-10 w-10\" />}\n title={category === 'ALL' ? 'No skills yet' : 'No skills in this category'}\n description={\n category === 'ALL'\n ? 'Define the competencies your courses build. Skills let you organise the catalog and target learning outcomes.'\n : 'No skills are tagged with this category yet. Add one, or switch categories.'\n }\n action={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n />\n ) : (\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {skills.map((skill) => (\n <SkillTile key={skill.id} skill={skill} />\n ))}\n </div>\n )}\n </div>\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (open) setDialogOpen(true);\n else closeDialog();\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>New skill</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Add a competency to the taxonomy. Courses can then be mapped to it.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-name\">Name *</Label>\n <Input\n id=\"skill-name\"\n value={form.name}\n onChange={(e) => setForm({ ...form, name: e.target.value })}\n placeholder=\"e.g. Data Visualisation\"\n />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-category\">Category</Label>\n <Select\n value={form.category}\n onValueChange={(v) => setForm({ ...form, category: v as BuiSkillCategory })}\n >\n <SelectTrigger id=\"skill-category\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {SKILL_CATEGORIES.map((c) => (\n <SelectItem key={c} value={c}>\n {formatEnumLabel(c)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-description\">Description</Label>\n <Textarea\n id=\"skill-description\"\n value={form.description}\n onChange={(e) => setForm({ ...form, description: e.target.value })}\n placeholder=\"Optional — what this competency covers and how it's assessed.\"\n rows={3}\n />\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button onClick={handleCreate} disabled={createSkill.isPending}>\n {createSkill.isPending ? 'Creating…' : 'Create skill'}\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SkillsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/skills/SkillsPage.tsx"],"sourcesContent":["/**\n * SkillsPage — the workspace skills taxonomy.\n * @module buildmyiq/pages/skills\n *\n * The competency vocabulary that courses map to (`BuiCourse.skillId → BuiSkill`).\n * Reads the taxonomy via `useBuiSkills(category?)` — the category filter is applied\n * server-side — and writes new competencies via `useBuiCreateSkill`. A small KPI\n * summary (total / active / inactive) is derived from the loaded list, so it is\n * honest for whichever category is currently in view. Every tile renders REAL\n * `bui*` data; the empty state is honest.\n */\n\nimport { useMemo, useState } from 'react';\nimport type { FC } from 'react';\nimport type { LucideIcon } from 'lucide-react';\nimport {\n Plus,\n Sparkles,\n BarChart3,\n Code2,\n Briefcase,\n Palette,\n Brain,\n Users,\n Languages,\n} from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n Label,\n Textarea,\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 ProgressRing,\n FilterTabs,\n EmptyState,\n LoadingView,\n ErrorView,\n type FilterOption,\n} from '../../components';\nimport { useBuiSkills, useBuiCreateSkill } from '../../hooks';\nimport { formatEnumLabel, formatRelativeTime } from '../../utils/formatters';\nimport { SKILL_CATEGORIES } from '../../types';\nimport type { BuiSkill, BuiSkillCategory } from '../../types';\n\ntype CategoryFilter = BuiSkillCategory | 'ALL';\n\nconst CATEGORY_FILTERS: FilterOption<CategoryFilter>[] = [\n { value: 'ALL', label: 'All' },\n ...SKILL_CATEGORIES.map((c) => ({ value: c, label: formatEnumLabel(c) })),\n];\n\n/**\n * Per-category icon + semantic-token colour. Distinct hues drawn ONLY from the\n * Boff UI 2.0 accent/status palette (theme-safe in light + dark, no raw colours).\n */\nconst CATEGORY_VISUAL: Record<BuiSkillCategory, { icon: LucideIcon; color: string }> = {\n TECHNICAL: { icon: Code2, color: 'var(--color-accent-teal)' },\n BUSINESS: { icon: Briefcase, color: 'var(--color-accent-blue)' },\n CREATIVE: { icon: Palette, color: 'var(--color-status-warning-text)' },\n COGNITIVE: { icon: Brain, color: 'var(--color-accent-purple)' },\n SOCIAL: { icon: Users, color: 'var(--color-status-success-text)' },\n LANGUAGE: { icon: Languages, color: 'var(--color-accent-indigo)' },\n};\n\ninterface CreateFormState {\n name: string;\n category: BuiSkillCategory;\n description: string;\n}\n\nconst EMPTY_FORM: CreateFormState = {\n name: '',\n category: 'TECHNICAL',\n description: '',\n};\n\n/** A single competency tile — category-coloured icon, badge, description, state. */\nconst SkillTile: FC<{ skill: BuiSkill }> = ({ skill }) => {\n const visual = CATEGORY_VISUAL[skill.category];\n const Icon = visual.icon;\n return (\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-3 p-5\">\n <div className=\"flex items-start gap-3\">\n <span\n aria-hidden=\"true\"\n className=\"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl\"\n style={{\n backgroundColor: `color-mix(in srgb, ${visual.color} 15%, transparent)`,\n color: visual.color,\n }}\n >\n <Icon className=\"h-5 w-5\" />\n </span>\n <div className=\"flex min-w-0 flex-1 items-start justify-between gap-2\">\n <h3 className=\"min-w-0 break-words font-semibold leading-snug text-text-primary\">\n {skill.name}\n </h3>\n <span\n className=\"inline-flex shrink-0 items-center rounded-full border px-2.5 py-0.5 text-xs font-medium\"\n style={{\n color: visual.color,\n borderColor: `color-mix(in srgb, ${visual.color} 40%, transparent)`,\n }}\n >\n {formatEnumLabel(skill.category)}\n </span>\n </div>\n </div>\n\n {skill.description ? (\n <p className=\"line-clamp-3 text-sm text-text-secondary\">{skill.description}</p>\n ) : (\n <p className=\"text-sm italic text-text-secondary/70\">No description provided.</p>\n )}\n\n <div className=\"mt-auto flex items-center justify-between gap-3 border-t border-border-seam/60 pt-3\">\n <span\n className={\n skill.isActive\n ? 'inline-flex items-center gap-1.5 text-xs font-medium text-status-success-text'\n : 'inline-flex items-center gap-1.5 text-xs font-medium text-text-secondary'\n }\n >\n <span\n aria-hidden=\"true\"\n className={\n skill.isActive\n ? 'size-1.5 rounded-full bg-status-success-text'\n : 'size-1.5 rounded-full bg-text-secondary'\n }\n />\n {skill.isActive ? 'Active' : 'Inactive'}\n </span>\n <span className=\"text-xs text-text-tertiary\">\n Added {formatRelativeTime(skill.createdAt)}\n </span>\n </div>\n </GlassCard>\n );\n};\n\nexport const SkillsPage: FC = () => {\n const [category, setCategory] = useState<CategoryFilter>('ALL');\n const [dialogOpen, setDialogOpen] = useState(false);\n const [form, setForm] = useState<CreateFormState>(EMPTY_FORM);\n const [formError, setFormError] = useState<string | null>(null);\n\n const { data, isLoading, isError, refetch } = useBuiSkills(\n category === 'ALL' ? undefined : category\n );\n const createSkill = useBuiCreateSkill();\n\n const skills = useMemo<BuiSkill[]>(() => data ?? [], [data]);\n\n // KPIs are derived from the loaded (optionally category-filtered) list.\n const summary = useMemo(() => {\n const total = skills.length;\n const active = skills.filter((s) => s.isActive).length;\n const activeRatio = total > 0 ? Math.round((active / total) * 100) : 0;\n return { total, active, inactive: total - active, activeRatio };\n }, [skills]);\n\n const scopeLabel = category === 'ALL' ? 'Skills' : `${formatEnumLabel(category)} skills`;\n const showSummary = !isLoading && !isError && summary.total > 0;\n\n const resetForm = () => {\n setForm(EMPTY_FORM);\n setFormError(null);\n };\n\n const closeDialog = () => {\n setDialogOpen(false);\n resetForm();\n };\n\n const handleCreate = async () => {\n const name = form.name.trim();\n if (!name) {\n setFormError('A skill name is required.');\n return;\n }\n try {\n await createSkill.mutateAsync({\n name,\n category: form.category,\n description: form.description.trim() || undefined,\n });\n closeDialog();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to create skill.');\n }\n };\n\n return (\n <PageLayout\n title=\"Skills\"\n description=\"The competency taxonomy your courses map to — organised by category.\"\n titleGradient\n aurora\n actions={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n >\n <div className=\"flex flex-col gap-6\">\n {/* Summary strip — scoped to the loaded list. */}\n {showSummary && (\n <GlassCard\n treatment=\"glass\"\n glow\n className=\"flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between\"\n >\n <div className=\"flex flex-wrap items-center gap-x-6 gap-y-3\">\n <div className=\"flex items-center gap-3\">\n <span className=\"flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--color-accent-soft)] text-[var(--color-accent-primary)]\">\n <BarChart3 className=\"h-5 w-5\" />\n </span>\n <span className=\"flex items-baseline gap-1.5\">\n <span className=\"text-2xl font-bold tabular-nums text-text-primary\">\n {summary.total}\n </span>\n <span className=\"text-sm text-text-secondary\">{scopeLabel}</span>\n </span>\n </div>\n <span aria-hidden=\"true\" className=\"hidden h-8 w-px bg-border-seam sm:block\" />\n <span className=\"inline-flex items-center gap-2 text-sm\">\n <span className=\"size-2 rounded-full bg-status-success-text\" />\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {summary.active}\n </span>\n <span className=\"text-text-secondary\">Active</span>\n </span>\n <span className=\"inline-flex items-center gap-2 text-sm\">\n <span className=\"size-2 rounded-full bg-text-tertiary\" />\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {summary.inactive}\n </span>\n <span className=\"text-text-secondary\">Inactive</span>\n </span>\n </div>\n <div className=\"flex items-center gap-3 self-start sm:self-auto\">\n <ProgressRing value={summary.activeRatio} size={52} stroke={5} />\n <span className=\"text-xs leading-tight text-text-secondary\">\n of the taxonomy\n <br />\n is active\n </span>\n </div>\n </GlassCard>\n )}\n\n <FilterTabs options={CATEGORY_FILTERS} value={category} onChange={setCategory} />\n\n {isLoading ? (\n <LoadingView label=\"Loading skills…\" />\n ) : isError ? (\n <ErrorView\n title=\"Couldn't load skills\"\n message=\"The skills taxonomy failed to load.\"\n onRetry={() => refetch()}\n />\n ) : skills.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<Sparkles className=\"h-10 w-10\" />}\n title={category === 'ALL' ? 'No skills yet' : 'No skills in this category'}\n description={\n category === 'ALL'\n ? 'Define the competencies your courses build. Skills let you organise the catalog and target learning outcomes.'\n : 'No skills are tagged with this category yet. Add one, or switch categories.'\n }\n action={\n <Button onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New skill\n </Button>\n }\n />\n ) : (\n <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {skills.map((skill) => (\n <SkillTile key={skill.id} skill={skill} />\n ))}\n </div>\n )}\n </div>\n\n <ResponsiveDialog\n open={dialogOpen}\n onOpenChange={(open) => {\n if (open) setDialogOpen(true);\n else closeDialog();\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>New skill</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Add a competency to the taxonomy. Courses can then be mapped to it.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"grid gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-name\">Name *</Label>\n <Input\n id=\"skill-name\"\n value={form.name}\n onChange={(e) => setForm({ ...form, name: e.target.value })}\n placeholder=\"e.g. Data Visualisation\"\n />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-category\">Category</Label>\n <Select\n value={form.category}\n onValueChange={(v) => setForm({ ...form, category: v as BuiSkillCategory })}\n >\n <SelectTrigger id=\"skill-category\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {SKILL_CATEGORIES.map((c) => (\n <SelectItem key={c} value={c}>\n {formatEnumLabel(c)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"skill-description\">Description</Label>\n <Textarea\n id=\"skill-description\"\n value={form.description}\n onChange={(e) => setForm({ ...form, description: e.target.value })}\n placeholder=\"Optional — what this competency covers and how it's assessed.\"\n rows={3}\n />\n </div>\n\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={closeDialog}>\n Cancel\n </Button>\n <Button onClick={handleCreate} disabled={createSkill.isPending}>\n {createSkill.isPending ? 'Creating…' : 'Create skill'}\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
@@ -356,14 +356,10 @@ var S = 24, _e = [
|
|
|
356
356
|
}),
|
|
357
357
|
N === "submission" ? /* @__PURE__ */ _("div", { children: [/* @__PURE__ */ _("p", {
|
|
358
358
|
className: "mb-2 text-sm font-semibold text-text-primary",
|
|
359
|
-
children: [
|
|
360
|
-
"
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
className: "font-normal text-text-secondary",
|
|
364
|
-
children: "(read-only)"
|
|
365
|
-
})
|
|
366
|
-
]
|
|
359
|
+
children: ["Answers ", /* @__PURE__ */ g("span", {
|
|
360
|
+
className: "font-normal text-text-secondary",
|
|
361
|
+
children: "(read-only)"
|
|
362
|
+
})]
|
|
367
363
|
}), /* @__PURE__ */ g(E, { answers: j.answers })] }) : /* @__PURE__ */ _("div", {
|
|
368
364
|
className: "grid grid-cols-2 gap-4 rounded-xl bg-bg-sunken/60 p-4",
|
|
369
365
|
children: [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SubmissionsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/submissions/SubmissionsPage.tsx"],"sourcesContent":["/**\n * SubmissionsPage — the workspace-wide grading queue.\n * @module buildmyiq/pages/submissions\n *\n * The educator surface for assessment submissions. Reads every submission in the\n * workspace via `useBuiSubmissions` (no `assessmentId` → all assessments),\n * filterable by status, and paginated by cursor. Clicking a row opens a grading\n * slide-over — a right-hand panel on desktop, a bottom-sheet on mobile — that\n * shows the learner's answers read-only (rendered safely from the `answers` JSON\n * blob) under a Submission / Details tab pair, plus a grading form\n * (`useBuiGradeSubmission`: score, passed, feedback). Already-graded rows show\n * their result read-only with a \"Re-grade\" affordance.\n *\n * Every number is derived from live `bui*` data; the KPI row is explicitly scoped\n * to the current page (\"this page\") so it never overstates workspace totals.\n * Learner identity is shown honestly: the signed-in educator's own attempts get\n * their real name + \"You\"; everyone else is a deterministic initials avatar keyed\n * off the opaque learner id — never a fabricated name or stock photo.\n */\n\nimport { useMemo, useState } from 'react';\nimport type { FC, ReactNode } from 'react';\nimport {\n ClipboardList,\n Clock,\n CheckCircle2,\n Undo2,\n GraduationCap,\n BookOpen,\n} from 'lucide-react';\nimport {\n Button,\n Input,\n Label,\n Textarea,\n Switch,\n Sheet,\n SheetContent,\n SheetHeader,\n SheetTitle,\n SheetDescription,\n SheetFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { useMediaQuery } from '@burdenoff/fe-libs/shared/hooks';\nimport {\n PageLayout,\n MetricsCard,\n StatusBadge,\n DataTable,\n Tabs,\n Pagination,\n EmptyState,\n LoadingView,\n ErrorView,\n Avatar,\n type Column,\n type TabItem,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiSubmissions, useBuiGradeSubmission, useCursorPager } from '../../hooks';\nimport { formatDate, formatDateTime } from '../../utils/formatters';\nimport type { BuiSubmission, BuiSubmissionStatus } from '../../types';\n\nconst PAGE_SIZE = 24;\n\ntype StatusFilter = BuiSubmissionStatus | 'ALL';\ntype PanelTab = 'submission' | 'details';\n\nconst STATUS_TABS: TabItem<StatusFilter>[] = [\n { value: 'ALL', label: 'All' },\n { value: 'SUBMITTED', label: 'Submitted' },\n { value: 'GRADING', label: 'Grading' },\n { value: 'GRADED', label: 'Graded' },\n { value: 'RETURNED', label: 'Returned' },\n];\n\nconst PANEL_TABS: TabItem<PanelTab>[] = [\n { value: 'submission', label: 'Submission' },\n { value: 'details', label: 'Details' },\n];\n\n/** Truncate an opaque id for compact display, keeping the full value in `title`. */\nconst shortId = (id: string): string => (id.length > 10 ? `${id.slice(0, 8)}…` : id);\n\n/** Time-only rendering (e.g. \"10:24 AM\") reusing the locale-aware date formatter. */\nconst formatClock = (date: string | null | undefined): string =>\n formatDate(date, { day: undefined, month: undefined, year: undefined, hour: 'numeric', minute: '2-digit' });\n\nconst isPlainRecord = (v: unknown): v is Record<string, unknown> =>\n typeof v === 'object' && v !== null && !Array.isArray(v);\n\n/** Render a single answer value safely, whatever its JSON shape. */\nconst formatAnswerValue = (v: unknown): string => {\n if (v == null) return '—';\n if (typeof v === 'string') return v;\n if (typeof v === 'number' || typeof v === 'boolean') return String(v);\n return JSON.stringify(v, null, 2);\n};\n\n/** Read-only rendering of a submission's `answers` JSON blob. */\nconst AnswersView: FC<{ answers: unknown }> = ({ answers }) => {\n if (answers == null) {\n return <p className=\"text-sm text-text-secondary\">No answers were submitted.</p>;\n }\n if (isPlainRecord(answers)) {\n const entries = Object.entries(answers);\n if (entries.length === 0) {\n return <p className=\"text-sm text-text-secondary\">No answers were submitted.</p>;\n }\n return (\n <ol className=\"grid gap-2.5\">\n {entries.map(([key, value], i) => (\n <li\n key={key}\n className=\"grid gap-1 rounded-lg border border-border-seam/70 bg-bg-surface/60 p-3 sm:grid-cols-[minmax(0,7rem)_1fr] sm:gap-3\"\n >\n <div className=\"flex items-baseline gap-2 sm:col-span-1\">\n <span className=\"shrink-0 text-xs font-semibold text-[var(--color-accent-teal)] tabular-nums\">\n Q{i + 1}\n </span>\n <span className=\"break-words font-mono text-xs text-text-secondary\">{key}</span>\n </div>\n <span className=\"whitespace-pre-wrap break-words text-sm text-text-primary sm:col-span-1\">\n {formatAnswerValue(value)}\n </span>\n </li>\n ))}\n </ol>\n );\n }\n return (\n <pre className=\"overflow-x-auto whitespace-pre-wrap break-words rounded-lg border border-border-seam/70 bg-bg-surface/60 p-3 text-xs text-text-primary\">\n {JSON.stringify(answers, null, 2)}\n </pre>\n );\n};\n\n/** A labelled read-only field for the submission meta / graded-result panels. */\nconst MetaField: FC<{ label: string; children: ReactNode }> = ({ label, children }) => (\n <div className=\"min-w-0\">\n <p className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">{label}</p>\n <div className=\"mt-0.5 text-sm text-text-primary\">{children}</div>\n </div>\n);\n\nexport const SubmissionsPage: FC = () => {\n const { currentUser } = useBuildmyiq();\n const isDesktop = useMediaQuery('(min-width: 768px)');\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');\n const pager = useCursorPager();\n\n const [selected, setSelected] = useState<BuiSubmission | null>(null);\n const [panelTab, setPanelTab] = useState<PanelTab>('submission');\n const [editMode, setEditMode] = useState(false);\n const [scoreInput, setScoreInput] = useState('');\n const [passed, setPassed] = useState(false);\n const [feedback, setFeedback] = useState('');\n const [formError, setFormError] = useState<string | null>(null);\n\n const { data, isLoading, isError, refetch } = useBuiSubmissions({\n status: statusFilter === 'ALL' ? undefined : statusFilter,\n cursor: pager.cursor,\n limit: PAGE_SIZE,\n });\n const gradeSubmission = useBuiGradeSubmission();\n\n const rows = useMemo<BuiSubmission[]>(() => data?.items ?? [], [data]);\n\n // The signed-in educator's own display name (used only for their own rows).\n const meName = useMemo<string | undefined>(() => {\n if (!currentUser) return undefined;\n const full = [currentUser.firstName, currentUser.lastName].filter(Boolean).join(' ').trim();\n return currentUser.name?.trim() || full || currentUser.username || currentUser.email;\n }, [currentUser]);\n\n // Counts are scoped to the loaded page — labelled \"this page\" to stay honest.\n const counts = useMemo(() => {\n const acc = { total: rows.length, awaiting: 0, graded: 0, returned: 0 };\n for (const s of rows) {\n if (s.status === 'SUBMITTED' || s.status === 'GRADING') acc.awaiting += 1;\n else if (s.status === 'GRADED') acc.graded += 1;\n else if (s.status === 'RETURNED') acc.returned += 1;\n }\n return acc;\n }, [rows]);\n\n const isMe = (learnerId: string): boolean => learnerId === currentUser?.id;\n const learnerLabel = (learnerId: string): string =>\n isMe(learnerId) ? 'You' : `Learner ${shortId(learnerId)}`;\n\n const openGrade = (s: BuiSubmission) => {\n setSelected(s);\n setPanelTab('submission');\n setScoreInput(s.score != null ? String(s.score) : '');\n setPassed(s.passed ?? false);\n setFeedback(s.feedback ?? '');\n setEditMode(false);\n setFormError(null);\n };\n\n const closePanel = () => {\n setSelected(null);\n setEditMode(false);\n setFormError(null);\n };\n\n const handleFilterChange = (value: StatusFilter) => {\n setStatusFilter(value);\n pager.reset();\n };\n\n const handleGrade = async () => {\n if (!selected) return;\n const trimmed = scoreInput.trim();\n const scoreNum = Number(trimmed);\n if (trimmed === '' || Number.isNaN(scoreNum)) {\n setFormError('Enter a numeric score.');\n return;\n }\n if (scoreNum < 0) {\n setFormError('Score cannot be negative.');\n return;\n }\n try {\n await gradeSubmission.mutateAsync({\n id: selected.id,\n input: { score: scoreNum, passed, feedback: feedback.trim() || undefined },\n });\n closePanel();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to save grade.');\n }\n };\n\n const gradingAllowed =\n selected != null && (selected.status === 'SUBMITTED' || selected.status === 'GRADING');\n const alreadyGraded =\n selected != null && (selected.status === 'GRADED' || selected.status === 'RETURNED');\n const notSubmitted = selected != null && selected.status === 'IN_PROGRESS';\n const showGradeForm = selected != null && (gradingAllowed || editMode);\n\n const columns: Column<BuiSubmission>[] = [\n {\n key: 'learner',\n header: 'Learner',\n sortValue: (s) => (isMe(s.learnerId) ? '' : s.learnerId),\n render: (s) => (\n <div className=\"flex items-center gap-3\">\n <Avatar\n size=\"sm\"\n name={isMe(s.learnerId) ? meName : undefined}\n seed={s.learnerId}\n src={isMe(s.learnerId) ? currentUser?.avatar : undefined}\n />\n <div className=\"min-w-0\">\n <span className=\"block truncate font-medium text-text-primary\" title={s.learnerId}>\n {isMe(s.learnerId) ? 'You' : shortId(s.learnerId)}\n </span>\n {isMe(s.learnerId) && meName && (\n <span className=\"block truncate text-xs text-text-secondary\">{meName}</span>\n )}\n </div>\n </div>\n ),\n },\n {\n key: 'assessment',\n header: 'Assessment',\n hideOnMobile: true,\n render: (s) => (\n <span className=\"inline-flex items-center gap-2 text-text-secondary\">\n <BookOpen className=\"h-4 w-4 shrink-0 opacity-70\" />\n <span className=\"font-mono text-xs\" title={s.assessmentId}>\n {shortId(s.assessmentId)}\n </span>\n </span>\n ),\n },\n {\n key: 'attempt',\n header: 'Attempt',\n align: 'right',\n hideOnMobile: true,\n sortValue: (s) => s.attempt,\n render: (s) => <span className=\"tabular-nums text-text-secondary\">#{s.attempt}</span>,\n },\n {\n key: 'status',\n header: 'Status',\n sortValue: (s) => s.status,\n render: (s) => <StatusBadge status={s.status} />,\n },\n {\n key: 'score',\n header: 'Score',\n align: 'right',\n sortValue: (s) => s.score ?? -1,\n render: (s) =>\n s.score == null ? (\n <span className=\"text-text-secondary\">—</span>\n ) : (\n <span className=\"tabular-nums font-semibold text-text-primary\">{s.score}</span>\n ),\n },\n {\n key: 'submittedAt',\n header: 'Submitted',\n align: 'right',\n hideOnMobile: true,\n sortValue: (s) => s.submittedAt ?? s.createdAt,\n render: (s) => (\n <div className=\"whitespace-nowrap text-right\">\n <div className=\"text-text-primary\">{formatDate(s.submittedAt)}</div>\n {s.submittedAt && (\n <div className=\"text-xs text-text-secondary\">{formatClock(s.submittedAt)}</div>\n )}\n </div>\n ),\n },\n ];\n\n return (\n <PageLayout\n title=\"Grading\"\n description=\"Review and grade assessment submissions across every course in your workspace.\"\n titleGradient\n aurora\n >\n <div className=\"flex flex-col gap-6\">\n {/* KPI row — scoped to the current page. */}\n <div className=\"grid grid-cols-2 gap-4 xl:grid-cols-4\">\n <MetricsCard\n icon={<ClipboardList className=\"h-5 w-5\" />}\n value={counts.total}\n label=\"Submissions (this page)\"\n featured\n />\n <MetricsCard\n icon={<Clock className=\"h-5 w-5\" />}\n value={counts.awaiting}\n label=\"Awaiting grade (this page)\"\n accent=\"warning\"\n />\n <MetricsCard\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n value={counts.graded}\n label=\"Graded (this page)\"\n accent=\"success\"\n />\n <MetricsCard\n icon={<Undo2 className=\"h-5 w-5\" />}\n value={counts.returned}\n label=\"Returned (this page)\"\n />\n </div>\n\n <Tabs items={STATUS_TABS} value={statusFilter} onChange={handleFilterChange} />\n\n {isLoading ? (\n <LoadingView label=\"Loading submissions…\" />\n ) : isError ? (\n <ErrorView\n title=\"Couldn't load submissions\"\n message=\"The grading queue failed to load.\"\n onRetry={() => refetch()}\n />\n ) : rows.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<GraduationCap className=\"h-10 w-10\" />}\n title={statusFilter === 'ALL' ? 'No submissions yet' : 'Nothing in this status'}\n description={\n statusFilter === 'ALL'\n ? 'When learners submit assessments, their attempts will appear here for grading.'\n : 'No submissions currently match this status filter.'\n }\n />\n ) : (\n <div className=\"flex flex-col gap-4\">\n <DataTable\n columns={columns}\n rows={rows}\n rowKey={(s) => s.id}\n onRowClick={openGrade}\n defaultSort={{ key: 'submittedAt', dir: 'desc' }}\n />\n <Pagination\n page={pager.page}\n pageSize={PAGE_SIZE}\n itemCount={rows.length}\n hasMore={data?.pageInfo?.hasMore ?? false}\n onPageChange={(target) => pager.onPageChange(target, data?.pageInfo?.cursor)}\n />\n </div>\n )}\n </div>\n\n {/* Grading slide-over — right panel on desktop, bottom-sheet on mobile. */}\n <Sheet\n open={selected != null}\n onOpenChange={(open) => {\n if (!open) closePanel();\n }}\n >\n <SheetContent\n side={isDesktop ? 'right' : 'bottom'}\n className={\n isDesktop\n ? 'flex w-full max-w-none flex-col gap-0 p-0 sm:max-w-xl'\n : 'flex max-h-[92vh] w-full flex-col gap-0 rounded-t-2xl p-0'\n }\n >\n {selected && (\n <>\n <SheetHeader className=\"border-b border-border-seam p-5 pr-12\">\n <div className=\"flex items-center gap-3\">\n <Avatar\n size=\"md\"\n name={isMe(selected.learnerId) ? meName : undefined}\n seed={selected.learnerId}\n src={isMe(selected.learnerId) ? currentUser?.avatar : undefined}\n />\n <div className=\"min-w-0\">\n <SheetTitle className=\"truncate text-lg\">\n {learnerLabel(selected.learnerId)}\n </SheetTitle>\n <SheetDescription\n className=\"truncate font-mono text-xs\"\n title={selected.assessmentId}\n >\n Assessment {shortId(selected.assessmentId)}\n </SheetDescription>\n </div>\n </div>\n <p className=\"text-xs text-text-secondary\">\n Attempt #{selected.attempt} · Submitted {formatDateTime(selected.submittedAt)}\n </p>\n <div className=\"pt-1\">\n <StatusBadge status={selected.status} />\n </div>\n </SheetHeader>\n\n <div className=\"flex-1 overflow-y-auto px-5 py-4\">\n <Tabs items={PANEL_TABS} value={panelTab} onChange={setPanelTab} className=\"mb-4\" />\n\n {panelTab === 'submission' ? (\n <div>\n <p className=\"mb-2 text-sm font-semibold text-text-primary\">\n Answers{' '}\n <span className=\"font-normal text-text-secondary\">(read-only)</span>\n </p>\n <AnswersView answers={selected.answers} />\n </div>\n ) : (\n <div className=\"grid grid-cols-2 gap-4 rounded-xl bg-bg-sunken/60 p-4\">\n <MetaField label=\"Status\">\n <StatusBadge status={selected.status} />\n </MetaField>\n <MetaField label=\"Attempt\">\n <span className=\"tabular-nums\">#{selected.attempt}</span>\n </MetaField>\n <MetaField label=\"Submitted\">\n {formatDateTime(selected.submittedAt)}\n </MetaField>\n <MetaField label=\"Assessment\">\n <span className=\"font-mono text-xs\" title={selected.assessmentId}>\n {shortId(selected.assessmentId)}\n </span>\n </MetaField>\n {selected.enrollmentId && (\n <MetaField label=\"Enrollment\">\n <span className=\"font-mono text-xs\" title={selected.enrollmentId}>\n {shortId(selected.enrollmentId)}\n </span>\n </MetaField>\n )}\n {selected.gradedAt && (\n <MetaField label=\"Graded\">{formatDateTime(selected.gradedAt)}</MetaField>\n )}\n </div>\n )}\n\n {/* Grade / result — persistent below the tab content. */}\n <div className=\"mt-6 border-t border-border-seam pt-5\">\n <p className=\"mb-3 text-sm font-semibold text-text-primary\">Grade</p>\n\n {notSubmitted && (\n <p className=\"rounded-lg bg-bg-sunken/60 px-4 py-3 text-sm text-text-secondary\">\n This attempt is still in progress — there is nothing to grade until the\n learner submits.\n </p>\n )}\n\n {alreadyGraded && !editMode && (\n <div className=\"rounded-xl border border-border-seam p-4\">\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"grid grid-cols-2 gap-4\">\n <MetaField label=\"Score\">\n <span className=\"tabular-nums font-semibold\">\n {selected.score ?? '—'}\n </span>\n </MetaField>\n <MetaField label=\"Result\">\n {selected.passed == null ? (\n <span className=\"text-text-secondary\">—</span>\n ) : (\n <StatusBadge status={selected.passed ? 'GRADED' : 'RETURNED'} />\n )}\n </MetaField>\n </div>\n <Button variant=\"outline\" size=\"sm\" onClick={() => setEditMode(true)}>\n Re-grade\n </Button>\n </div>\n <div className=\"mt-3\">\n <MetaField label=\"Feedback\">\n {selected.feedback ? (\n <span className=\"whitespace-pre-wrap\">{selected.feedback}</span>\n ) : (\n <span className=\"text-text-secondary\">No feedback provided.</span>\n )}\n </MetaField>\n </div>\n </div>\n )}\n\n {showGradeForm && (\n <div className=\"grid gap-4\">\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"grade-score\">Score</Label>\n <Input\n id=\"grade-score\"\n type=\"number\"\n min=\"0\"\n step=\"1\"\n value={scoreInput}\n onChange={(e) => setScoreInput(e.target.value)}\n placeholder=\"e.g. 85\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"grade-passed\">Result</Label>\n <div className=\"flex h-10 items-center gap-3\">\n <Switch\n id=\"grade-passed\"\n checked={passed}\n onCheckedChange={setPassed}\n />\n <span className=\"text-sm text-text-secondary\">\n {passed ? 'Passed' : 'Not passed'}\n </span>\n </div>\n </div>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"grade-feedback\">Feedback</Label>\n <Textarea\n id=\"grade-feedback\"\n value={feedback}\n onChange={(e) => setFeedback(e.target.value)}\n placeholder=\"Notes, corrections or encouragement for the learner (optional).\"\n rows={4}\n />\n </div>\n {formError && (\n <p className=\"text-sm text-status-error-text\">{formError}</p>\n )}\n </div>\n )}\n </div>\n </div>\n\n <SheetFooter className=\"flex-row justify-end gap-3 border-t border-border-seam p-4\">\n <Button variant=\"outline\" onClick={closePanel}>\n {showGradeForm ? 'Cancel' : 'Close'}\n </Button>\n {showGradeForm && (\n <Button onClick={handleGrade} disabled={gradeSubmission.isPending}>\n {gradeSubmission.isPending\n ? 'Saving…'\n : alreadyGraded\n ? 'Update grade'\n : 'Grade submission'}\n </Button>\n )}\n </SheetFooter>\n </>\n )}\n </SheetContent>\n </Sheet>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SubmissionsPage.js","names":[],"sources":["../../../../src/buildmyiq/pages/submissions/SubmissionsPage.tsx"],"sourcesContent":["/**\n * SubmissionsPage — the workspace-wide grading queue.\n * @module buildmyiq/pages/submissions\n *\n * The educator surface for assessment submissions. Reads every submission in the\n * workspace via `useBuiSubmissions` (no `assessmentId` → all assessments),\n * filterable by status, and paginated by cursor. Clicking a row opens a grading\n * slide-over — a right-hand panel on desktop, a bottom-sheet on mobile — that\n * shows the learner's answers read-only (rendered safely from the `answers` JSON\n * blob) under a Submission / Details tab pair, plus a grading form\n * (`useBuiGradeSubmission`: score, passed, feedback). Already-graded rows show\n * their result read-only with a \"Re-grade\" affordance.\n *\n * Every number is derived from live `bui*` data; the KPI row is explicitly scoped\n * to the current page (\"this page\") so it never overstates workspace totals.\n * Learner identity is shown honestly: the signed-in educator's own attempts get\n * their real name + \"You\"; everyone else is a deterministic initials avatar keyed\n * off the opaque learner id — never a fabricated name or stock photo.\n */\n\nimport { useMemo, useState } from 'react';\nimport type { FC, ReactNode } from 'react';\nimport { ClipboardList, Clock, CheckCircle2, Undo2, GraduationCap, BookOpen } from 'lucide-react';\nimport {\n Button,\n Input,\n Label,\n Textarea,\n Switch,\n Sheet,\n SheetContent,\n SheetHeader,\n SheetTitle,\n SheetDescription,\n SheetFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { useMediaQuery } from '@burdenoff/fe-libs/shared/hooks';\nimport {\n PageLayout,\n MetricsCard,\n StatusBadge,\n DataTable,\n Tabs,\n Pagination,\n EmptyState,\n LoadingView,\n ErrorView,\n Avatar,\n type Column,\n type TabItem,\n} from '../../components';\nimport { useBuildmyiq } from '../../context';\nimport { useBuiSubmissions, useBuiGradeSubmission, useCursorPager } from '../../hooks';\nimport { formatDate, formatDateTime } from '../../utils/formatters';\nimport type { BuiSubmission, BuiSubmissionStatus } from '../../types';\n\nconst PAGE_SIZE = 24;\n\ntype StatusFilter = BuiSubmissionStatus | 'ALL';\ntype PanelTab = 'submission' | 'details';\n\nconst STATUS_TABS: TabItem<StatusFilter>[] = [\n { value: 'ALL', label: 'All' },\n { value: 'SUBMITTED', label: 'Submitted' },\n { value: 'GRADING', label: 'Grading' },\n { value: 'GRADED', label: 'Graded' },\n { value: 'RETURNED', label: 'Returned' },\n];\n\nconst PANEL_TABS: TabItem<PanelTab>[] = [\n { value: 'submission', label: 'Submission' },\n { value: 'details', label: 'Details' },\n];\n\n/** Truncate an opaque id for compact display, keeping the full value in `title`. */\nconst shortId = (id: string): string => (id.length > 10 ? `${id.slice(0, 8)}…` : id);\n\n/** Time-only rendering (e.g. \"10:24 AM\") reusing the locale-aware date formatter. */\nconst formatClock = (date: string | null | undefined): string =>\n formatDate(date, {\n day: undefined,\n month: undefined,\n year: undefined,\n hour: 'numeric',\n minute: '2-digit',\n });\n\nconst isPlainRecord = (v: unknown): v is Record<string, unknown> =>\n typeof v === 'object' && v !== null && !Array.isArray(v);\n\n/** Render a single answer value safely, whatever its JSON shape. */\nconst formatAnswerValue = (v: unknown): string => {\n if (v == null) return '—';\n if (typeof v === 'string') return v;\n if (typeof v === 'number' || typeof v === 'boolean') return String(v);\n return JSON.stringify(v, null, 2);\n};\n\n/** Read-only rendering of a submission's `answers` JSON blob. */\nconst AnswersView: FC<{ answers: unknown }> = ({ answers }) => {\n if (answers == null) {\n return <p className=\"text-sm text-text-secondary\">No answers were submitted.</p>;\n }\n if (isPlainRecord(answers)) {\n const entries = Object.entries(answers);\n if (entries.length === 0) {\n return <p className=\"text-sm text-text-secondary\">No answers were submitted.</p>;\n }\n return (\n <ol className=\"grid gap-2.5\">\n {entries.map(([key, value], i) => (\n <li\n key={key}\n className=\"grid gap-1 rounded-lg border border-border-seam/70 bg-bg-surface/60 p-3 sm:grid-cols-[minmax(0,7rem)_1fr] sm:gap-3\"\n >\n <div className=\"flex items-baseline gap-2 sm:col-span-1\">\n <span className=\"shrink-0 text-xs font-semibold text-[var(--color-accent-teal)] tabular-nums\">\n Q{i + 1}\n </span>\n <span className=\"break-words font-mono text-xs text-text-secondary\">{key}</span>\n </div>\n <span className=\"whitespace-pre-wrap break-words text-sm text-text-primary sm:col-span-1\">\n {formatAnswerValue(value)}\n </span>\n </li>\n ))}\n </ol>\n );\n }\n return (\n <pre className=\"overflow-x-auto whitespace-pre-wrap break-words rounded-lg border border-border-seam/70 bg-bg-surface/60 p-3 text-xs text-text-primary\">\n {JSON.stringify(answers, null, 2)}\n </pre>\n );\n};\n\n/** A labelled read-only field for the submission meta / graded-result panels. */\nconst MetaField: FC<{ label: string; children: ReactNode }> = ({ label, children }) => (\n <div className=\"min-w-0\">\n <p className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">{label}</p>\n <div className=\"mt-0.5 text-sm text-text-primary\">{children}</div>\n </div>\n);\n\nexport const SubmissionsPage: FC = () => {\n const { currentUser } = useBuildmyiq();\n const isDesktop = useMediaQuery('(min-width: 768px)');\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');\n const pager = useCursorPager();\n\n const [selected, setSelected] = useState<BuiSubmission | null>(null);\n const [panelTab, setPanelTab] = useState<PanelTab>('submission');\n const [editMode, setEditMode] = useState(false);\n const [scoreInput, setScoreInput] = useState('');\n const [passed, setPassed] = useState(false);\n const [feedback, setFeedback] = useState('');\n const [formError, setFormError] = useState<string | null>(null);\n\n const { data, isLoading, isError, refetch } = useBuiSubmissions({\n status: statusFilter === 'ALL' ? undefined : statusFilter,\n cursor: pager.cursor,\n limit: PAGE_SIZE,\n });\n const gradeSubmission = useBuiGradeSubmission();\n\n const rows = useMemo<BuiSubmission[]>(() => data?.items ?? [], [data]);\n\n // The signed-in educator's own display name (used only for their own rows).\n const meName = useMemo<string | undefined>(() => {\n if (!currentUser) return undefined;\n const full = [currentUser.firstName, currentUser.lastName].filter(Boolean).join(' ').trim();\n return currentUser.name?.trim() || full || currentUser.username || currentUser.email;\n }, [currentUser]);\n\n // Counts are scoped to the loaded page — labelled \"this page\" to stay honest.\n const counts = useMemo(() => {\n const acc = { total: rows.length, awaiting: 0, graded: 0, returned: 0 };\n for (const s of rows) {\n if (s.status === 'SUBMITTED' || s.status === 'GRADING') acc.awaiting += 1;\n else if (s.status === 'GRADED') acc.graded += 1;\n else if (s.status === 'RETURNED') acc.returned += 1;\n }\n return acc;\n }, [rows]);\n\n const isMe = (learnerId: string): boolean => learnerId === currentUser?.id;\n const learnerLabel = (learnerId: string): string =>\n isMe(learnerId) ? 'You' : `Learner ${shortId(learnerId)}`;\n\n const openGrade = (s: BuiSubmission) => {\n setSelected(s);\n setPanelTab('submission');\n setScoreInput(s.score != null ? String(s.score) : '');\n setPassed(s.passed ?? false);\n setFeedback(s.feedback ?? '');\n setEditMode(false);\n setFormError(null);\n };\n\n const closePanel = () => {\n setSelected(null);\n setEditMode(false);\n setFormError(null);\n };\n\n const handleFilterChange = (value: StatusFilter) => {\n setStatusFilter(value);\n pager.reset();\n };\n\n const handleGrade = async () => {\n if (!selected) return;\n const trimmed = scoreInput.trim();\n const scoreNum = Number(trimmed);\n if (trimmed === '' || Number.isNaN(scoreNum)) {\n setFormError('Enter a numeric score.');\n return;\n }\n if (scoreNum < 0) {\n setFormError('Score cannot be negative.');\n return;\n }\n try {\n await gradeSubmission.mutateAsync({\n id: selected.id,\n input: { score: scoreNum, passed, feedback: feedback.trim() || undefined },\n });\n closePanel();\n } catch (err) {\n setFormError(err instanceof Error ? err.message : 'Failed to save grade.');\n }\n };\n\n const gradingAllowed =\n selected != null && (selected.status === 'SUBMITTED' || selected.status === 'GRADING');\n const alreadyGraded =\n selected != null && (selected.status === 'GRADED' || selected.status === 'RETURNED');\n const notSubmitted = selected != null && selected.status === 'IN_PROGRESS';\n const showGradeForm = selected != null && (gradingAllowed || editMode);\n\n const columns: Column<BuiSubmission>[] = [\n {\n key: 'learner',\n header: 'Learner',\n sortValue: (s) => (isMe(s.learnerId) ? '' : s.learnerId),\n render: (s) => (\n <div className=\"flex items-center gap-3\">\n <Avatar\n size=\"sm\"\n name={isMe(s.learnerId) ? meName : undefined}\n seed={s.learnerId}\n src={isMe(s.learnerId) ? currentUser?.avatar : undefined}\n />\n <div className=\"min-w-0\">\n <span className=\"block truncate font-medium text-text-primary\" title={s.learnerId}>\n {isMe(s.learnerId) ? 'You' : shortId(s.learnerId)}\n </span>\n {isMe(s.learnerId) && meName && (\n <span className=\"block truncate text-xs text-text-secondary\">{meName}</span>\n )}\n </div>\n </div>\n ),\n },\n {\n key: 'assessment',\n header: 'Assessment',\n hideOnMobile: true,\n render: (s) => (\n <span className=\"inline-flex items-center gap-2 text-text-secondary\">\n <BookOpen className=\"h-4 w-4 shrink-0 opacity-70\" />\n <span className=\"font-mono text-xs\" title={s.assessmentId}>\n {shortId(s.assessmentId)}\n </span>\n </span>\n ),\n },\n {\n key: 'attempt',\n header: 'Attempt',\n align: 'right',\n hideOnMobile: true,\n sortValue: (s) => s.attempt,\n render: (s) => <span className=\"tabular-nums text-text-secondary\">#{s.attempt}</span>,\n },\n {\n key: 'status',\n header: 'Status',\n sortValue: (s) => s.status,\n render: (s) => <StatusBadge status={s.status} />,\n },\n {\n key: 'score',\n header: 'Score',\n align: 'right',\n sortValue: (s) => s.score ?? -1,\n render: (s) =>\n s.score == null ? (\n <span className=\"text-text-secondary\">—</span>\n ) : (\n <span className=\"tabular-nums font-semibold text-text-primary\">{s.score}</span>\n ),\n },\n {\n key: 'submittedAt',\n header: 'Submitted',\n align: 'right',\n hideOnMobile: true,\n sortValue: (s) => s.submittedAt ?? s.createdAt,\n render: (s) => (\n <div className=\"whitespace-nowrap text-right\">\n <div className=\"text-text-primary\">{formatDate(s.submittedAt)}</div>\n {s.submittedAt && (\n <div className=\"text-xs text-text-secondary\">{formatClock(s.submittedAt)}</div>\n )}\n </div>\n ),\n },\n ];\n\n return (\n <PageLayout\n title=\"Grading\"\n description=\"Review and grade assessment submissions across every course in your workspace.\"\n titleGradient\n aurora\n >\n <div className=\"flex flex-col gap-6\">\n {/* KPI row — scoped to the current page. */}\n <div className=\"grid grid-cols-2 gap-4 xl:grid-cols-4\">\n <MetricsCard\n icon={<ClipboardList className=\"h-5 w-5\" />}\n value={counts.total}\n label=\"Submissions (this page)\"\n featured\n />\n <MetricsCard\n icon={<Clock className=\"h-5 w-5\" />}\n value={counts.awaiting}\n label=\"Awaiting grade (this page)\"\n accent=\"warning\"\n />\n <MetricsCard\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n value={counts.graded}\n label=\"Graded (this page)\"\n accent=\"success\"\n />\n <MetricsCard\n icon={<Undo2 className=\"h-5 w-5\" />}\n value={counts.returned}\n label=\"Returned (this page)\"\n />\n </div>\n\n <Tabs items={STATUS_TABS} value={statusFilter} onChange={handleFilterChange} />\n\n {isLoading ? (\n <LoadingView label=\"Loading submissions…\" />\n ) : isError ? (\n <ErrorView\n title=\"Couldn't load submissions\"\n message=\"The grading queue failed to load.\"\n onRetry={() => refetch()}\n />\n ) : rows.length === 0 ? (\n <EmptyState\n illustration=\"empty-data\"\n icon={<GraduationCap className=\"h-10 w-10\" />}\n title={statusFilter === 'ALL' ? 'No submissions yet' : 'Nothing in this status'}\n description={\n statusFilter === 'ALL'\n ? 'When learners submit assessments, their attempts will appear here for grading.'\n : 'No submissions currently match this status filter.'\n }\n />\n ) : (\n <div className=\"flex flex-col gap-4\">\n <DataTable\n columns={columns}\n rows={rows}\n rowKey={(s) => s.id}\n onRowClick={openGrade}\n defaultSort={{ key: 'submittedAt', dir: 'desc' }}\n />\n <Pagination\n page={pager.page}\n pageSize={PAGE_SIZE}\n itemCount={rows.length}\n hasMore={data?.pageInfo?.hasMore ?? false}\n onPageChange={(target) => pager.onPageChange(target, data?.pageInfo?.cursor)}\n />\n </div>\n )}\n </div>\n\n {/* Grading slide-over — right panel on desktop, bottom-sheet on mobile. */}\n <Sheet\n open={selected != null}\n onOpenChange={(open) => {\n if (!open) closePanel();\n }}\n >\n <SheetContent\n side={isDesktop ? 'right' : 'bottom'}\n className={\n isDesktop\n ? 'flex w-full max-w-none flex-col gap-0 p-0 sm:max-w-xl'\n : 'flex max-h-[92vh] w-full flex-col gap-0 rounded-t-2xl p-0'\n }\n >\n {selected && (\n <>\n <SheetHeader className=\"border-b border-border-seam p-5 pr-12\">\n <div className=\"flex items-center gap-3\">\n <Avatar\n size=\"md\"\n name={isMe(selected.learnerId) ? meName : undefined}\n seed={selected.learnerId}\n src={isMe(selected.learnerId) ? currentUser?.avatar : undefined}\n />\n <div className=\"min-w-0\">\n <SheetTitle className=\"truncate text-lg\">\n {learnerLabel(selected.learnerId)}\n </SheetTitle>\n <SheetDescription\n className=\"truncate font-mono text-xs\"\n title={selected.assessmentId}\n >\n Assessment {shortId(selected.assessmentId)}\n </SheetDescription>\n </div>\n </div>\n <p className=\"text-xs text-text-secondary\">\n Attempt #{selected.attempt} · Submitted {formatDateTime(selected.submittedAt)}\n </p>\n <div className=\"pt-1\">\n <StatusBadge status={selected.status} />\n </div>\n </SheetHeader>\n\n <div className=\"flex-1 overflow-y-auto px-5 py-4\">\n <Tabs items={PANEL_TABS} value={panelTab} onChange={setPanelTab} className=\"mb-4\" />\n\n {panelTab === 'submission' ? (\n <div>\n <p className=\"mb-2 text-sm font-semibold text-text-primary\">\n Answers <span className=\"font-normal text-text-secondary\">(read-only)</span>\n </p>\n <AnswersView answers={selected.answers} />\n </div>\n ) : (\n <div className=\"grid grid-cols-2 gap-4 rounded-xl bg-bg-sunken/60 p-4\">\n <MetaField label=\"Status\">\n <StatusBadge status={selected.status} />\n </MetaField>\n <MetaField label=\"Attempt\">\n <span className=\"tabular-nums\">#{selected.attempt}</span>\n </MetaField>\n <MetaField label=\"Submitted\">{formatDateTime(selected.submittedAt)}</MetaField>\n <MetaField label=\"Assessment\">\n <span className=\"font-mono text-xs\" title={selected.assessmentId}>\n {shortId(selected.assessmentId)}\n </span>\n </MetaField>\n {selected.enrollmentId && (\n <MetaField label=\"Enrollment\">\n <span className=\"font-mono text-xs\" title={selected.enrollmentId}>\n {shortId(selected.enrollmentId)}\n </span>\n </MetaField>\n )}\n {selected.gradedAt && (\n <MetaField label=\"Graded\">{formatDateTime(selected.gradedAt)}</MetaField>\n )}\n </div>\n )}\n\n {/* Grade / result — persistent below the tab content. */}\n <div className=\"mt-6 border-t border-border-seam pt-5\">\n <p className=\"mb-3 text-sm font-semibold text-text-primary\">Grade</p>\n\n {notSubmitted && (\n <p className=\"rounded-lg bg-bg-sunken/60 px-4 py-3 text-sm text-text-secondary\">\n This attempt is still in progress — there is nothing to grade until the\n learner submits.\n </p>\n )}\n\n {alreadyGraded && !editMode && (\n <div className=\"rounded-xl border border-border-seam p-4\">\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"grid grid-cols-2 gap-4\">\n <MetaField label=\"Score\">\n <span className=\"tabular-nums font-semibold\">\n {selected.score ?? '—'}\n </span>\n </MetaField>\n <MetaField label=\"Result\">\n {selected.passed == null ? (\n <span className=\"text-text-secondary\">—</span>\n ) : (\n <StatusBadge status={selected.passed ? 'GRADED' : 'RETURNED'} />\n )}\n </MetaField>\n </div>\n <Button variant=\"outline\" size=\"sm\" onClick={() => setEditMode(true)}>\n Re-grade\n </Button>\n </div>\n <div className=\"mt-3\">\n <MetaField label=\"Feedback\">\n {selected.feedback ? (\n <span className=\"whitespace-pre-wrap\">{selected.feedback}</span>\n ) : (\n <span className=\"text-text-secondary\">No feedback provided.</span>\n )}\n </MetaField>\n </div>\n </div>\n )}\n\n {showGradeForm && (\n <div className=\"grid gap-4\">\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"grade-score\">Score</Label>\n <Input\n id=\"grade-score\"\n type=\"number\"\n min=\"0\"\n step=\"1\"\n value={scoreInput}\n onChange={(e) => setScoreInput(e.target.value)}\n placeholder=\"e.g. 85\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"grade-passed\">Result</Label>\n <div className=\"flex h-10 items-center gap-3\">\n <Switch\n id=\"grade-passed\"\n checked={passed}\n onCheckedChange={setPassed}\n />\n <span className=\"text-sm text-text-secondary\">\n {passed ? 'Passed' : 'Not passed'}\n </span>\n </div>\n </div>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"grade-feedback\">Feedback</Label>\n <Textarea\n id=\"grade-feedback\"\n value={feedback}\n onChange={(e) => setFeedback(e.target.value)}\n placeholder=\"Notes, corrections or encouragement for the learner (optional).\"\n rows={4}\n />\n </div>\n {formError && <p className=\"text-sm text-status-error-text\">{formError}</p>}\n </div>\n )}\n </div>\n </div>\n\n <SheetFooter className=\"flex-row justify-end gap-3 border-t border-border-seam p-4\">\n <Button variant=\"outline\" onClick={closePanel}>\n {showGradeForm ? 'Cancel' : 'Close'}\n </Button>\n {showGradeForm && (\n <Button onClick={handleGrade} disabled={gradeSubmission.isPending}>\n {gradeSubmission.isPending\n ? 'Saving…'\n : alreadyGraded\n ? 'Update grade'\n : 'Grade submission'}\n </Button>\n )}\n </SheetFooter>\n </>\n )}\n </SheetContent>\n </Sheet>\n </PageLayout>\n );\n};\n"],"mappings":"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"}
|