create-mithaq 0.1.1 → 0.2.1
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/LICENSES/openstatus-time-picker.txt +21 -0
- package/index.js +457 -63
- package/package.json +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
- package/template/apps/asas-backoffice/src/components.ts +35 -0
- package/template/apps/asas-backoffice/src/first-user.ts +14 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
- package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
- package/template/apps/asas-backoffice/src/redirect.ts +1 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
- package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
- package/template/apps/asas-backoffice/src/router.tsx +18 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
- package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
- package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
- package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
- package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
- package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
- package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
- package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
- package/template/apps/asas-backoffice/src/server.ts +30 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
- package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
- package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
- package/template/apps/asas-tanstack/package.json +4 -2
- package/template/apps/asas-tanstack/src/api.ts +1 -1
- package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
- package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
- package/template/apps/asas-tanstack/src/components.ts +35 -0
- package/template/apps/asas-tanstack/src/copy.ts +416 -79
- package/template/apps/asas-tanstack/src/first-run.ts +16 -0
- package/template/apps/asas-tanstack/src/links.ts +1 -1
- package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
- package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -1
- package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
- package/template/apps/asas-tanstack/src/router.tsx +12 -1
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
- package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
- package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
- package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
- package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
- package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
- package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
- package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
- package/template/apps/asas-tanstack/src/screens.tsx +46 -26
- package/template/apps/asas-tanstack/src/server.ts +10 -0
- package/template/apps/asas-tanstack/src/slide.ts +1 -1
- package/template/apps/asas-tanstack/src/styles.css +7 -3
- package/template/apps/asas-tanstack/tsconfig.json +2 -2
- package/template/apps/asas-tanstack/vite.config.ts +7 -1
- package/template/package.json +3 -3
- package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
- package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
- package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
- package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
- package/template/templates/asas/.env.example +22 -5
- package/template/templates/asas/AGENTS.md +101 -0
- package/template/templates/asas/CLAUDE.md +1 -0
- package/template/templates/asas/README.md +1094 -0
- package/template/templates/asas/package.json +19 -4
- package/template/templates/asas/src/asas.ts +81 -56
- package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
- package/template/templates/asas/src/dev-setup.ts +112 -0
- package/template/templates/asas/src/local-database.ts +22 -0
- package/template/templates/asas/src/messages/account.json +129 -103
- package/template/templates/asas/src/messages/auditLog.json +223 -105
- package/template/templates/asas/src/messages/backofficeLog.json +132 -0
- package/template/templates/asas/src/messages/courses.json +256 -214
- package/template/templates/asas/src/messages/enrollments.json +45 -27
- package/template/templates/asas/src/messages/invitation.json +72 -56
- package/template/templates/asas/src/messages/localUsers.json +34 -28
- package/template/templates/asas/src/messages/media.json +31 -21
- package/template/templates/asas/src/messages/member.json +59 -39
- package/template/templates/asas/src/messages/memberRoles.json +37 -31
- package/template/templates/asas/src/messages/mithaq.json +206 -162
- package/template/templates/asas/src/messages/organization.json +63 -41
- package/template/templates/asas/src/messages/organizationRole.json +59 -47
- package/template/templates/asas/src/messages/roles.json +59 -45
- package/template/templates/asas/src/messages/session.json +99 -79
- package/template/templates/asas/src/messages/sessions.json +110 -80
- package/template/templates/asas/src/messages/settings.json +51 -53
- package/template/templates/asas/src/messages/tags.json +31 -21
- package/template/templates/asas/src/messages/user.json +109 -87
- package/template/templates/asas/src/messages/verification.json +59 -47
- package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
- package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
- package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
- package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
- package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
- package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
- package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
- package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
- package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
- package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
- package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
- package/template/templates/asas/src/migrations/schema.ts +1 -0
- package/template/templates/asas/src/mithaq.ts +4 -406
- package/template/templates/asas/src/organizations.ts +3 -0
- package/template/templates/asas/src/seed.ts +147 -45
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
- package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
- package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
- package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
- package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
- package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
- package/template/templates/asas/src/ui-portal/home.tsx +9 -13
- package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
- package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
- package/template/templates/features/audit-log/index.ts +4 -0
- package/template/templates/features/audit-log/package.json +13 -0
- package/template/templates/features/auth/index.ts +11 -0
- package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
- package/template/templates/{auth → features/auth}/package.json +2 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
- package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
- package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
- package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
- package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
- package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
- package/template/templates/features/courses/courses/cover-reads.ts +33 -0
- package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
- package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
- package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
- package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
- package/template/templates/features/courses/domain/archive.ts +39 -0
- package/template/templates/features/courses/domain/on-offer.ts +6 -0
- package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
- package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
- package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
- package/template/templates/features/courses/index.ts +8 -0
- package/template/templates/features/courses/package.json +24 -0
- package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
- package/template/templates/features/courses/portal/ui/course.tsx +176 -0
- package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
- package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
- package/template/templates/features/courses/portal/ui/reads.ts +76 -0
- package/template/templates/features/courses/portal/ui/rules.ts +87 -0
- package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
- package/template/templates/features/courses/sessions/seats.ts +42 -0
- package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
- package/template/templates/features/media/index.ts +10 -0
- package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
- package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
- package/template/templates/features/media/package.json +19 -0
- package/template/templates/features/roles/index.ts +4 -0
- package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
- package/template/templates/features/roles/package.json +21 -0
- package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
- package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
- package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
- package/template/templates/features/settings/index.ts +3 -0
- package/template/templates/features/settings/package.json +13 -0
- package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
- package/template/templates/features/tags/index.ts +3 -0
- package/template/templates/features/tags/package.json +14 -0
- package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
- package/template/templates/mithaq/package.json +33 -0
- package/template/templates/mithaq/src/mail/index.ts +32 -0
- package/template/templates/mithaq/src/mail/mailers.ts +36 -0
- package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
- package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
- package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
- package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
- package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
- package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
- package/template/templates/mithaq/src/mithaq.ts +593 -0
- package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
- package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
- package/template/templates/mithaq/src/roles/member-access.ts +11 -0
- package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
- package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
- package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
- package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
- package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
- package/template/templates/mithaq/src/ui/activity.tsx +527 -0
- package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
- package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
- package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
- package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
- package/template/apps/asas-platform/src/redirect.ts +0 -1
- package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
- package/template/apps/asas-platform/src/router.tsx +0 -7
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
- package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
- package/template/apps/asas-platform/src/server.ts +0 -20
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
- package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
- package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
- package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
- package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
- package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
- package/template/templates/asas/src/rich-text-nodes.ts +0 -11
- package/template/templates/asas/src/roles.ts +0 -16
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
- package/template/templates/auth/src/index.ts +0 -690
- /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
- /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
- /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
- /package/template/templates/{asas → mithaq}/components.json +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { conflict, defaultOperations, folderOf, invalid, type Ctx, type FilterOptions, type WriteTools } from 'mithaq'
|
|
2
|
+
import { defineKayan, organization, serverTargets } from '@asas/mithaq'
|
|
3
|
+
import { currentOrganization, readsPast, user as actor } from '@asas/mithaq/roles/tenant-access.ts'
|
|
4
|
+
import { activity, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
|
|
5
|
+
import { sessions } from '../sessions/sessions.ts'
|
|
6
|
+
import { holdSeats, seatsTaken, sessionHeld } from '../sessions/seats.ts'
|
|
7
|
+
// add-on: settings
|
|
8
|
+
import { sql } from 'mithaq/drizzle'
|
|
9
|
+
import type { NodePgDatabase } from 'mithaq/drizzle/node-postgres'
|
|
10
|
+
// end add-on: settings
|
|
11
|
+
|
|
12
|
+
const ui = folderOf(new URL('./ui/', import.meta.url))
|
|
13
|
+
const portalUi = folderOf(new URL('../portal/ui/', import.meta.url))
|
|
14
|
+
/** A portal page in its shell, which draws the portal's own nav, over its single column. */
|
|
15
|
+
const portalPage = <const Main extends readonly unknown[]>(main: Main) => ({ shell: 'top', header: [{ title: {} }], main: [{ layout: 'page', main }] }) as const
|
|
16
|
+
const sessionCell = ui('session-cell.tsx#SessionCell')
|
|
17
|
+
const enrollment = [{ section: 'enrollment', label: { en: 'Enrollment', ar: 'التسجيل' }, fields: ['session', 'student'] }] as const
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The seats an actor reads, edits and deletes, inside its active organization:
|
|
21
|
+
* every seat of its organization for an actor holding
|
|
22
|
+
* `enrollments.others` and for backoffice staff, its own seats alone for anyone
|
|
23
|
+
* else. That is the student, whose enrollment keys would otherwise reach every
|
|
24
|
+
* other student's seat, and a custom role a tenant writes without the key.
|
|
25
|
+
* Another's seat answers 404, as a missing row does.
|
|
26
|
+
*
|
|
27
|
+
* The id is read off the actor as an own key, and an actor carrying none reaches
|
|
28
|
+
* no seat. Every read answers from it, a session's join of its enrollments
|
|
29
|
+
* included.
|
|
30
|
+
*/
|
|
31
|
+
const ownId = (user: unknown) => {
|
|
32
|
+
const actor = user && typeof user === 'object' ? (user as Record<string, unknown>) : undefined
|
|
33
|
+
const id = actor && Object.hasOwn(actor, 'id') ? actor.id : undefined
|
|
34
|
+
return typeof id === 'string' && id ? id : undefined
|
|
35
|
+
}
|
|
36
|
+
const ownSeats = ({ user }: { user: unknown }) => {
|
|
37
|
+
if (readsPast(user, 'enrollments.others')) return true
|
|
38
|
+
const id = ownId(user)
|
|
39
|
+
return id ? { student: { equals: id } } : false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a write names another person as a seat's student for an actor that
|
|
44
|
+
* writes its own seats alone. Refused on a create and on an update, so a seat
|
|
45
|
+
* is never made for, or handed to, someone the actor may not write for.
|
|
46
|
+
*/
|
|
47
|
+
const namesAnother = ({ user, data }: { user: unknown; data?: unknown }) =>
|
|
48
|
+
!readsPast(user, 'enrollments.others') && !!data && typeof data === 'object' && Object.hasOwn(data, 'student')
|
|
49
|
+
&& (data as { student?: unknown }).student !== ownId(user)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whom a seat's student picker offers: the people holding the student role in
|
|
53
|
+
* the active organization, a teacher who also studies among them. It narrows
|
|
54
|
+
* what the actor's own read of people answers and never widens it, so an
|
|
55
|
+
* instructor is offered the students it already reads and not itself.
|
|
56
|
+
*/
|
|
57
|
+
const students: FilterOptions = async ({ user, tx }) => {
|
|
58
|
+
const active = currentOrganization(user)
|
|
59
|
+
if (!active) return false
|
|
60
|
+
const held = await serverTargets.drizzle.find({ tx, kayan: 'memberRoles', args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { organization: { equals: active }, role: { equals: 'student' } } } })
|
|
61
|
+
const ids = held.docs.map((row) => row.user).filter((id): id is string => typeof id === 'string')
|
|
62
|
+
return ids.length ? { id: { in: ids } } : false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Not archived, the column read by its name, or across a session's course. */
|
|
66
|
+
const open = (path: string) => ({ or: [{ [path]: { exists: false } }, { [path]: { equals: false } }] })
|
|
67
|
+
|
|
68
|
+
/** What a seat's session picker offers, and a save is held to: a session open for seats, of a course open too. */
|
|
69
|
+
const openSessions: FilterOptions = () => ({ and: [open('archived'), open('course.archived')] })
|
|
70
|
+
|
|
71
|
+
/** Whether the actor writes its own seats alone: a student, held to what each course lets its students do. */
|
|
72
|
+
const ownSeatsOnly = (user: unknown) => !readsPast(user, 'enrollments.others')
|
|
73
|
+
|
|
74
|
+
/** Whether a session is still to start, which a student's enrollment and its cancel both ask. */
|
|
75
|
+
const yetToStart = (startsAt: unknown) => new Date(startsAt as string | Date).getTime() > Date.now()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a session takes this seat. A session the actor may not read is left
|
|
79
|
+
* to the reference check, which refuses it in the words a missing one gets.
|
|
80
|
+
* The rest is judged on the session's own columns, whatever the actor is shown:
|
|
81
|
+
* an archived session, or one of an archived course, takes none; a student
|
|
82
|
+
* takes a seat only where its course lets students enroll themselves, and only
|
|
83
|
+
* before the session starts; and a session with a capacity takes no seat past
|
|
84
|
+
* it, whoever writes, counted under a lock on the session. A seat kept in the
|
|
85
|
+
* session it already holds takes no new one.
|
|
86
|
+
*/
|
|
87
|
+
const takesSeat = async (ctx: Ctx<{ session?: unknown }>, tools: WriteTools): Promise<Ctx> => {
|
|
88
|
+
const id = ctx.data.session
|
|
89
|
+
// An edit of a seat the actor may not reach loaded no row; the store answers it the 404 a missing one gets.
|
|
90
|
+
if (typeof id !== 'string' || (tools.operation !== 'create' && !ctx.dataBefore)) return ctx
|
|
91
|
+
if (!(await tools.of(sessions).findByID({ id }).catch(() => null))) return ctx
|
|
92
|
+
const session = await sessionHeld({ tx: ctx.tx, session: id })
|
|
93
|
+
if (!session) return ctx
|
|
94
|
+
// The stored id from here on: a uuid matches in any case, and the lock, the count and the comparison below must not
|
|
95
|
+
// tell `A0EE…` from `a0ee…`.
|
|
96
|
+
const held = session.id
|
|
97
|
+
if (session.archived || session.courseArchived) {
|
|
98
|
+
throw invalid({
|
|
99
|
+
message: 'This session is archived, or its course is, so it takes no new seat.',
|
|
100
|
+
key: 'error.invalid.archived_session',
|
|
101
|
+
params: { kayan: 'enrollments', field: 'session' },
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
if (held === ctx.dataBefore?.session) return ctx
|
|
105
|
+
if (ownSeatsOnly(ctx.user)) {
|
|
106
|
+
if (session.selfEnroll !== true) {
|
|
107
|
+
throw invalid({
|
|
108
|
+
message: 'Students of this course do not enroll themselves: the school enrolls them.',
|
|
109
|
+
key: 'error.invalid.enrollment_closed',
|
|
110
|
+
params: { kayan: 'enrollments', field: 'session' },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
if (!yetToStart(session.startsAt)) {
|
|
114
|
+
throw invalid({ message: 'This session has started, so it takes no new seat.', key: 'error.invalid.session_started', params: { kayan: 'enrollments', field: 'session' } })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (session.capacity !== null) {
|
|
118
|
+
await holdSeats({ tx: ctx.tx, session: held })
|
|
119
|
+
if ((await seatsTaken({ session: held, tx: ctx.tx })) >= session.capacity) {
|
|
120
|
+
throw conflict({ message: 'Every seat of this session is taken.', key: 'error.conflict.session_full', params: { kayan: 'enrollments', field: 'session' } })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return ctx
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A student leaves a session only before it starts: the seat it held stays the
|
|
128
|
+
* record that it was there. Whoever reads every seat withdraws one at any time.
|
|
129
|
+
* A seat the actor may not reach loaded no row, and answers the 404 a missing
|
|
130
|
+
* one does. The start is the session's column, so a seat whose course has since
|
|
131
|
+
* left the actor's sight is not said to have started.
|
|
132
|
+
*/
|
|
133
|
+
const leavesBeforeStart = async (ctx: Ctx): Promise<Ctx> => {
|
|
134
|
+
if (!ownSeatsOnly(ctx.user) || !ctx.dataBefore) return ctx
|
|
135
|
+
const id = ctx.dataBefore.session
|
|
136
|
+
const session = typeof id === 'string' ? await sessionHeld({ tx: ctx.tx, session: id }) : undefined
|
|
137
|
+
if (!session || !yetToStart(session.startsAt)) {
|
|
138
|
+
throw invalid({ message: 'This session has started, so its seat stays.', key: 'error.invalid.session_started', params: { kayan: 'enrollments' } })
|
|
139
|
+
}
|
|
140
|
+
return ctx
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// add-on: settings
|
|
144
|
+
/**
|
|
145
|
+
* While the portal is closed for maintenance a student takes and gives back no
|
|
146
|
+
* seat, whatever calls the server; staff keep working. The organization's
|
|
147
|
+
* settings are read in the write's own transaction, since a student reads them
|
|
148
|
+
* as itself and a role might not.
|
|
149
|
+
*/
|
|
150
|
+
// The table and column are written out, as `sessionHeld`'s are; R7g fails on a rename.
|
|
151
|
+
const portalOpen = async (ctx: Ctx): Promise<Ctx> => {
|
|
152
|
+
if (!ownSeatsOnly(ctx.user) || !ctx.tenant) return ctx
|
|
153
|
+
const [held] = (await (ctx.tx as NodePgDatabase).execute<{ maintenance: boolean | null }>(sql`select maintenance from settings where organization_id = ${ctx.tenant}`)).rows
|
|
154
|
+
if (held?.maintenance === true) {
|
|
155
|
+
throw invalid({ message: 'The portal is closed for maintenance, so no seat changes from it.', key: 'error.invalid.portal_closed', params: { kayan: 'enrollments' } })
|
|
156
|
+
}
|
|
157
|
+
return ctx
|
|
158
|
+
}
|
|
159
|
+
// end add-on: settings
|
|
160
|
+
|
|
161
|
+
export const enrollments = defineKayan({
|
|
162
|
+
name: 'enrollments',
|
|
163
|
+
access: {
|
|
164
|
+
read: ownSeats,
|
|
165
|
+
create: (args: { user: unknown; data?: unknown }) => !namesAnother(args),
|
|
166
|
+
update: (args: { user: unknown; data?: unknown }) => (namesAnother(args) ? false : ownSeats(args)),
|
|
167
|
+
delete: ownSeats,
|
|
168
|
+
},
|
|
169
|
+
// What a form says where a second seat of one student in one session is
|
|
170
|
+
// refused, over the shared words for a unique value taken.
|
|
171
|
+
messages: {
|
|
172
|
+
conflict: { unique: { en: 'This student already holds a seat in this session.', ar: 'لهذا الطالب مقعد في هذه الحصة بالفعل.' } },
|
|
173
|
+
},
|
|
174
|
+
// A student holds one seat in a session, which is one organization's, so the
|
|
175
|
+
// organization needs no column in the key. The mark is in it with its nulls
|
|
176
|
+
// not distinct, so a withdrawn seat makes room for a new one while two
|
|
177
|
+
// standing seats collide.
|
|
178
|
+
indexes: [{ fields: ['session', 'student', 'deletedAt'], unique: true, nullsNotDistinct: true }],
|
|
179
|
+
fields: ({ defaults }) => ({
|
|
180
|
+
...defaults,
|
|
181
|
+
organization,
|
|
182
|
+
// Read as the session's course and its start on every screen, where a
|
|
183
|
+
// session alone is titled by its start.
|
|
184
|
+
//
|
|
185
|
+
// Both are required, so no write makes a seat for nobody or in nothing, and
|
|
186
|
+
// a session or a person that holds one is not deleted under it: a required
|
|
187
|
+
// relationship restricts.
|
|
188
|
+
session: {
|
|
189
|
+
kind: 'relationship', label: { en: 'Session', ar: 'الحصة' }, relationTo: () => sessions,
|
|
190
|
+
filterOptions: openSessions,
|
|
191
|
+
rules: { required: true },
|
|
192
|
+
targets: { dashboard: { cell: sessionCell }, portal: { cell: sessionCell }, backoffice: { cell: sessionCell } },
|
|
193
|
+
},
|
|
194
|
+
// A create that names no student enrolls the person making it: the runner's
|
|
195
|
+
// defaults step fills it from the actor, and a field with a default is one a
|
|
196
|
+
// create may leave out, at the HTTP boundary too. A role without the column's
|
|
197
|
+
// key is refused the filled value. A student holds it, so its own enrollment
|
|
198
|
+
// names itself, and `namesAnother` refuses it any other person.
|
|
199
|
+
student: {
|
|
200
|
+
kind: 'relationship', label: { en: 'Student', ar: 'الطالب' }, relationTo: 'user',
|
|
201
|
+
filterOptions: students,
|
|
202
|
+
rules: { required: true, defaultValue: actor.id },
|
|
203
|
+
},
|
|
204
|
+
/**
|
|
205
|
+
* The field the language names as its soft-delete mark, so a student who
|
|
206
|
+
* leaves a session is a row this kayan keeps rather than one it removes: an
|
|
207
|
+
* enrollment is the record that the student was there. It is the one kayan
|
|
208
|
+
* of this template that takes the mark. Nothing points at an enrollment, so
|
|
209
|
+
* no child outlives it, and nothing refers to it through a `restrict`
|
|
210
|
+
* foreign key that a delete would have to release. Nobody supplies the
|
|
211
|
+
* mark: the runner writes it at the delete.
|
|
212
|
+
*
|
|
213
|
+
* `session` and `student` are required, so each restricts, and a session or
|
|
214
|
+
* a person a marked enrollment still names is not deleted under it.
|
|
215
|
+
*/
|
|
216
|
+
deletedAt: {
|
|
217
|
+
kind: 'datetime',
|
|
218
|
+
label: { en: 'Deleted at', ar: 'تاريخ الحذف' },
|
|
219
|
+
rules: { providedBy: { create: 'never', update: 'never' } },
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
targets: {
|
|
223
|
+
// Every organization's rows on the backoffice, read-only, and each row's
|
|
224
|
+
// record, which only reads. A record is titled by its student, a person the
|
|
225
|
+
// backoffice reads, where a session is titled only by its start.
|
|
226
|
+
backoffice: {
|
|
227
|
+
useAsTitle: 'student',
|
|
228
|
+
pages: {
|
|
229
|
+
...backofficeList('/enrollments', { columns: ['session', 'student', 'organization', 'createdAt'], filters: ['organization', 'session', 'student'], sort: '-createdAt' }),
|
|
230
|
+
...backofficeRecord({
|
|
231
|
+
path: '/enrollments/:id',
|
|
232
|
+
main: [{ section: 'enrollment', label: { en: 'Enrollment', ar: 'التسجيل' }, fields: ['session', 'student', 'deletedAt'] }],
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
service: {
|
|
237
|
+
operations: {
|
|
238
|
+
...defaultOperations,
|
|
239
|
+
create: { ...defaultOperations.create, steps: ({ steps }) => [
|
|
240
|
+
// add-on: settings
|
|
241
|
+
portalOpen,
|
|
242
|
+
// end add-on: settings
|
|
243
|
+
takesSeat,
|
|
244
|
+
steps.insert,
|
|
245
|
+
] },
|
|
246
|
+
update: { ...defaultOperations.update, steps: ({ steps }) => [
|
|
247
|
+
// add-on: settings
|
|
248
|
+
portalOpen,
|
|
249
|
+
// end add-on: settings
|
|
250
|
+
takesSeat,
|
|
251
|
+
steps.update,
|
|
252
|
+
] },
|
|
253
|
+
delete: { ...defaultOperations.delete, steps: ({ steps }) => [
|
|
254
|
+
// add-on: settings
|
|
255
|
+
portalOpen,
|
|
256
|
+
// end add-on: settings
|
|
257
|
+
leavesBeforeStart,
|
|
258
|
+
steps.remove,
|
|
259
|
+
] },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
// Titled by the student on the dashboard, as on the backoffice, so a tab and a
|
|
263
|
+
// heading tell one seat from another; on the portal, where the student is most
|
|
264
|
+
// often the viewer, by the session.
|
|
265
|
+
dashboard: {
|
|
266
|
+
useAsTitle: 'student',
|
|
267
|
+
description: {
|
|
268
|
+
en: 'Which student holds a seat in which session.',
|
|
269
|
+
ar: 'أي طالب يشغل مقعدا في أي حصة.',
|
|
270
|
+
},
|
|
271
|
+
pages: {
|
|
272
|
+
// The same table every kayan's list is. Searched by the student's name,
|
|
273
|
+
// the one column of a person a tenant reads, and filtered by session,
|
|
274
|
+
// student and when the student enrolled, as the course list is.
|
|
275
|
+
list: { path: '/enrollments', page: appPage([{ layout: 'table', main: [{ list: {
|
|
276
|
+
columns: ['session', 'student', 'createdAt'],
|
|
277
|
+
filters: ['session', 'student', 'createdAt'],
|
|
278
|
+
search: ['student.name'],
|
|
279
|
+
} }] }]) },
|
|
280
|
+
create: { path: '/enrollments/new', page: appPage([{ layout: 'record', main: [{ form: enrollment }] }]) },
|
|
281
|
+
// The editor layout, and the one timestamp worth reading here, named and
|
|
282
|
+
// read-only: when the student enrolled. Not the organization, which on the
|
|
283
|
+
// dashboard is always the active one.
|
|
284
|
+
edit: {
|
|
285
|
+
path: '/enrollments/:id',
|
|
286
|
+
page: appPage([{
|
|
287
|
+
layout: 'record',
|
|
288
|
+
main: [{ form: enrollment }],
|
|
289
|
+
side: [{ section: 'record', label: { en: 'Record', ar: 'السجل' }, fields: [{ field: 'createdAt', readOnly: true }] }, activity],
|
|
290
|
+
danger: danger(),
|
|
291
|
+
}]),
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
portal: {
|
|
296
|
+
useAsTitle: 'session',
|
|
297
|
+
pages: {
|
|
298
|
+
// The viewer's seats, upcoming first, drawn whole by the courses feature's portal.
|
|
299
|
+
list: { path: '/enrollments', component: portalUi('enrollments.tsx#Enrollments') },
|
|
300
|
+
// The list's New enrollment, which only staff are offered, with the
|
|
301
|
+
// student as the dashboard draws it, so an instructor picks whom it
|
|
302
|
+
// enrolls. A student who opens it by its address and names another is
|
|
303
|
+
// refused by `namesAnother`; one who names nobody enrolls itself.
|
|
304
|
+
create: { path: '/enrollments/new', page: portalPage([{ form: ['session', 'student'] }]) },
|
|
305
|
+
edit: { path: '/enrollments/:id', page: portalPage([{ form: ['session'] }]) },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// What an enrollment's session column draws: the session's course by its title, then when it starts, which tells two sessions of one course apart
|
|
1
|
+
// What an enrollment's session column draws: the session's course by its title, then when it starts, which tells two sessions of one course apart,
|
|
2
|
+
// then where and how it meets.
|
|
2
3
|
// A session is titled by its start and a title is one field, so the course is a second read.
|
|
3
4
|
//
|
|
4
5
|
// The list's own `useRelationshipTitles` keeps only each session's title, the start as text, and not the course it points at, so this cell reads them again:
|
|
@@ -9,8 +10,10 @@ import { use } from 'react'
|
|
|
9
10
|
import { useQuery } from '@tanstack/react-query'
|
|
10
11
|
import type { CellProps, FetchClient, Row } from 'mithaq/admin'
|
|
11
12
|
import { ListContext, useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import type { FoundPage } from 'mithaq-admin/control-props.ts'
|
|
14
|
+
import { formatDate, noValue } from 'mithaq-admin/format.tsx'
|
|
15
|
+
import { refusalKeyOf } from 'mithaq-admin/refusal.ts'
|
|
16
|
+
import { findByIds, nameOfRow, titleFieldOf } from 'mithaq-admin/relationship-control.tsx'
|
|
14
17
|
|
|
15
18
|
/** A refusal is the viewer's access answering, so it reads as no rows; anything else is a failure, and throws for the query to try again. */
|
|
16
19
|
const unread = (thrown: unknown): never[] => {
|
|
@@ -36,7 +39,7 @@ const sessionsOf = async ({ client, ids, maxLimit }: { client: FetchClient; ids:
|
|
|
36
39
|
export function SessionCell({ field, value, row }: CellProps) {
|
|
37
40
|
const client = useClient()
|
|
38
41
|
const { locale, t } = useLocale()
|
|
39
|
-
const { kayan: { courses }, maxLimit } = useScreen()
|
|
42
|
+
const { kayan: { courses, sessions: sessionKayan }, maxLimit } = useScreen()
|
|
40
43
|
// A list's page, or the one row a record page draws.
|
|
41
44
|
// Sorted, so the same rows in another order are the same query.
|
|
42
45
|
const ids = [...new Set((use(ListContext)?.page.docs ?? [row]).flatMap((one: Row) => (typeof one[field.name] === 'string' ? [one[field.name] as string] : [])))].sort()
|
|
@@ -45,12 +48,18 @@ export function SessionCell({ field, value, row }: CellProps) {
|
|
|
45
48
|
const session = sessions?.get(value)
|
|
46
49
|
const when = formatDate({ value: session?.startsAt, locale })
|
|
47
50
|
const course = session?.course ? nameOfRow({ row: session.course as Row, title: titleFieldOf(courses), locale }) : undefined
|
|
51
|
+
// Where and how it meets, each by its option's label in the viewer's locale, where the viewer reads it.
|
|
52
|
+
const labelOf = (name: string) => {
|
|
53
|
+
const option = sessionKayan?.fields.find((one) => one.name === name)?.options?.find((one) => one.value === session?.[name])
|
|
54
|
+
return option?.label[locale]
|
|
55
|
+
}
|
|
56
|
+
const place = [labelOf('venue'), labelOf('format')].filter(Boolean).join(' · ')
|
|
48
57
|
// Never the id the column holds, as the relationship cell draws none:
|
|
49
58
|
// loading until the read answers, the server's render among it, a dash
|
|
50
|
-
// where it failed, and
|
|
51
|
-
//
|
|
59
|
+
// where it failed, and not available for a session it may not read or one
|
|
60
|
+
// of which it may read neither the course nor the start.
|
|
52
61
|
if (!when && !course) {
|
|
53
|
-
if (sessions) return t('admin.
|
|
62
|
+
if (sessions) return t('admin.notAvailable', 'Not available')
|
|
54
63
|
return isError ? '—' : t('admin.loading', 'Loading…')
|
|
55
64
|
}
|
|
56
65
|
// Two lines, the course over its start, so a narrow column wraps each
|
|
@@ -60,6 +69,7 @@ export function SessionCell({ field, value, row }: CellProps) {
|
|
|
60
69
|
{course ? <span className="font-medium">{course}</span> : null}
|
|
61
70
|
{/* One unbroken run, so a narrow column breaks between the course and the date and never inside the date. */}
|
|
62
71
|
{when ? <span className="text-muted-foreground text-xs"><bdi className="whitespace-nowrap">{when}</bdi></span> : null}
|
|
72
|
+
{place ? <span className="text-muted-foreground text-xs">{place}</span> : null}
|
|
63
73
|
</span>
|
|
64
74
|
)
|
|
65
75
|
}
|
|
@@ -10,9 +10,9 @@ import type { ReactNode } from 'react'
|
|
|
10
10
|
import { useQuery } from '@tanstack/react-query'
|
|
11
11
|
import type { ProviderProps, Row } from 'mithaq/admin'
|
|
12
12
|
import { useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
13
|
-
import { formatDate } from '
|
|
14
|
-
import { nameOfRow, RowLabelContext, titleFieldOf, type UseRowLabel } from '
|
|
15
|
-
import { coursesOf } from '
|
|
13
|
+
import { formatDate } from 'mithaq-admin/format.tsx'
|
|
14
|
+
import { nameOfRow, RowLabelContext, titleFieldOf, type UseRowLabel } from 'mithaq-admin/relationship-control.tsx'
|
|
15
|
+
import { coursesOf } from './session-cell.tsx'
|
|
16
16
|
|
|
17
17
|
/** The session's course and its start, or nothing for any other kayan and for a session whose course is not read yet. */
|
|
18
18
|
export const useSessionLabel: UseRowLabel = (target, rows) => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { courses } from './courses/courses.ts'
|
|
2
|
+
import { enrollments } from './enrollments/enrollments.ts'
|
|
3
|
+
import { sessions } from './sessions/sessions.ts'
|
|
4
|
+
|
|
5
|
+
export const kayan = [courses, sessions, enrollments] as const
|
|
6
|
+
|
|
7
|
+
// The images a course's reader reads as its cover, which the asas hands the media feature.
|
|
8
|
+
export { coverReads } from './courses/cover-reads.ts'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@asas/courses",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts",
|
|
8
|
+
"./domain/*": "./domain/*",
|
|
9
|
+
"./portal/*": "./portal/*"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@asas/mithaq": "*",
|
|
13
|
+
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
|
14
|
+
"mithaq": "*",
|
|
15
|
+
"mithaq-admin": "*",
|
|
16
|
+
"zod": "^4.6.5"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/react": "^19.3.0",
|
|
20
|
+
"lucide-react": "^1.16.0",
|
|
21
|
+
"react": "^19.3.0",
|
|
22
|
+
"@tanstack/react-query": "^5.104.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The portal's catalogue: the courses on offer as cards, searched by title and summary and narrowed by tag, each
|
|
2
|
+
// card opening the course's page.
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Search } from 'lucide-react'
|
|
5
|
+
import { useQuery } from '@tanstack/react-query'
|
|
6
|
+
import { useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
7
|
+
import { Input } from '@/components/ui/input'
|
|
8
|
+
import { Toggle } from '@/components/ui/toggle'
|
|
9
|
+
import { PortalShell, display } from './shell.tsx'
|
|
10
|
+
import { CardsLoading, CourseCard, ReadFailed } from './parts.tsx'
|
|
11
|
+
import { inLocale } from './rules.ts'
|
|
12
|
+
import { catalogueOf } from './reads.ts'
|
|
13
|
+
|
|
14
|
+
/** Drawn whole as the courses' list page, so it reads the list itself. */
|
|
15
|
+
export function Catalogue() {
|
|
16
|
+
const { locale, t } = useLocale()
|
|
17
|
+
const client = useClient()
|
|
18
|
+
const screen = useScreen()
|
|
19
|
+
const read = useQuery({ queryKey: ['portal-catalogue'], queryFn: () => catalogueOf({ client, maxLimit: screen.maxLimit, now: Date.now() }), staleTime: 30_000 })
|
|
20
|
+
const [term, setTerm] = useState('')
|
|
21
|
+
const [tag, setTag] = useState<string>()
|
|
22
|
+
const found = read.data
|
|
23
|
+
const asked = term.trim().toLocaleLowerCase(locale)
|
|
24
|
+
const shown = found?.courses.filter((course) =>
|
|
25
|
+
(!tag || (Array.isArray(course.tags) && course.tags.includes(tag)))
|
|
26
|
+
&& (!asked || [course.title, course.summary].some((one) => typeof one === 'string' && one.toLocaleLowerCase(locale).includes(asked))))
|
|
27
|
+
const title = t('courses.plural', 'Courses')
|
|
28
|
+
return (
|
|
29
|
+
<PortalShell
|
|
30
|
+
regions={{
|
|
31
|
+
header: <h1 className={`${display} text-3xl`}>{title}</h1>,
|
|
32
|
+
main: (
|
|
33
|
+
<div className="grid gap-5">
|
|
34
|
+
<div className="grid gap-3">
|
|
35
|
+
<label className="border-input bg-card focus-within:ring-ring flex h-12 items-center gap-2.5 rounded-full border px-4 focus-within:ring-2">
|
|
36
|
+
<Search aria-hidden className="text-muted-foreground size-4.5 shrink-0" />
|
|
37
|
+
<span className="sr-only">{t('portal.search', 'Search courses')}</span>
|
|
38
|
+
<Input
|
|
39
|
+
type="search"
|
|
40
|
+
value={term}
|
|
41
|
+
onChange={(event) => setTerm(event.target.value)}
|
|
42
|
+
placeholder={t('portal.search', 'Search courses')}
|
|
43
|
+
className="h-auto border-0 bg-transparent p-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent"
|
|
44
|
+
/>
|
|
45
|
+
</label>
|
|
46
|
+
{found?.tags.size ? (
|
|
47
|
+
<div role="group" aria-label={t('portal.byTag', 'Filter by tag')} className="flex flex-wrap gap-2">
|
|
48
|
+
<Toggle variant="outline" pressed={!tag} onPressedChange={() => setTag(undefined)} className="aria-pressed:bg-foreground aria-pressed:text-background h-11 rounded-full px-4">
|
|
49
|
+
{t('portal.allTags', 'All')}
|
|
50
|
+
</Toggle>
|
|
51
|
+
{[...found.tags.values()].map((one) => (
|
|
52
|
+
<Toggle
|
|
53
|
+
key={String(one.id)}
|
|
54
|
+
variant="outline"
|
|
55
|
+
pressed={tag === one.id}
|
|
56
|
+
onPressedChange={(pressed) => setTag(pressed ? String(one.id) : undefined)}
|
|
57
|
+
className="aria-pressed:bg-foreground aria-pressed:text-background h-11 rounded-full px-4"
|
|
58
|
+
>
|
|
59
|
+
{inLocale(one.name, locale)}
|
|
60
|
+
</Toggle>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
{read.isError ? <ReadFailed retry={() => void read.refetch()} /> : !found || !shown ? <CardsLoading /> : shown.length ? (
|
|
66
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-5">
|
|
67
|
+
{shown.map((course) => (
|
|
68
|
+
<CourseCard
|
|
69
|
+
key={String(course.id)}
|
|
70
|
+
course={course}
|
|
71
|
+
cover={found.covers.get(String(course.cover))}
|
|
72
|
+
next={found.next.get(String(course.id))}
|
|
73
|
+
tags={found.tags}
|
|
74
|
+
href={`${screen.path}/courses/${encodeURIComponent(String(course.id))}`}
|
|
75
|
+
/>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
) : (
|
|
79
|
+
<p className="text-muted-foreground text-sm">{found.courses.length ? t('portal.noMatch', 'No course matches.') : t('courses.noneOnOffer', 'No courses on offer yet.')}</p>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
),
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|