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,300 @@
|
|
|
1
|
+
// The figures are read by the page's own `loader`, which the screen host runs
|
|
2
|
+
// where the page is loaded, as the viewer: on the server for a page load, so the
|
|
3
|
+
// numbers and the series are in the first HTML, and in the browser for a client
|
|
4
|
+
// navigation. A figure over a kayan the report does not let the actor read is not
|
|
5
|
+
// requested and not drawn.
|
|
6
|
+
import { useEffect, useState } from 'react'
|
|
7
|
+
import { Banknote, BookOpen, ClipboardList, TrendingUp } from 'lucide-react'
|
|
8
|
+
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
9
|
+
import type { LoaderTools, PageProps, Row } from 'mithaq/admin'
|
|
10
|
+
import { useLocale, useScreen } from 'mithaq/admin/react'
|
|
11
|
+
import { Button } from '@/components/ui/button'
|
|
12
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
13
|
+
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
|
14
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
15
|
+
import { counted, formatDate } from 'mithaq-admin/format.tsx'
|
|
16
|
+
import { AppShell } from '@asas/mithaq/ui/app-shell.tsx'
|
|
17
|
+
import { KpiCard } from '@asas/mithaq/ui/kpi-card.tsx'
|
|
18
|
+
import { MembersCard, membersFigures, type MembersFigures } from './members-card.tsx'
|
|
19
|
+
|
|
20
|
+
const ranges = [7, 30, 90] as const
|
|
21
|
+
type Range = (typeof ranges)[number]
|
|
22
|
+
|
|
23
|
+
/** The range `?range=` names, and 30 for anything else, since the URL is the reader's to write. */
|
|
24
|
+
const rangeOf = (search: string): Range => {
|
|
25
|
+
const asked = Number(new URLSearchParams(search).get('range'))
|
|
26
|
+
return ranges.find((one) => one === asked) ?? 30
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const day = 24 * 60 * 60 * 1000
|
|
30
|
+
|
|
31
|
+
/** The `range` UTC days ending today, oldest first, as `YYYY-MM-DD`. */
|
|
32
|
+
const daysOf = (range: Range) => Array.from({ length: range }, (_, at) => new Date(Date.now() - (range - 1 - at) * day).toISOString().slice(0, 10))
|
|
33
|
+
|
|
34
|
+
/** Every row a read matches, page by page, since a served read answers at most the build's `maxLimit` at once. */
|
|
35
|
+
const readAll = async ({ tools: { client, maxLimit }, kayan, where, draft }: { tools: LoaderTools; kayan: string; where?: Row; draft?: boolean }) => {
|
|
36
|
+
const rows: Row[] = []
|
|
37
|
+
for (let page = 1, pages = 1; page <= pages; page++) {
|
|
38
|
+
const found = (await client({ kayan, operation: 'find', input: { limit: maxLimit, page, depth: 0, ...(where && { where }) }, ...(draft && { draft }) })) as { docs: Row[]; totalPages: number }
|
|
39
|
+
rows.push(...found.docs)
|
|
40
|
+
pages = found.totalPages
|
|
41
|
+
}
|
|
42
|
+
return rows
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the page draws: the counts, the fees per currency, and the enrollments of each of the widest range's days. */
|
|
46
|
+
export type Figures = {
|
|
47
|
+
courses: number
|
|
48
|
+
open: number
|
|
49
|
+
/** One sum per currency, where the actor reads both a course's fee and its currency. */
|
|
50
|
+
fees?: [currency: string, sum: number][]
|
|
51
|
+
enrollments?: number
|
|
52
|
+
perDay?: Record<string, number>
|
|
53
|
+
/** The next sessions to start, each beside its course's title, where the actor reads sessions. */
|
|
54
|
+
upcoming?: { id: string; course: string; startsAt: string; venue?: string; capacity?: number }[]
|
|
55
|
+
/** Who works here and the roles they hold, where the actor may list members. */
|
|
56
|
+
members?: MembersFigures
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How many sessions the home's table lists, which is a card's worth. */
|
|
60
|
+
const upcomingLimit = 8
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The figures, read as the viewer. The two totals are the store's `count`.
|
|
64
|
+
* The enrollments of the widest range are read once, so choosing a narrower
|
|
65
|
+
* one asks the server nothing.
|
|
66
|
+
*/
|
|
67
|
+
export const loader = async (tools: LoaderTools): Promise<Figures> => {
|
|
68
|
+
const { client, access } = tools
|
|
69
|
+
const reads = access.collections?.enrollments?.read === true
|
|
70
|
+
// The table filters and orders on a session's start and names its course,
|
|
71
|
+
// so it is read only where the report lets the actor read both fields: a
|
|
72
|
+
// role holding `sessions.find` and neither field is refused the filter.
|
|
73
|
+
const sessionFields = access.collections?.sessions?.fields
|
|
74
|
+
const readsSessions = access.collections?.sessions?.read === true && sessionFields?.startsAt?.read === true && sessionFields.course?.read === true
|
|
75
|
+
// A fee is summed only beside its currency: a reader of the fee alone would
|
|
76
|
+
// be handed riyals and dollars added into one number.
|
|
77
|
+
const courseFields = access.collections?.courses?.fields
|
|
78
|
+
const readsFees = courseFields?.fee?.read === true && courseFields.currency?.read === true
|
|
79
|
+
const since = `${daysOf(90)[0]}T00:00:00.000Z`
|
|
80
|
+
// The courses the way the Courses list reads them: the latest draft of every
|
|
81
|
+
// row where the actor holds `findVersions`, so the card and the list count one
|
|
82
|
+
// set, a course nobody has published yet among it.
|
|
83
|
+
const draft = access.collections?.courses?.operations?.findVersions === true
|
|
84
|
+
const [rows, enrollments, recent, next, members] = await Promise.all([
|
|
85
|
+
readAll({ tools, kayan: 'courses', draft }),
|
|
86
|
+
reads ? (client({ kayan: 'enrollments', operation: 'count', input: {} }) as Promise<{ totalDocs: number }>) : undefined,
|
|
87
|
+
reads ? readAll({ tools, kayan: 'enrollments', where: { createdAt: { greater_than_equal: since } } }) : undefined,
|
|
88
|
+
readsSessions
|
|
89
|
+
? (client({
|
|
90
|
+
kayan: 'sessions',
|
|
91
|
+
operation: 'find',
|
|
92
|
+
input: { where: { startsAt: { greater_than_equal: new Date().toISOString() } }, sort: 'startsAt', limit: Math.min(upcomingLimit, tools.maxLimit), depth: 0 },
|
|
93
|
+
}) as Promise<{ docs: Row[] }>)
|
|
94
|
+
: undefined,
|
|
95
|
+
membersFigures(tools),
|
|
96
|
+
])
|
|
97
|
+
const titles = new Map(rows.map((course) => [String(course.id), String(course.title ?? course.id)]))
|
|
98
|
+
const fees: Record<string, number> = {}
|
|
99
|
+
// One sum per currency, because a riyal and a dollar do not add up, and
|
|
100
|
+
// none for a fee with no currency, which is no amount of any of them.
|
|
101
|
+
// Of the courses not archived, the ones the card's second line counts.
|
|
102
|
+
const open = rows.filter((course) => !course.archived)
|
|
103
|
+
for (const course of open) if (course.currency) fees[String(course.currency)] = (fees[String(course.currency)] ?? 0) + Number(course.fee ?? 0)
|
|
104
|
+
const perDay: Record<string, number> = {}
|
|
105
|
+
for (const row of recent ?? []) {
|
|
106
|
+
// A `Date` in process on the server, an ISO string over HTTP in the browser.
|
|
107
|
+
const key = new Date(row.createdAt as string | Date).toISOString().slice(0, 10)
|
|
108
|
+
perDay[key] = (perDay[key] ?? 0) + 1
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
...(members && { members }),
|
|
112
|
+
courses: rows.length,
|
|
113
|
+
open: open.length,
|
|
114
|
+
...(readsFees && { fees: Object.entries(fees) }),
|
|
115
|
+
...(reads && { enrollments: enrollments?.totalDocs, perDay }),
|
|
116
|
+
...(next && {
|
|
117
|
+
upcoming: next.docs.map((session) => ({
|
|
118
|
+
id: String(session.id),
|
|
119
|
+
course: titles.get(String(session.course)) ?? String(session.course ?? ''),
|
|
120
|
+
startsAt: new Date(session.startsAt as string | Date).toISOString(),
|
|
121
|
+
...(typeof session.venue === 'string' && { venue: session.venue }),
|
|
122
|
+
...(typeof session.capacity === 'number' && { capacity: session.capacity }),
|
|
123
|
+
})),
|
|
124
|
+
}),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function Dashboard({ loaderData: figures }: PageProps<Figures>) {
|
|
129
|
+
const { dir, locale, t } = useLocale()
|
|
130
|
+
const screen = useScreen()
|
|
131
|
+
const reads = figures?.perDay !== undefined
|
|
132
|
+
const sessions = screen.kayan.sessions
|
|
133
|
+
const sessionHref = (id: string) => `${screen.path}${sessions?.pages.edit?.path.replace(':id', encodeURIComponent(id)) ?? ''}`
|
|
134
|
+
const venueOf = (value: string) => sessions?.fields.find((field) => field.name === 'venue')?.options?.find((one) => one.value === value)?.label[locale] ?? value
|
|
135
|
+
const [range, setRange] = useState<Range>(30)
|
|
136
|
+
|
|
137
|
+
// The range is in the URL, so a reload or a link keeps it. The server has no
|
|
138
|
+
// location, so the first paint draws the default and this reads the URL once
|
|
139
|
+
// the page is in the browser.
|
|
140
|
+
useEffect(() => setRange(rangeOf(window.location.search)), [])
|
|
141
|
+
const choose = (next: Range) => {
|
|
142
|
+
const url = new URL(window.location.href)
|
|
143
|
+
url.searchParams.set('range', String(next))
|
|
144
|
+
window.history.replaceState(window.history.state, '', url)
|
|
145
|
+
setRange(next)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const count = (value: number | undefined) => (value === undefined ? '…' : new Intl.NumberFormat(locale).format(value))
|
|
149
|
+
const money = (value: number, currency: string) => {
|
|
150
|
+
try {
|
|
151
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value)
|
|
152
|
+
} catch {
|
|
153
|
+
return `${count(value)} ${currency}`
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const days = daysOf(range)
|
|
157
|
+
const points = days.map((key) => ({
|
|
158
|
+
day: new Date(key).toLocaleDateString(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }),
|
|
159
|
+
enrollments: figures?.perDay?.[key] ?? 0,
|
|
160
|
+
}))
|
|
161
|
+
const chartConfig = { enrollments: { label: t('dashboard.kpiEnrollments', 'Enrollments'), color: 'var(--chart-1)' } } satisfies ChartConfig
|
|
162
|
+
// Counted in each locale's own plural forms, so Arabic says "7 أيام" and "30 يومًا".
|
|
163
|
+
const inRange = counted({ t, locale, key: 'dashboard.kpiInRange', count: range, fallback: 'In the last {count} days' })
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<AppShell
|
|
167
|
+
regions={{
|
|
168
|
+
header: <h1>{t('dashboard.dashboard', 'Dashboard')}</h1>,
|
|
169
|
+
main: (
|
|
170
|
+
<section data-dashboard="dashboard" className="@container flex flex-col gap-4 md:gap-6">
|
|
171
|
+
{/* Says enrollments only to a reader of them, who is drawn their figures. */}
|
|
172
|
+
<p className="text-muted-foreground">
|
|
173
|
+
{reads ? t('dashboard.dashboardAbout', 'What this organization holds, and how many have enrolled lately.') : t('dashboard.dashboardAboutCatalogue', 'What this organization holds.')}
|
|
174
|
+
</p>
|
|
175
|
+
{/* Two to a row on a phone, so four figures fit on one screen. Four to a
|
|
176
|
+
row once the section itself is 672 pixels wide, which is what the
|
|
177
|
+
main area holds beside the sidebar at `lg`, so a narrower frame
|
|
178
|
+
keeps two and cuts no figure. */}
|
|
179
|
+
<div className="grid grid-cols-2 gap-4 @2xl:grid-cols-4">
|
|
180
|
+
<KpiCard
|
|
181
|
+
kpi="courses"
|
|
182
|
+
icon={<BookOpen className="size-4" />}
|
|
183
|
+
title={t('dashboard.kpiCourses', 'Courses')}
|
|
184
|
+
value={count(figures?.courses)}
|
|
185
|
+
footer={t('dashboard.kpiCoursesOpen', '{count} not archived')!.replace('{count}', count(figures?.open))}
|
|
186
|
+
/>
|
|
187
|
+
{/* One sum per line, each a left-to-right isolate, so Arabic
|
|
188
|
+
neither reorders "US$" into "$US" nor strands a separator at
|
|
189
|
+
a line's end. */}
|
|
190
|
+
{!figures || figures.fees ? (
|
|
191
|
+
<KpiCard
|
|
192
|
+
kpi="fees"
|
|
193
|
+
icon={<Banknote className="size-4" />}
|
|
194
|
+
title={t('dashboard.kpiFees', 'Total fees')}
|
|
195
|
+
// The placeholder every card draws until the figures arrive, so the grid does not shift.
|
|
196
|
+
value={!figures?.fees ? '…' : figures.fees.length ? figures.fees.map(([currency, sum]) => <bdi key={currency} dir="ltr" className="block w-fit">{money(sum, currency)}</bdi>) : '—'}
|
|
197
|
+
footer={t('dashboard.kpiFeesFooter', 'The fee of every course not archived, summed per currency')!}
|
|
198
|
+
/>
|
|
199
|
+
) : null}
|
|
200
|
+
{reads ? (
|
|
201
|
+
<>
|
|
202
|
+
<KpiCard
|
|
203
|
+
kpi="enrollments"
|
|
204
|
+
icon={<ClipboardList className="size-4" />}
|
|
205
|
+
title={t('dashboard.kpiEnrollments', 'Enrollments')}
|
|
206
|
+
value={count(figures?.enrollments)}
|
|
207
|
+
footer={t('dashboard.kpiAllTime', 'Since the first one')!}
|
|
208
|
+
/>
|
|
209
|
+
<KpiCard
|
|
210
|
+
kpi="recent"
|
|
211
|
+
icon={<TrendingUp className="size-4" />}
|
|
212
|
+
title={t('dashboard.kpiRecent', 'New enrollments')}
|
|
213
|
+
value={count(figures && points.reduce((sum, point) => sum + point.enrollments, 0))}
|
|
214
|
+
footer={inRange}
|
|
215
|
+
/>
|
|
216
|
+
</>
|
|
217
|
+
) : null}
|
|
218
|
+
{figures?.members ? <MembersCard figures={figures.members} href={`${screen.path}/members`} /> : null}
|
|
219
|
+
</div>
|
|
220
|
+
{reads ? (
|
|
221
|
+
<Card data-chart-card="enrollments">
|
|
222
|
+
<CardHeader>
|
|
223
|
+
<CardTitle>{t('dashboard.chartEnrollments', 'Enrollments per day')}</CardTitle>
|
|
224
|
+
<CardDescription>{inRange}</CardDescription>
|
|
225
|
+
<div className="flex items-center gap-1 pt-2">
|
|
226
|
+
{ranges.map((one) => (
|
|
227
|
+
<Button key={one} size="sm" variant={one === range ? 'secondary' : 'ghost'} className="max-sm:h-11" aria-pressed={one === range} onClick={() => choose(one)}>
|
|
228
|
+
{counted({ t, locale, key: 'dashboard.chartDays', count: one, fallback: '{count} days' })}
|
|
229
|
+
</Button>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
</CardHeader>
|
|
233
|
+
<CardContent>
|
|
234
|
+
<ChartContainer config={chartConfig} className="aspect-auto h-[280px] w-full">
|
|
235
|
+
<AreaChart data={points}>
|
|
236
|
+
<CartesianGrid vertical={false} />
|
|
237
|
+
{/* Time runs from the edge the text starts at: in Arabic
|
|
238
|
+
the oldest day is on the right and the figures too. */}
|
|
239
|
+
<XAxis dataKey="day" reversed={dir === 'rtl'} tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
|
|
240
|
+
<YAxis orientation={dir === 'rtl' ? 'right' : 'left'} allowDecimals={false} tickLine={false} axisLine={false} width={40} tickFormatter={(value: number) => count(value)} />
|
|
241
|
+
<ChartTooltip content={<ChartTooltipContent indicator="dot" />} />
|
|
242
|
+
<Area dataKey="enrollments" type="monotone" fill="var(--color-enrollments)" fillOpacity={0.4} stroke="var(--color-enrollments)" />
|
|
243
|
+
</AreaChart>
|
|
244
|
+
</ChartContainer>
|
|
245
|
+
</CardContent>
|
|
246
|
+
</Card>
|
|
247
|
+
) : null}
|
|
248
|
+
{figures?.upcoming ? (
|
|
249
|
+
<Card data-home-table="sessions">
|
|
250
|
+
<CardHeader>
|
|
251
|
+
<CardTitle>{t('dashboard.upcoming', 'Upcoming sessions')}</CardTitle>
|
|
252
|
+
<CardDescription>{t('dashboard.upcomingAbout', 'The next sessions to start, soonest first.')}</CardDescription>
|
|
253
|
+
</CardHeader>
|
|
254
|
+
<CardContent>
|
|
255
|
+
{figures.upcoming.length === 0 ? (
|
|
256
|
+
<p className="text-muted-foreground text-sm">{t('dashboard.upcomingNone', 'No session is about to start.')}</p>
|
|
257
|
+
) : (
|
|
258
|
+
<div className="overflow-hidden rounded-lg border">
|
|
259
|
+
<Table>
|
|
260
|
+
<TableHeader className="bg-muted/50">
|
|
261
|
+
<TableRow>
|
|
262
|
+
<TableHead>{t('sessions.course', 'Course')}</TableHead>
|
|
263
|
+
<TableHead>{t('sessions.startsAt', 'Starts at')}</TableHead>
|
|
264
|
+
<TableHead className="max-sm:hidden">{t('sessions.venue', 'Venue')}</TableHead>
|
|
265
|
+
<TableHead className="text-end max-sm:hidden">{t('sessions.capacity', 'Capacity')}</TableHead>
|
|
266
|
+
</TableRow>
|
|
267
|
+
</TableHeader>
|
|
268
|
+
<TableBody>
|
|
269
|
+
{figures.upcoming.map((session) => (
|
|
270
|
+
<TableRow key={session.id}>
|
|
271
|
+
<TableCell className="font-medium">
|
|
272
|
+
<a href={sessionHref(session.id)} className="hover:underline">{session.course}</a>
|
|
273
|
+
{/* Below `sm` the venue and the capacity fold under the course's name, so the table
|
|
274
|
+
fits a phone's width with no column cut at its edge. */}
|
|
275
|
+
{session.venue || session.capacity !== undefined ? (
|
|
276
|
+
<span className="text-muted-foreground block text-xs font-normal sm:hidden">
|
|
277
|
+
{[...(session.venue ? [venueOf(session.venue)] : []), ...(session.capacity === undefined ? [] : [`${t('sessions.capacity', 'Capacity')}: ${count(session.capacity)}`])].join(' · ')}
|
|
278
|
+
</span>
|
|
279
|
+
) : null}
|
|
280
|
+
</TableCell>
|
|
281
|
+
{/* The viewer's own clock, which the server's may not be, so the browser's reading replaces the server's without a warning.
|
|
282
|
+
One line, so a phone does not break the date and the time over five. */}
|
|
283
|
+
<TableCell className="whitespace-nowrap tabular-nums" suppressHydrationWarning>{formatDate({ value: session.startsAt, locale })}</TableCell>
|
|
284
|
+
<TableCell className="max-sm:hidden">{session.venue ? venueOf(session.venue) : '—'}</TableCell>
|
|
285
|
+
<TableCell className="text-end tabular-nums max-sm:hidden">{session.capacity === undefined ? '—' : count(session.capacity)}</TableCell>
|
|
286
|
+
</TableRow>
|
|
287
|
+
))}
|
|
288
|
+
</TableBody>
|
|
289
|
+
</Table>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</CardContent>
|
|
293
|
+
</Card>
|
|
294
|
+
) : null}
|
|
295
|
+
</section>
|
|
296
|
+
),
|
|
297
|
+
}}
|
|
298
|
+
/>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What `/dashboard` itself draws where the courses add-on, whose home counts
|
|
2
|
+
// its catalogue, is not there: a figure card for each list the nav offers the
|
|
3
|
+
// viewer, with how many rows of it they may read and a link to it, and with the
|
|
4
|
+
// roles add-on who works here and the roles they hold. A list
|
|
5
|
+
// whose count the viewer may not read, or whose count failed, shows a dash.
|
|
6
|
+
import type { LoaderTools, PageProps } from 'mithaq/admin'
|
|
7
|
+
import { useLocale, useScreen } from 'mithaq/admin/react'
|
|
8
|
+
import { AppShell, iconOf } from '@asas/mithaq/ui/app-shell.tsx'
|
|
9
|
+
import { KpiCard } from '@asas/mithaq/ui/kpi-card.tsx'
|
|
10
|
+
import { navLabel } from '@asas/mithaq/ui/screen-nav.tsx'
|
|
11
|
+
// add-on: roles
|
|
12
|
+
import { MembersCard, membersFigures } from './members-card.tsx'
|
|
13
|
+
// end add-on: roles
|
|
14
|
+
|
|
15
|
+
/** How many rows of each kayan the viewer may count, read as the viewer, keyed by its kayan. */
|
|
16
|
+
const countsOf = async ({ client, access }: LoaderTools): Promise<Record<string, number | undefined>> =>
|
|
17
|
+
Object.fromEntries(
|
|
18
|
+
await Promise.all(
|
|
19
|
+
Object.entries(access.collections ?? {})
|
|
20
|
+
.filter(([, one]) => one?.operations?.count === true)
|
|
21
|
+
.map(async ([kayan]) => [kayan, await (client({ kayan, operation: 'count', input: {} }) as Promise<{ totalDocs: number }>).then((found) => found.totalDocs).catch(() => undefined)] as const),
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export const loader = async (tools: LoaderTools) => ({
|
|
26
|
+
counts: await countsOf(tools),
|
|
27
|
+
// add-on: roles
|
|
28
|
+
// Who works here, where the viewer may list members.
|
|
29
|
+
members: await membersFigures(tools),
|
|
30
|
+
// end add-on: roles
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export function Home({ loaderData }: PageProps<Awaited<ReturnType<typeof loader>>>) {
|
|
34
|
+
const counts = loaderData?.counts
|
|
35
|
+
const { locale, t } = useLocale()
|
|
36
|
+
const screen = useScreen()
|
|
37
|
+
// add-on: roles
|
|
38
|
+
const members = loaderData?.members ? <MembersCard figures={loaderData.members} href={`${screen.path}/members`} /> : null
|
|
39
|
+
// end add-on: roles
|
|
40
|
+
// without add-on: roles
|
|
41
|
+
// const members = null
|
|
42
|
+
// end without add-on: roles
|
|
43
|
+
return (
|
|
44
|
+
<AppShell
|
|
45
|
+
regions={{
|
|
46
|
+
header: <h1>{t('dashboard.dashboard', 'Dashboard')}</h1>,
|
|
47
|
+
main: (
|
|
48
|
+
<section data-dashboard="dashboard" className="@container grid gap-4">
|
|
49
|
+
<p className="text-muted-foreground">{t('dashboard.dashboardAboutCatalogue', 'What this organization holds.')}</p>
|
|
50
|
+
<div className="grid grid-cols-2 items-start gap-4 @2xl:grid-cols-4">
|
|
51
|
+
{screen.nav.flatMap((entry) => {
|
|
52
|
+
if (!entry.kayan) return []
|
|
53
|
+
const Icon = iconOf(entry.path)
|
|
54
|
+
const counted = counts?.[entry.kayan]
|
|
55
|
+
return [
|
|
56
|
+
<KpiCard
|
|
57
|
+
key={entry.kayan}
|
|
58
|
+
kpi={entry.kayan}
|
|
59
|
+
icon={<Icon className="size-4" />}
|
|
60
|
+
title={navLabel({ entry, screen, locale, t })}
|
|
61
|
+
value={counted === undefined ? '—' : new Intl.NumberFormat(locale).format(counted)}
|
|
62
|
+
footer={<a href={`${screen.path}${entry.path}`} className="text-foreground inline-flex items-center hover:underline max-sm:min-h-11">{t('portal.open', 'Open the list')}</a>}
|
|
63
|
+
/>,
|
|
64
|
+
]
|
|
65
|
+
})}
|
|
66
|
+
{members}
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
),
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// What the Members page and the dashboard's Members card both read: the members of the viewer's organization with their
|
|
2
|
+
// roles, and the roles a member may hold, read as the viewer. Its own module, so the dashboard's home carries the card
|
|
3
|
+
// and not the page.
|
|
4
|
+
import { Users } from 'lucide-react'
|
|
5
|
+
import { holds, type LoaderTools } from 'mithaq/admin'
|
|
6
|
+
import { useLocale } from 'mithaq/admin/react'
|
|
7
|
+
import { isOwner, OWNER_ROLE } from '@asas/mithaq/roles/grants.ts'
|
|
8
|
+
import { tenantRoles } from '@asas/mithaq/roles/tenant-roles.ts'
|
|
9
|
+
import { KpiCard } from '@asas/mithaq/ui/kpi-card.tsx'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One member as the page draws it: each role assignment by its row, whether they own the organization, which holds
|
|
13
|
+
* `administrator` whatever the rows say, and when they were last seen, where the viewer may read it.
|
|
14
|
+
*/
|
|
15
|
+
export type Member = { userId: string; name: string; email: string; joined: string; lastSeen?: string; owner: boolean; roles: { id: string; role: string }[] }
|
|
16
|
+
/**
|
|
17
|
+
* One role the drawer offers: its label in every locale, whether this project ships it, and its keys, which a fixed role's
|
|
18
|
+
* code always says and a custom role's row says where the viewer may read them.
|
|
19
|
+
*/
|
|
20
|
+
export type Role = { name: string; label?: Readonly<Record<string, string>>; fixed: boolean; permissions?: readonly string[] }
|
|
21
|
+
/** An ISO timestamp, or `''` for a value that does not parse: `toISOString()` throws on an invalid date, and a row with no readable date is still worth drawing. */
|
|
22
|
+
export const isoOf = (value: unknown): string => {
|
|
23
|
+
const when = new Date(value as string)
|
|
24
|
+
return value === null || value === undefined || Number.isNaN(when.getTime()) ? '' : when.toISOString()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every row of a read that answers a page at a time, read until the total is reached: an organization outgrows any one
|
|
29
|
+
* page, and a page that drew the first hundred would leave the rest out without a word.
|
|
30
|
+
*
|
|
31
|
+
* The pages are offsets, so a row added or removed between two reads shifts the next page: a row then comes back twice,
|
|
32
|
+
* and is kept once by `key`, or is missed until the page loads again.
|
|
33
|
+
*/
|
|
34
|
+
// ponytail: every member is read and drawn at once, by offset; page the table itself, by a cursor, once an organization's size makes the page slow.
|
|
35
|
+
export const everyPage = async <Row,>({ read, key }: { read: (offset: number) => Promise<{ rows: Row[]; total: number }>; key: (row: Row) => string }): Promise<Row[]> => {
|
|
36
|
+
const rows = new Map<string, Row>()
|
|
37
|
+
for (let offset = 0; ; ) {
|
|
38
|
+
const page = await read(offset)
|
|
39
|
+
for (const row of page.rows) if (!rows.has(key(row))) rows.set(key(row), row)
|
|
40
|
+
offset += page.rows.length
|
|
41
|
+
if (!page.rows.length || offset >= page.total) return [...rows.values()]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The members with their roles, and the roles a member may hold: what the page and the dashboard's card both read. */
|
|
46
|
+
export const readMembers = async ({ access, client, request, maxLimit }: LoaderTools): Promise<{ members: Member[]; roles: Role[] }> => {
|
|
47
|
+
const [members, assignments, roles] = await Promise.all([
|
|
48
|
+
everyPage({
|
|
49
|
+
key: (member) => member.userId,
|
|
50
|
+
read: async (offset: number) => {
|
|
51
|
+
// In id order, which no change to a member's other columns reorders.
|
|
52
|
+
const response = await request(`/api/auth/user/organization/list-members?limit=${maxLimit}&offset=${offset}&sortBy=id`)
|
|
53
|
+
if (!response.ok) throw new Error(`list-members answered ${response.status}`)
|
|
54
|
+
const page = (await response.json()) as { members: { userId: string; role: string; createdAt: string; lastSeenAt?: string | null; user: { name: string; email: string } }[]; total: number }
|
|
55
|
+
return { rows: page.members, total: page.total }
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
// The page is served on `member.find`, so a viewer may list the members
|
|
59
|
+
// and not read who holds which role.
|
|
60
|
+
holds(access, 'memberRoles.find')
|
|
61
|
+
? everyPage({
|
|
62
|
+
key: (row) => row.id,
|
|
63
|
+
read: async (offset: number) => {
|
|
64
|
+
const page = (await client({ kayan: 'memberRoles', operation: 'find', input: { limit: maxLimit, page: Math.ceil(offset / maxLimit) + 1, depth: 0 } })) as { docs: { id: string; user: string; role: string }[]; totalDocs: number }
|
|
65
|
+
return { rows: page.docs, total: page.totalDocs }
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
: [],
|
|
69
|
+
// Asked only of a viewer the report lets read roles, so the page still
|
|
70
|
+
// draws for one who may read the assignments and not the roles.
|
|
71
|
+
holds(access, 'roles.find')
|
|
72
|
+
? (client({ kayan: 'roles', operation: 'find', input: { limit: maxLimit, depth: 0, sort: 'name' } }) as Promise<{ docs: { name: string; label?: Record<string, string> | null; permissions?: unknown }[] }>)
|
|
73
|
+
: { docs: [] },
|
|
74
|
+
])
|
|
75
|
+
return {
|
|
76
|
+
members: [...members].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((member) => ({
|
|
77
|
+
userId: member.userId,
|
|
78
|
+
name: member.user.name,
|
|
79
|
+
email: member.user.email,
|
|
80
|
+
joined: isoOf(member.createdAt),
|
|
81
|
+
// Answered only to a viewer holding the column's key; the server leaves it out for anyone else.
|
|
82
|
+
...(member.lastSeenAt && { lastSeen: isoOf(member.lastSeenAt) }),
|
|
83
|
+
owner: isOwner(member.role),
|
|
84
|
+
roles: assignments.filter((row) => row.user === member.userId).map((row) => ({ id: String(row.id), role: row.role })),
|
|
85
|
+
})),
|
|
86
|
+
roles: [
|
|
87
|
+
...Object.entries(tenantRoles).map(([name, role]): Role => ({ name, label: role.label, fixed: true, permissions: role.permissions === '*' ? ['*'] : role.permissions })),
|
|
88
|
+
...roles.docs.map((row): Role => ({
|
|
89
|
+
name: row.name,
|
|
90
|
+
...(row.label && { label: row.label }),
|
|
91
|
+
fixed: false,
|
|
92
|
+
...(Array.isArray(row.permissions) && { permissions: row.permissions as string[] }),
|
|
93
|
+
})),
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The dashboard's Members card: how many work here and, to a reader of the assignments, how many hold each role. */
|
|
99
|
+
export type MembersFigures = { total: number; held?: { role: string; label?: Readonly<Record<string, string>>; count: number }[] }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The card's figures, read as the viewer, and nothing for a viewer who may not list the members or whose read failed,
|
|
103
|
+
* so the home still draws. An owner holds `administrator` whatever the rows say, as the server reads them.
|
|
104
|
+
*/
|
|
105
|
+
export const membersFigures = async (tools: LoaderTools): Promise<MembersFigures | undefined> => {
|
|
106
|
+
if (!holds(tools.access, 'member.find')) return undefined
|
|
107
|
+
const read = await readMembers(tools).catch(() => undefined)
|
|
108
|
+
if (!read) return undefined
|
|
109
|
+
if (!holds(tools.access, 'memberRoles.find')) return { total: read.members.length }
|
|
110
|
+
const counts = new Map<string, number>()
|
|
111
|
+
for (const member of read.members) {
|
|
112
|
+
for (const role of new Set([...member.roles.map((one) => one.role), ...(member.owner ? [OWNER_ROLE] : [])])) counts.set(role, (counts.get(role) ?? 0) + 1)
|
|
113
|
+
}
|
|
114
|
+
const order = read.roles.map((role) => role.name)
|
|
115
|
+
return {
|
|
116
|
+
total: read.members.length,
|
|
117
|
+
held: [...counts]
|
|
118
|
+
.sort(([a], [b]) => (order.indexOf(a) >>> 0) - (order.indexOf(b) >>> 0))
|
|
119
|
+
.map(([role, count]) => {
|
|
120
|
+
const label = read.roles.find((one) => one.name === role)?.label
|
|
121
|
+
return { role, ...(label && { label }), count }
|
|
122
|
+
}),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function MembersCard({ figures, href }: { figures: MembersFigures; href: string }) {
|
|
127
|
+
const { locale, t } = useLocale()
|
|
128
|
+
const number = new Intl.NumberFormat(locale)
|
|
129
|
+
return (
|
|
130
|
+
<KpiCard
|
|
131
|
+
kpi="members"
|
|
132
|
+
icon={<Users className="size-4" />}
|
|
133
|
+
title={t('dashboard.members', 'Members')}
|
|
134
|
+
value={number.format(figures.total)}
|
|
135
|
+
footer={
|
|
136
|
+
<span className="grid gap-1">
|
|
137
|
+
{figures.held?.length ? (
|
|
138
|
+
<span data-members-held="">
|
|
139
|
+
{figures.held.map((one) => `${number.format(one.count)} ${one.label?.[locale] ?? one.role}`).join(' · ')}
|
|
140
|
+
</span>
|
|
141
|
+
) : null}
|
|
142
|
+
<a href={href} className="text-foreground inline-flex items-center hover:underline max-sm:min-h-11">{t('portal.open', 'Open the list')}</a>
|
|
143
|
+
</span>
|
|
144
|
+
}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
}
|