create-mithaq 0.0.0-stage → 0.2.0
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/LICENSE +22 -0
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +521 -0
- package/package.json +19 -4
- package/template/apps/asas-backoffice/components.json +25 -0
- package/template/apps/asas-backoffice/gitignore +3 -0
- package/template/apps/asas-backoffice/package.json +57 -0
- package/template/apps/asas-backoffice/public/favicon.svg +1 -0
- package/template/apps/asas-backoffice/src/api.ts +147 -0
- package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
- package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-backoffice/src/components.ts +35 -0
- package/template/apps/asas-backoffice/src/copy.ts +1 -0
- package/template/apps/asas-backoffice/src/first-user.ts +14 -0
- package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
- package/template/apps/asas-backoffice/src/links.ts +38 -0
- package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
- 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-backoffice/src/request-headers.ts +11 -0
- 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-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
- package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
- 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-backoffice/src/routes/auth.sign-in.tsx +77 -0
- package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
- package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
- package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
- package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
- package/template/apps/asas-backoffice/src/screens.tsx +888 -0
- package/template/apps/asas-backoffice/src/server.ts +30 -0
- package/template/apps/asas-backoffice/src/slide.ts +22 -0
- package/template/apps/asas-backoffice/src/start.ts +42 -0
- package/template/apps/asas-backoffice/src/styles.css +202 -0
- package/template/apps/asas-backoffice/src/theme.ts +2 -0
- package/template/apps/asas-backoffice/tsconfig.json +19 -0
- package/template/apps/asas-backoffice/vite.config.ts +32 -0
- package/template/apps/asas-tanstack/components.json +25 -0
- package/template/apps/asas-tanstack/gitignore +3 -0
- package/template/apps/asas-tanstack/package.json +58 -0
- package/template/apps/asas-tanstack/public/favicon.svg +1 -0
- package/template/apps/asas-tanstack/src/api.ts +147 -0
- package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
- package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
- package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
- package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
- package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
- package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
- package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
- package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
- package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
- package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
- package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
- package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
- package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
- package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
- package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
- package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
- package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
- package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
- package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
- package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
- package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
- package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
- package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
- package/template/apps/asas-tanstack/src/components.ts +35 -0
- package/template/apps/asas-tanstack/src/copy.ts +1207 -0
- package/template/apps/asas-tanstack/src/first-run.ts +16 -0
- package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
- package/template/apps/asas-tanstack/src/links.ts +38 -0
- package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
- package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +39 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
- package/template/apps/asas-tanstack/src/router.tsx +18 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
- package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
- package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +888 -0
- package/template/apps/asas-tanstack/src/server.ts +26 -0
- package/template/apps/asas-tanstack/src/slide.ts +22 -0
- package/template/apps/asas-tanstack/src/start.ts +57 -0
- package/template/apps/asas-tanstack/src/styles.css +163 -0
- package/template/apps/asas-tanstack/src/theme.ts +5 -0
- package/template/apps/asas-tanstack/tsconfig.json +19 -0
- package/template/apps/asas-tanstack/vite.config.ts +34 -0
- package/template/package.json +16 -0
- 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 +33 -0
- package/template/templates/asas/AGENTS.md +101 -0
- package/template/templates/asas/CLAUDE.md +1 -0
- package/template/templates/asas/README.md +1056 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +53 -0
- package/template/templates/asas/src/asas.ts +118 -0
- package/template/templates/asas/src/backoffice-http.ts +117 -0
- 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 +132 -0
- package/template/templates/asas/src/messages/auditLog.json +304 -0
- package/template/templates/asas/src/messages/backofficeLog.json +132 -0
- package/template/templates/asas/src/messages/courses.json +312 -0
- package/template/templates/asas/src/messages/enrollments.json +60 -0
- package/template/templates/asas/src/messages/invitation.json +82 -0
- package/template/templates/asas/src/messages/localUsers.json +48 -0
- package/template/templates/asas/src/messages/media.json +76 -0
- package/template/templates/asas/src/messages/member.json +62 -0
- package/template/templates/asas/src/messages/memberRoles.json +48 -0
- package/template/templates/asas/src/messages/mithaq.json +214 -0
- package/template/templates/asas/src/messages/organization.json +72 -0
- package/template/templates/asas/src/messages/organizationRole.json +62 -0
- package/template/templates/asas/src/messages/roles.json +72 -0
- package/template/templates/asas/src/messages/session.json +102 -0
- package/template/templates/asas/src/messages/sessions.json +136 -0
- package/template/templates/asas/src/messages/settings.json +64 -0
- package/template/templates/asas/src/messages/tags.json +48 -0
- package/template/templates/asas/src/messages/user.json +112 -0
- package/template/templates/asas/src/messages/verification.json +62 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
- 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/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +30 -0
- package/template/templates/asas/src/mithaq.ts +4 -0
- package/template/templates/asas/src/organizations.ts +3 -0
- package/template/templates/asas/src/seed.ts +427 -0
- package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
- package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
- package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
- package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
- 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 +829 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
- package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
- package/template/templates/asas/src/ui-portal/home.tsx +73 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- 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/features/auth/local-users/local-users.ts +143 -0
- package/template/templates/features/auth/package.json +13 -0
- package/template/templates/features/auth/user/account.access.ts +8 -0
- package/template/templates/features/auth/user/account.ts +31 -0
- package/template/templates/features/auth/user/index.ts +18 -0
- package/template/templates/features/auth/user/invitation.access.ts +4 -0
- package/template/templates/features/auth/user/invitation.ts +27 -0
- package/template/templates/features/auth/user/member.access.ts +10 -0
- package/template/templates/features/auth/user/member.screens.ts +37 -0
- package/template/templates/features/auth/user/member.ts +26 -0
- package/template/templates/features/auth/user/organization.access.ts +20 -0
- package/template/templates/features/auth/user/organization.screens.ts +37 -0
- package/template/templates/features/auth/user/organization.ts +25 -0
- package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/features/auth/user/organizationRole.ts +25 -0
- package/template/templates/features/auth/user/session.access.ts +8 -0
- package/template/templates/features/auth/user/session.ts +28 -0
- package/template/templates/features/auth/user/user.access.ts +30 -0
- package/template/templates/features/auth/user/user.screens.ts +45 -0
- package/template/templates/features/auth/user/user.ts +31 -0
- package/template/templates/features/auth/user/verification.access.ts +8 -0
- package/template/templates/features/auth/user/verification.ts +23 -0
- package/template/templates/features/courses/courses/courses.ts +601 -0
- 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/features/courses/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
- package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
- 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/features/courses/enrollments/ui/session-cell.tsx +75 -0
- package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
- 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/features/courses/sessions/sessions.ts +186 -0
- package/template/templates/features/media/index.ts +10 -0
- package/template/templates/features/media/media/media.ts +131 -0
- package/template/templates/features/media/media/ui/preview.tsx +143 -0
- package/template/templates/features/media/package.json +19 -0
- package/template/templates/features/roles/index.ts +4 -0
- package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
- package/template/templates/features/roles/package.json +21 -0
- package/template/templates/features/roles/roles/roles.ts +184 -0
- package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
- package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
- package/template/templates/features/settings/index.ts +3 -0
- package/template/templates/features/settings/package.json +13 -0
- package/template/templates/features/settings/settings/settings.ts +64 -0
- package/template/templates/features/tags/index.ts +3 -0
- package/template/templates/features/tags/package.json +14 -0
- package/template/templates/features/tags/tags/tags.ts +80 -0
- package/template/templates/mithaq/components.json +22 -0
- 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/mithaq/src/mithaq/audit-sink.ts +111 -0
- package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
- 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/mithaq/src/mithaq/pages.ts +83 -0
- 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/mithaq/src/roles/grants.ts +47 -0
- package/template/templates/mithaq/src/roles/member-access.ts +11 -0
- package/template/templates/mithaq/src/roles/statements.ts +13 -0
- package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
- package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
- package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
- package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/mithaq/src/storage/defaults.ts +156 -0
- package/template/templates/mithaq/src/storage/index.ts +97 -0
- package/template/templates/mithaq/src/storage/local.ts +143 -0
- package/template/templates/mithaq/src/storage/r2.ts +5 -0
- package/template/templates/mithaq/src/storage/s3.ts +91 -0
- package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
- package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
- package/template/templates/mithaq/src/ui/activity.tsx +527 -0
- package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
- package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
- package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
- package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
- package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
- package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
- package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
- package/README.md +0 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A row with no currency, one the reader may not read, or one Intl does not know keeps the plain
|
|
2
|
+
// number. The amount is a left-to-right isolate so Arabic neither reads "US$" as "$US" nor strands
|
|
3
|
+
// the dot of "ر.س." on the wrong side.
|
|
4
|
+
import type { CellProps } from 'mithaq/admin'
|
|
5
|
+
import { useLocale } from 'mithaq/admin/react'
|
|
6
|
+
import { noValue } from 'mithaq-admin/format.tsx'
|
|
7
|
+
|
|
8
|
+
const amount = (text: string) => <bdi dir="ltr">{text}</bdi>
|
|
9
|
+
|
|
10
|
+
export function FeeCell({ value, row }: CellProps) {
|
|
11
|
+
const { locale } = useLocale()
|
|
12
|
+
if (typeof value !== 'number') return noValue
|
|
13
|
+
try {
|
|
14
|
+
if (typeof row.currency === 'string' && row.currency) return amount(new Intl.NumberFormat(locale, { style: 'currency', currency: row.currency }).format(value))
|
|
15
|
+
} catch {
|
|
16
|
+
// A code Intl refuses throws a RangeError; the number alone is still true.
|
|
17
|
+
}
|
|
18
|
+
return amount(value.toLocaleString(locale))
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// What the courses list draws when it holds no row, and what the board page
|
|
2
|
+
// draws by path: the same file under a registered name and an inline path.
|
|
3
|
+
import type { EmptyProps } from 'mithaq/admin'
|
|
4
|
+
import { useKayan, useLocale } from 'mithaq/admin/react'
|
|
5
|
+
|
|
6
|
+
export function NoCourses({ page }: EmptyProps) {
|
|
7
|
+
const { t } = useLocale()
|
|
8
|
+
return (
|
|
9
|
+
<p data-empty={`${useKayan().name}${page.path}`} className="text-muted-foreground text-sm">
|
|
10
|
+
{t('courses.none', 'No courses yet.')}
|
|
11
|
+
</p>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The list reads each row's latest draft, so a published course whose newest version is a draft
|
|
2
|
+
// and a never-published course both arrive as `draft`. The plain read finds only courses with a
|
|
3
|
+
// published copy, which tells the two apart. An archived course says so over any of the three.
|
|
4
|
+
import { use } from 'react'
|
|
5
|
+
import { useQuery } from '@tanstack/react-query'
|
|
6
|
+
import type { CellProps, Row } from 'mithaq/admin'
|
|
7
|
+
import { ListContext, useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
8
|
+
import { Badge } from '@/components/ui/badge'
|
|
9
|
+
import { noValue } from 'mithaq-admin/format.tsx'
|
|
10
|
+
import { coursesOf } from '../../enrollments/ui/session-cell.tsx'
|
|
11
|
+
|
|
12
|
+
const pills = {
|
|
13
|
+
draft: 'bg-muted text-muted-foreground',
|
|
14
|
+
published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
|
15
|
+
changed: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
|
16
|
+
archived: 'bg-destructive/10 text-destructive',
|
|
17
|
+
}
|
|
18
|
+
const words = { draft: 'Draft', published: 'Published', changed: 'Changed', archived: 'Archived' }
|
|
19
|
+
|
|
20
|
+
export function StatusCell({ value, row }: CellProps) {
|
|
21
|
+
const client = useClient()
|
|
22
|
+
const { t } = useLocale()
|
|
23
|
+
const { maxLimit } = useScreen()
|
|
24
|
+
// Sorted, so the same rows in another order are the same query.
|
|
25
|
+
const ids = [...new Set((use(ListContext)?.page.docs ?? [row]).flatMap((one: Row) => (one._status === 'draft' ? [String(one.id)] : [])))].sort()
|
|
26
|
+
const { data: published, isError } = useQuery({ queryKey: ['status-cell', ids], queryFn: () => coursesOf({ client, ids, maxLimit }), enabled: ids.length > 0, staleTime: 30_000 })
|
|
27
|
+
if (value !== 'draft' && value !== 'published') return noValue
|
|
28
|
+
if (row.archived === true) return <Badge data-status="archived" className={pills.archived}>{t('version.archived', words.archived)}</Badge>
|
|
29
|
+
// Loading until the read answers, and a draft where it failed, which is what the row itself holds.
|
|
30
|
+
if (value === 'draft' && !published && !isError) return t('admin.loading', 'Loading…')
|
|
31
|
+
const status = value === 'draft' && published?.has(String(row.id)) ? 'changed' : value
|
|
32
|
+
return <Badge data-status={status} className={pills[status]}>{t(`version.${status}`, words[status])}</Badge>
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The danger zone's Unpublish: one click, behind the page's confirm, sending the
|
|
2
|
+
// draft status `unpublish` takes, as Payload's Unpublish does. The course stays
|
|
3
|
+
// as it is, read by draft readers alone, until it is published again.
|
|
4
|
+
import type { ButtonProps } from 'mithaq/admin'
|
|
5
|
+
import { useLocale } from 'mithaq/admin/react'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
|
|
8
|
+
export function UnpublishButton({ busy, operation, run }: ButtonProps) {
|
|
9
|
+
const { t } = useLocale()
|
|
10
|
+
return (
|
|
11
|
+
<Button type="button" variant="outline" data-button={operation} disabled={busy} onClick={() => void run({ _status: 'draft' })}>
|
|
12
|
+
{t('courses.operations.unpublish', 'Unpublish')}
|
|
13
|
+
</Button>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Ctx } from 'mithaq'
|
|
3
|
+
|
|
4
|
+
/** The box an archive's or an unarchive's dialog asks for on every screen that draws its button. */
|
|
5
|
+
export const reason = {
|
|
6
|
+
kind: 'text',
|
|
7
|
+
label: { en: 'Reason', ar: 'السبب' },
|
|
8
|
+
rules: { required: true, maxLength: 200 },
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What `archive` and `unarchive` parse: the row and why. The error messages are
|
|
13
|
+
* the kayan's own copy keys, which the form resolves; without them it draws
|
|
14
|
+
* what zod wrote, in English.
|
|
15
|
+
*/
|
|
16
|
+
export const reasonInput = (key: `${string}.operations.${'archive' | 'unarchive'}`) => z.object({
|
|
17
|
+
id: z.guid(),
|
|
18
|
+
reason: z
|
|
19
|
+
.string({ error: `${key}.reason.required` })
|
|
20
|
+
.max(200, { error: `${key}.reason.maxLength` }),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one step each adds: it turns the operation's `reason` into the two
|
|
25
|
+
* columns the kayan has, so `reason` never has to be a field. `ctx.data` is
|
|
26
|
+
* spread to keep what the runner filled before it, such as `updatedAt`, and
|
|
27
|
+
* `reason` reaches no column: the store step writes the kayan's own fields alone.
|
|
28
|
+
* An empty reason, which a bulk archive sends, keeps none.
|
|
29
|
+
*/
|
|
30
|
+
export const marks = (archived: boolean) => (ctx: Ctx<{ reason: string }>): Ctx => ({ ...ctx, data: { ...ctx.data, archived, archiveReason: ctx.data.reason || null } })
|
|
31
|
+
|
|
32
|
+
/** Over a row not archived yet, where an archive button is drawn. Both arms, since a row from before the column holds `null`. */
|
|
33
|
+
export const unarchived = { or: [{ 'record.archived': { exists: false } }, { 'record.archived': { equals: false } }] } as const
|
|
34
|
+
|
|
35
|
+
/** Over an archived row, where an unarchive button and the Archived badge are drawn. */
|
|
36
|
+
export const archivedRow = { 'record.archived': { equals: true } } as const
|
|
37
|
+
|
|
38
|
+
/** A list's tabs over the same column: what is running, what was archived, and everything. */
|
|
39
|
+
export const notArchived = { or: [{ archived: { exists: false } }, { archived: { equals: false } }] } as const
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The courses the portal offers: every course not archived, of those a plain
|
|
2
|
+
// read answers, which are the published ones. The portal's catalogue, its home
|
|
3
|
+
// and its course page read these alone. Plain data in a file of its own, so a
|
|
4
|
+
// page reads it without the kayan's declaration. A course with no `archived`
|
|
5
|
+
// value is not archived either.
|
|
6
|
+
export const onOffer = { or: [{ archived: { exists: false } }, { archived: { equals: false } }] }
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
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.
|
|
3
|
+
// A session is titled by its start and a title is one field, so the course is a second read.
|
|
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:
|
|
6
|
+
// one read of every session the page's rows point at and one of their courses, shared by every cell of the page through the query client rather than two per cell,
|
|
7
|
+
// and read again once it is as old as the list's own pages. A read that failed is not kept, so the next one tries again.
|
|
8
|
+
// Each read is the viewer's own, so a session or a course the viewer may not read is left out, and its cell keeps what it can say.
|
|
9
|
+
import { use } from 'react'
|
|
10
|
+
import { useQuery } from '@tanstack/react-query'
|
|
11
|
+
import type { CellProps, FetchClient, Row } from 'mithaq/admin'
|
|
12
|
+
import { ListContext, useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
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'
|
|
17
|
+
|
|
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. */
|
|
19
|
+
const unread = (thrown: unknown): never[] => {
|
|
20
|
+
if (refusalKeyOf(thrown)) return []
|
|
21
|
+
throw thrown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const of = (client: FetchClient, kayan: string) => ({ find: (input: Record<string, unknown>) => client({ kayan, operation: 'find', input }) as Promise<FoundPage> })
|
|
25
|
+
|
|
26
|
+
/** Some courses by id, as the viewer reads them, which the session picker's label reads too. */
|
|
27
|
+
export const coursesOf = async ({ client, ids, maxLimit }: { client: FetchClient; ids: readonly string[]; maxLimit: number }): Promise<ReadonlyMap<string, Row>> => {
|
|
28
|
+
const courses = ids.length ? await findByIds({ reads: of(client, 'courses'), ids, maxLimit }).catch(unread) : []
|
|
29
|
+
return new Map(courses.map((one) => [String(one.id), one]))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sessionsOf = async ({ client, ids, maxLimit }: { client: FetchClient; ids: readonly string[]; maxLimit: number }): Promise<ReadonlyMap<string, Row>> => {
|
|
33
|
+
const sessions = await findByIds({ reads: of(client, 'sessions'), ids, maxLimit }).catch(unread)
|
|
34
|
+
const courseIds = [...new Set(sessions.flatMap((one) => (typeof one.course === 'string' ? [one.course] : [])))]
|
|
35
|
+
const course = await coursesOf({ client, ids: courseIds, maxLimit })
|
|
36
|
+
return new Map(sessions.map((one) => [String(one.id), { ...one, course: course.get(String(one.course)) }]))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SessionCell({ field, value, row }: CellProps) {
|
|
40
|
+
const client = useClient()
|
|
41
|
+
const { locale, t } = useLocale()
|
|
42
|
+
const { kayan: { courses, sessions: sessionKayan }, maxLimit } = useScreen()
|
|
43
|
+
// A list's page, or the one row a record page draws.
|
|
44
|
+
// Sorted, so the same rows in another order are the same query.
|
|
45
|
+
const ids = [...new Set((use(ListContext)?.page.docs ?? [row]).flatMap((one: Row) => (typeof one[field.name] === 'string' ? [one[field.name] as string] : [])))].sort()
|
|
46
|
+
const { data: sessions, isError } = useQuery({ queryKey: ['session-cell', ids], queryFn: () => sessionsOf({ client, ids, maxLimit }), staleTime: 30_000 })
|
|
47
|
+
if (typeof value !== 'string') return noValue
|
|
48
|
+
const session = sessions?.get(value)
|
|
49
|
+
const when = formatDate({ value: session?.startsAt, locale })
|
|
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(' · ')
|
|
57
|
+
// Never the id the column holds, as the relationship cell draws none:
|
|
58
|
+
// loading until the read answers, the server's render among it, a dash
|
|
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.
|
|
61
|
+
if (!when && !course) {
|
|
62
|
+
if (sessions) return t('admin.notAvailable', 'Not available')
|
|
63
|
+
return isError ? '—' : t('admin.loading', 'Loading…')
|
|
64
|
+
}
|
|
65
|
+
// Two lines, the course over its start, so a narrow column wraps each
|
|
66
|
+
// whole rather than one into the other.
|
|
67
|
+
return (
|
|
68
|
+
<span className="grid">
|
|
69
|
+
{course ? <span className="font-medium">{course}</span> : null}
|
|
70
|
+
{/* One unbroken run, so a narrow column breaks between the course and the date and never inside the date. */}
|
|
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}
|
|
73
|
+
</span>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// How a session reads wherever a picker lists or holds one: its course by the
|
|
2
|
+
// course's title, then when it starts, since a session's own title, the start
|
|
3
|
+
// alone, makes two sessions on one day read alike.
|
|
4
|
+
//
|
|
5
|
+
// A session holds its course as an id, and a read populates no relationship, so
|
|
6
|
+
// the courses are a second read: one of every course the picker's rows point at,
|
|
7
|
+
// shared through the query client. Each read is the viewer's, so a course the
|
|
8
|
+
// viewer may not read leaves the session's title, its start, as it was.
|
|
9
|
+
import type { ReactNode } from 'react'
|
|
10
|
+
import { useQuery } from '@tanstack/react-query'
|
|
11
|
+
import type { ProviderProps, Row } from 'mithaq/admin'
|
|
12
|
+
import { useClient, useLocale, useScreen } from 'mithaq/admin/react'
|
|
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
|
+
|
|
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
|
+
export const useSessionLabel: UseRowLabel = (target, rows) => {
|
|
19
|
+
const client = useClient()
|
|
20
|
+
const { locale } = useLocale()
|
|
21
|
+
const { kayan, maxLimit } = useScreen()
|
|
22
|
+
const sessions = target === 'sessions'
|
|
23
|
+
// Sorted, so the same courses in another order are the same query.
|
|
24
|
+
const ids = sessions ? [...new Set(rows.flatMap((one) => (typeof one.course === 'string' ? [one.course] : [])))].sort() : []
|
|
25
|
+
const { data: courses } = useQuery({ queryKey: ['session-courses', ids], queryFn: () => coursesOf({ client, ids, maxLimit }), enabled: ids.length > 0, staleTime: 30_000 })
|
|
26
|
+
if (!sessions) return undefined
|
|
27
|
+
return (row: Row) => {
|
|
28
|
+
const course = courses?.get(String(row.course))
|
|
29
|
+
const when = formatDate({ value: row.startsAt, locale })
|
|
30
|
+
// The date's own spaces are no-break ones, so a box too narrow for the whole label breaks between the course
|
|
31
|
+
// and the date and never inside the date.
|
|
32
|
+
return course && when ? `${nameOfRow({ row: course, title: titleFieldOf(kayan.courses), locale })} · ${when.replaceAll(' ', '\u00a0')}` : undefined
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SessionLabels = ({ children }: ProviderProps<ReactNode>) => <RowLabelContext value={useSessionLabel}>{children}</RowLabelContext>
|
|
@@ -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
|
+
}
|