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.
Files changed (396) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/index.js +521 -0
  6. package/package.json +19 -4
  7. package/template/apps/asas-backoffice/components.json +25 -0
  8. package/template/apps/asas-backoffice/gitignore +3 -0
  9. package/template/apps/asas-backoffice/package.json +57 -0
  10. package/template/apps/asas-backoffice/public/favicon.svg +1 -0
  11. package/template/apps/asas-backoffice/src/api.ts +147 -0
  12. package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
  13. package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
  14. package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
  15. package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
  16. package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
  17. package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
  18. package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
  19. package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
  20. package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
  21. package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
  22. package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
  23. package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
  24. package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
  25. package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
  26. package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
  27. package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
  28. package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
  29. package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
  30. package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
  31. package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
  32. package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
  33. package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
  34. package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
  35. package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
  36. package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
  37. package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
  38. package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
  39. package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
  40. package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
  41. package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
  42. package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
  43. package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
  44. package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
  45. package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
  46. package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
  47. package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
  48. package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
  49. package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
  50. package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
  51. package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
  52. package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
  53. package/template/apps/asas-backoffice/src/components.ts +35 -0
  54. package/template/apps/asas-backoffice/src/copy.ts +1 -0
  55. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  56. package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
  57. package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
  58. package/template/apps/asas-backoffice/src/links.ts +38 -0
  59. package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
  60. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  61. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  62. package/template/apps/asas-backoffice/src/request-headers.ts +11 -0
  63. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  64. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  65. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  66. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
  67. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
  68. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
  69. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
  70. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
  71. package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
  72. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  73. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  74. package/template/apps/asas-backoffice/src/routes/auth.sign-in.tsx +77 -0
  75. package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
  76. package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
  77. package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
  78. package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
  79. package/template/apps/asas-backoffice/src/screens.tsx +888 -0
  80. package/template/apps/asas-backoffice/src/server.ts +30 -0
  81. package/template/apps/asas-backoffice/src/slide.ts +22 -0
  82. package/template/apps/asas-backoffice/src/start.ts +42 -0
  83. package/template/apps/asas-backoffice/src/styles.css +202 -0
  84. package/template/apps/asas-backoffice/src/theme.ts +2 -0
  85. package/template/apps/asas-backoffice/tsconfig.json +19 -0
  86. package/template/apps/asas-backoffice/vite.config.ts +32 -0
  87. package/template/apps/asas-tanstack/components.json +25 -0
  88. package/template/apps/asas-tanstack/gitignore +3 -0
  89. package/template/apps/asas-tanstack/package.json +58 -0
  90. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  91. package/template/apps/asas-tanstack/src/api.ts +147 -0
  92. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  93. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  94. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  95. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  96. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  97. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  98. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  99. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  100. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  101. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  102. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  103. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  104. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  105. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  106. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  107. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  108. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  109. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  110. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  111. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  112. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  113. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  114. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  115. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  116. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  117. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  118. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  119. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  120. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  121. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  122. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  123. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  124. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  125. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  126. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  127. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  128. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  129. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  130. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  131. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  132. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  133. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  134. package/template/apps/asas-tanstack/src/components.ts +35 -0
  135. package/template/apps/asas-tanstack/src/copy.ts +1207 -0
  136. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  137. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  138. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  139. package/template/apps/asas-tanstack/src/links.ts +38 -0
  140. package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
  141. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  142. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  143. package/template/apps/asas-tanstack/src/redirect.ts +39 -0
  144. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  145. package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
  146. package/template/apps/asas-tanstack/src/router.tsx +18 -0
  147. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
  148. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
  149. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
  150. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  151. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  152. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  153. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
  154. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
  155. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
  156. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  157. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  158. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  159. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  160. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  161. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
  162. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  163. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  164. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  165. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
  166. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  167. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
  168. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
  169. package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
  170. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
  171. package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
  172. package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
  173. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  174. package/template/apps/asas-tanstack/src/screens.tsx +888 -0
  175. package/template/apps/asas-tanstack/src/server.ts +26 -0
  176. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  177. package/template/apps/asas-tanstack/src/start.ts +57 -0
  178. package/template/apps/asas-tanstack/src/styles.css +163 -0
  179. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  180. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  181. package/template/apps/asas-tanstack/vite.config.ts +34 -0
  182. package/template/package.json +16 -0
  183. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  184. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  185. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  186. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  187. package/template/templates/asas/.env.example +33 -0
  188. package/template/templates/asas/AGENTS.md +101 -0
  189. package/template/templates/asas/CLAUDE.md +1 -0
  190. package/template/templates/asas/README.md +1056 -0
  191. package/template/templates/asas/mithaq.hints.json +25 -0
  192. package/template/templates/asas/package.json +53 -0
  193. package/template/templates/asas/src/asas.ts +118 -0
  194. package/template/templates/asas/src/backoffice-http.ts +117 -0
  195. package/template/templates/asas/src/dev-setup.ts +112 -0
  196. package/template/templates/asas/src/local-database.ts +22 -0
  197. package/template/templates/asas/src/messages/account.json +132 -0
  198. package/template/templates/asas/src/messages/auditLog.json +304 -0
  199. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  200. package/template/templates/asas/src/messages/courses.json +312 -0
  201. package/template/templates/asas/src/messages/enrollments.json +60 -0
  202. package/template/templates/asas/src/messages/invitation.json +82 -0
  203. package/template/templates/asas/src/messages/localUsers.json +48 -0
  204. package/template/templates/asas/src/messages/media.json +76 -0
  205. package/template/templates/asas/src/messages/member.json +62 -0
  206. package/template/templates/asas/src/messages/memberRoles.json +48 -0
  207. package/template/templates/asas/src/messages/mithaq.json +214 -0
  208. package/template/templates/asas/src/messages/organization.json +72 -0
  209. package/template/templates/asas/src/messages/organizationRole.json +62 -0
  210. package/template/templates/asas/src/messages/roles.json +72 -0
  211. package/template/templates/asas/src/messages/session.json +102 -0
  212. package/template/templates/asas/src/messages/sessions.json +136 -0
  213. package/template/templates/asas/src/messages/settings.json +64 -0
  214. package/template/templates/asas/src/messages/tags.json +48 -0
  215. package/template/templates/asas/src/messages/user.json +112 -0
  216. package/template/templates/asas/src/messages/verification.json +62 -0
  217. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  218. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  219. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  220. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  221. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  222. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  223. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  224. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  225. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  226. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  227. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  228. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  229. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  230. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  231. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  232. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  233. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  234. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  235. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  236. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  237. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  238. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  239. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  240. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  241. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  242. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  243. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  244. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  245. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  246. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  247. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  248. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  249. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  250. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  251. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  252. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  253. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  254. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  255. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  256. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  257. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  258. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  259. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  260. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  261. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  262. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  263. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  264. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  265. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  266. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  267. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  268. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  269. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  270. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  271. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  272. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  273. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  274. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  275. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  276. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  277. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  278. package/template/templates/asas/src/migrations/schema.ts +30 -0
  279. package/template/templates/asas/src/mithaq.ts +4 -0
  280. package/template/templates/asas/src/organizations.ts +3 -0
  281. package/template/templates/asas/src/seed.ts +427 -0
  282. package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
  283. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  284. package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
  285. package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
  286. package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
  287. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
  288. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  289. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  290. package/template/templates/asas/src/ui-dashboard/members.tsx +829 -0
  291. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  292. package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
  293. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  294. package/template/templates/asas/src/ui-portal/home.tsx +73 -0
  295. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  296. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  297. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  298. package/template/templates/features/audit-log/index.ts +4 -0
  299. package/template/templates/features/audit-log/package.json +13 -0
  300. package/template/templates/features/auth/index.ts +11 -0
  301. package/template/templates/features/auth/local-users/local-users.ts +143 -0
  302. package/template/templates/features/auth/package.json +13 -0
  303. package/template/templates/features/auth/user/account.access.ts +8 -0
  304. package/template/templates/features/auth/user/account.ts +31 -0
  305. package/template/templates/features/auth/user/index.ts +18 -0
  306. package/template/templates/features/auth/user/invitation.access.ts +4 -0
  307. package/template/templates/features/auth/user/invitation.ts +27 -0
  308. package/template/templates/features/auth/user/member.access.ts +10 -0
  309. package/template/templates/features/auth/user/member.screens.ts +37 -0
  310. package/template/templates/features/auth/user/member.ts +26 -0
  311. package/template/templates/features/auth/user/organization.access.ts +20 -0
  312. package/template/templates/features/auth/user/organization.screens.ts +37 -0
  313. package/template/templates/features/auth/user/organization.ts +25 -0
  314. package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
  315. package/template/templates/features/auth/user/organizationRole.ts +25 -0
  316. package/template/templates/features/auth/user/session.access.ts +8 -0
  317. package/template/templates/features/auth/user/session.ts +28 -0
  318. package/template/templates/features/auth/user/user.access.ts +30 -0
  319. package/template/templates/features/auth/user/user.screens.ts +45 -0
  320. package/template/templates/features/auth/user/user.ts +31 -0
  321. package/template/templates/features/auth/user/verification.access.ts +8 -0
  322. package/template/templates/features/auth/user/verification.ts +23 -0
  323. package/template/templates/features/courses/courses/courses.ts +601 -0
  324. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  325. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  326. package/template/templates/features/courses/courses/ui/fee-cell.tsx +19 -0
  327. package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
  328. package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
  329. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  330. package/template/templates/features/courses/domain/archive.ts +39 -0
  331. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  332. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  333. package/template/templates/features/courses/enrollments/ui/session-cell.tsx +75 -0
  334. package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
  335. package/template/templates/features/courses/index.ts +8 -0
  336. package/template/templates/features/courses/package.json +24 -0
  337. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  338. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  339. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  340. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  341. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  342. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  343. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  344. package/template/templates/features/courses/sessions/seats.ts +42 -0
  345. package/template/templates/features/courses/sessions/sessions.ts +186 -0
  346. package/template/templates/features/media/index.ts +10 -0
  347. package/template/templates/features/media/media/media.ts +131 -0
  348. package/template/templates/features/media/media/ui/preview.tsx +143 -0
  349. package/template/templates/features/media/package.json +19 -0
  350. package/template/templates/features/roles/index.ts +4 -0
  351. package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
  352. package/template/templates/features/roles/package.json +21 -0
  353. package/template/templates/features/roles/roles/roles.ts +184 -0
  354. package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
  355. package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
  356. package/template/templates/features/settings/index.ts +3 -0
  357. package/template/templates/features/settings/package.json +13 -0
  358. package/template/templates/features/settings/settings/settings.ts +64 -0
  359. package/template/templates/features/tags/index.ts +3 -0
  360. package/template/templates/features/tags/package.json +14 -0
  361. package/template/templates/features/tags/tags/tags.ts +80 -0
  362. package/template/templates/mithaq/components.json +22 -0
  363. package/template/templates/mithaq/package.json +33 -0
  364. package/template/templates/mithaq/src/mail/index.ts +32 -0
  365. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  366. package/template/templates/mithaq/src/mithaq/audit-sink.ts +111 -0
  367. package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
  368. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  369. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  370. package/template/templates/mithaq/src/mithaq/pages.ts +83 -0
  371. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  372. package/template/templates/mithaq/src/mithaq.ts +593 -0
  373. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  374. package/template/templates/mithaq/src/roles/grants.ts +47 -0
  375. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  376. package/template/templates/mithaq/src/roles/statements.ts +13 -0
  377. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  378. package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
  379. package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
  380. package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
  381. package/template/templates/mithaq/src/storage/defaults.ts +156 -0
  382. package/template/templates/mithaq/src/storage/index.ts +97 -0
  383. package/template/templates/mithaq/src/storage/local.ts +143 -0
  384. package/template/templates/mithaq/src/storage/r2.ts +5 -0
  385. package/template/templates/mithaq/src/storage/s3.ts +91 -0
  386. package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
  387. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  388. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  389. package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
  390. package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
  391. package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
  392. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  393. package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
  394. package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
  395. package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
  396. 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
+ }