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,527 @@
1
+ // The entries are read in the browser, as the viewer, from the log's own `find`,
2
+ // which answers only this tenant's entries of kayan the viewer lists and only the
3
+ // changes of columns it reads, and narrows every search of a change to the kayan
4
+ // the viewer may be told of. The page holds only the fields this viewer reads as
5
+ // well, so a change the server let through for a column the page left out is not
6
+ // drawn either.
7
+ import { useContext, useEffect, useId, useMemo, useState, type ReactNode } from 'react'
8
+ import type { ClientField, ClientKayan, ComponentProps, Row } from 'mithaq/admin'
9
+ import { FormContext, useAccess, useClient, useKayan, useLocale, useRecord, useScreen, useUser } from 'mithaq/admin/react'
10
+ import { empty, type Change } from '../mithaq/audit-sink.ts'
11
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
12
+ import { Button } from '@/components/ui/button'
13
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
14
+ import { Label } from '@/components/ui/label'
15
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
16
+ import { cellFor, useComponents } from 'mithaq-admin/components.tsx'
17
+ import { DatePicker } from 'mithaq-admin/date-control.tsx'
18
+ import { missingRow, type FindApi, type FoundPage, type RelationshipTitles } from 'mithaq-admin/control-props.ts'
19
+ import { labelFor, operationLabelFor, titleOf, type Translate } from 'mithaq-admin/copy.ts'
20
+ import { formatDate } from 'mithaq-admin/format.tsx'
21
+ import { Pagination, type PageInfo } from 'mithaq-admin/list-pagination.tsx'
22
+ import { Search } from 'mithaq-admin/list-toolbar.tsx'
23
+ import { rowKey, useRelationshipTitles } from 'mithaq-admin/relationship-cell.tsx'
24
+ import { RelationshipControl } from 'mithaq-admin/relationship-control.tsx'
25
+ import { rowTitle } from 'mithaq-admin/row-title.tsx'
26
+
27
+ /** One entry as the log's `find` answers it. */
28
+ export type Entry = {
29
+ id: string
30
+ createdAt: string | Date
31
+ kayan?: string
32
+ record?: string
33
+ operation: string
34
+ actor?: string | null
35
+ impersonatedBy?: string | null
36
+ changes: Readonly<Record<string, Change>>
37
+ }
38
+
39
+ /** How many entries the record page's side column draws: the last few, the rest on the Activity page. */
40
+ const latest = 5
41
+
42
+ /** The columns every kayan of this project carries and no person edits, which the panel leaves out. */
43
+ const technical = new Set(['id', 'organization', 'createdAt', 'updatedAt'])
44
+
45
+ /**
46
+ * Who a write ran as, as a relationship to the provider's people, so the panel
47
+ * names them through the same read a relationship cell does: the active
48
+ * organization's members by their name, and anyone that read does not answer as
49
+ * someone.
50
+ */
51
+ const actorField = { name: 'actor', kind: 'relationship', relationTo: 'user', label: {}, rules: {}, component: 'relationship', cell: 'relationship' } as unknown as ClientField
52
+
53
+ /** A record of one kayan, as a relationship to it, which titles the record each entry of the Activity page names. */
54
+ const recordField = (kayan: string) => ({ name: `record:${kayan}`, kind: 'relationship', relationTo: kayan, label: {}, rules: {} }) as unknown as ClientField
55
+
56
+ /**
57
+ * What the sink writes for a command: the command, then the operating-system user
58
+ * in parentheses. A user's id holds no space, so no person is read as one.
59
+ */
60
+ const commandOf = /^(.+?) \((.+)\)$/
61
+
62
+ const changesOf = (entry: Entry): [string, Change][] => Object.entries(entry.changes ?? {})
63
+
64
+ const units: [Intl.RelativeTimeFormatUnit, number][] = [['year', 31_536_000], ['month', 2_592_000], ['week', 604_800], ['day', 86_400], ['hour', 3_600], ['minute', 60], ['second', 1]]
65
+
66
+ /** How long ago, in the viewer's own locale, in the largest unit that is at least one. */
67
+ export const relative = ({ at, locale, now = Date.now() }: { at: Date; locale: string; now?: number }) => {
68
+ const seconds = Math.round((at.getTime() - now) / 1000)
69
+ const [unit, size] = units.find(([, size]) => Math.abs(seconds) >= size) ?? units.at(-1)!
70
+ return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(Math.round(seconds / size), unit)
71
+ }
72
+
73
+ type EntriesProps = {
74
+ entries: readonly Entry[]
75
+ /** The viewer's own id, whom an entry that names them calls "You", without a read of the people. */
76
+ viewer?: string
77
+ /** Whether the viewer reads the provider's people, which is what lets a command's operating-system user be named. */
78
+ people?: boolean
79
+ /** The kayan every entry is of, where one record's entries are drawn. */
80
+ kayan?: ClientKayan
81
+ /** Every kayan the screen draws, which an entry of any kayan is drawn by where no one kayan is given. */
82
+ kayans?: Readonly<Record<string, ClientKayan | undefined>>
83
+ /** What names the record an entry is of, on a page of many records' entries. */
84
+ record?: (entry: Entry, kayan: ClientKayan | undefined) => ReactNode
85
+ titles?: RelationshipTitles
86
+ t: Translate
87
+ locale: string
88
+ }
89
+
90
+ /** The entries drawn, with nothing read: what the panel shows once a page has come back. */
91
+ export function ActivityEntries({ entries, kayan: one, kayans, record, titles, t, locale, viewer, people }: EntriesProps) {
92
+ const copy = { t, locale }
93
+ const { cells } = useComponents()
94
+ // A person the title read did not name, one the reader may not read among
95
+ // them, is someone, never the raw id.
96
+ const named = (id: string | null | undefined) => {
97
+ if (!id) return t('auditLog.system', 'The system') ?? 'The system'
98
+ if (id === viewer) return t('auditLog.you', 'You') ?? 'You'
99
+ // A command's actor, which the sink writes as one string, and which no
100
+ // people read names: said in the viewer's locale to a reader of the
101
+ // people, with each part isolated so the Arabic sentence keeps the
102
+ // command's own direction.
103
+ const command = people ? commandOf.exec(id) : null
104
+ if (command) return (t('auditLog.command', 'the command {command} ({user})') ?? '').replace('{command}', () => `⁨${command[1]}⁩`).replace('{user}', () => `⁨${command[2]}⁩`)
105
+ const title = titles?.get(`user:${id}`)
106
+ return typeof title === 'string' ? title : (t('auditLog.someone', 'Someone') ?? 'Someone')
107
+ }
108
+ return (
109
+ <ol className="grid gap-4 text-sm">
110
+ {entries.map((entry) => {
111
+ const kayan = one ?? kayans?.[entry.kayan ?? '']
112
+ // A cut change holds JSON text, which no cell of the field's kind reads.
113
+ const drawn = (field: ClientField, value: unknown, change: Change): ReactNode =>
114
+ empty(value) ? '—' : change.truncated
115
+ ? <code dir="ltr" className="break-all font-mono">{String(value)}</code>
116
+ : cellFor({ cells, field })({ kayan: kayan!, field, value, t, locale, titles })
117
+ const at = new Date(entry.createdAt)
118
+ // Every operation has its words in the log's own scope, in the past
119
+ // tense the sentence reads in, and one a project adds before it
120
+ // writes them falls back to the operation's own label.
121
+ const action = t(`auditLog.action.${entry.operation}`, kayan ? operationLabelFor({ kayan, operation: { name: entry.operation }, copy }) : entry.operation) ?? entry.operation
122
+ // Staff writing inside a member's session is named first, as the member.
123
+ const actor = entry.impersonatedBy
124
+ ? (t('auditLog.impersonated', '{staff} as {member}') ?? '{staff} as {member}').replace('{staff}', () => named(entry.impersonatedBy)).replace('{member}', () => named(entry.actor))
125
+ : named(entry.actor)
126
+ // A create says what the person set: a field left empty, or a box left off, is nothing they did.
127
+ const made = entry.operation === 'create'
128
+ // In the order the kayan declares its fields: jsonb keeps its keys in an order of its own.
129
+ const shown = (kayan?.fields ?? []).flatMap((field) => {
130
+ const change = technical.has(field.name) || !Object.hasOwn(entry.changes ?? {}, field.name) ? undefined : entry.changes[field.name]
131
+ return change && !(made && (empty(change.to) || change.to === false)) ? [{ field, change }] : []
132
+ })
133
+ return (
134
+ <li key={entry.id} data-entry={entry.operation} className="grid grid-cols-[2rem_minmax(0,1fr)] gap-3">
135
+ <Avatar className="mt-0.5 size-8">
136
+ <AvatarFallback>{(named(entry.impersonatedBy ?? entry.actor).trim()[0] ?? '?').toUpperCase()}</AvatarFallback>
137
+ </Avatar>
138
+ <div className="min-w-0">
139
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
140
+ <p data-event className="font-medium">
141
+ {(viewer && entry.actor === viewer && !entry.impersonatedBy ? (t('auditLog.eventYou', '{action} by You') ?? '{action} by You') : (t('auditLog.event', '{action} by {actor}') ?? '{action} by {actor}')).replace('{action}', () => action).replace('{actor}', () => actor)}
142
+ </p>
143
+ <time dateTime={at.toISOString()} className="text-muted-foreground shrink-0 text-xs">
144
+ {relative({ at, locale })}
145
+ </time>
146
+ </div>
147
+ {record ? <p data-record className="text-xs">{record(entry, kayan)}</p> : null}
148
+ <p className="text-muted-foreground text-xs">
149
+ {formatDate({ value: at, locale })}
150
+ </p>
151
+ {shown.length ? (
152
+ <ul className="text-muted-foreground mt-1.5 grid gap-1">
153
+ {shown.map(({ field, change }) => (
154
+ <li key={field.name} data-change={field.name} className="break-words">
155
+ <span className="text-foreground">{labelFor({ kayan: kayan!, field, copy })}</span>
156
+ {': '}
157
+ {made ? null : (
158
+ <>
159
+ <span data-from>{drawn(field, change.from, change)}</span>
160
+ <span aria-hidden className="mx-1 inline-block rtl:rotate-180">→</span>
161
+ </>
162
+ )}
163
+ <span data-to>{drawn(field, change.to, change)}</span>
164
+ {change.truncated ? <span data-truncated className="block">{t('auditLog.truncated', 'Too long to keep whole; its start is shown.')}</span> : null}
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ ) : (
169
+ <p className="text-muted-foreground mt-1.5">{t('auditLog.none', 'No field you may read changed.')}</p>
170
+ )}
171
+ </div>
172
+ </li>
173
+ )
174
+ })}
175
+ </ol>
176
+ )
177
+ }
178
+
179
+ /** The five actions the Activity filters by, each the `where` it reads the log by. */
180
+ export const actionWheres = {
181
+ created: { operation: { equals: 'create' } },
182
+ updated: { operation: { in: ['update', 'updateMany'] } },
183
+ deleted: { operation: { in: ['delete', 'deleteMany'] } },
184
+ published: { operation: { equals: 'publish' } },
185
+ restored: { operation: { equals: 'restoreVersion' } },
186
+ } as const
187
+ export type ActionName = keyof typeof actionWheres
188
+
189
+ /** What the Activity is filtered by, every key a string a link can carry. */
190
+ export type ActivityFilters = {
191
+ kayan?: string
192
+ record?: string
193
+ /** Text any changed value holds. */
194
+ search?: string
195
+ /** The person who wrote. */
196
+ actor?: string
197
+ /** The column that changed. */
198
+ field?: string
199
+ action?: string
200
+ /** The first and last day, as a date box writes them, each read in the viewer's own time zone. */
201
+ from?: string
202
+ to?: string
203
+ }
204
+ export type ActivityQuery = ActivityFilters & { page: number; limit: number }
205
+
206
+ const filterKeys = ['kayan', 'record', 'search', 'actor', 'field', 'action', 'from', 'to'] as const
207
+
208
+ /** The start of a day in the viewer's own time zone, and of the day after it. */
209
+ const dayStart = (day: string, after = 0) => {
210
+ const at = new Date(`${day}T00:00:00`)
211
+ at.setDate(at.getDate() + after)
212
+ return Number.isNaN(at.getTime()) ? undefined : at.toISOString()
213
+ }
214
+
215
+ /** The log's `where` for these filters, or nothing where none is set. */
216
+ export const whereOf = (filters: ActivityFilters) => {
217
+ const from = filters.from ? dayStart(filters.from) : undefined
218
+ const to = filters.to ? dayStart(filters.to, 1) : undefined
219
+ const parts = [
220
+ filters.kayan && { kayan: { equals: filters.kayan } },
221
+ filters.record && { record: { equals: filters.record } },
222
+ filters.search?.trim() && { changes: { contains: filters.search.trim() } },
223
+ filters.actor && { actor: { equals: filters.actor } },
224
+ filters.field && { [`changes.${filters.field}`]: { exists: true } },
225
+ filters.action && Object.hasOwn(actionWheres, filters.action) && actionWheres[filters.action as ActionName],
226
+ from && { createdAt: { greater_than_equal: from } },
227
+ to && { createdAt: { less_than: to } },
228
+ ].filter((part) => !!part)
229
+ return parts.length ? { and: parts } : undefined
230
+ }
231
+
232
+ /**
233
+ * The filters a link carries, each the key it is named by, and anything else left out. A person
234
+ * is left out for a reader of no people, whose read of the log refuses any filter by one.
235
+ */
236
+ export const filtersOf = ({ search, people }: { search: string; people: boolean }): ActivityQuery => {
237
+ const params = new URLSearchParams(search)
238
+ if (!people) params.delete('actor')
239
+ const page = Number.parseInt(params.get('page') ?? '', 10)
240
+ const limit = Number.parseInt(params.get('limit') ?? '', 10)
241
+ return {
242
+ ...Object.fromEntries(filterKeys.flatMap((key) => (params.get(key) ? [[key, params.get(key)!]] : []))),
243
+ page: page > 0 ? page : 1,
244
+ limit: limit > 0 ? limit : 10,
245
+ }
246
+ }
247
+
248
+ /** Every kayan this screen draws, keyed by name, and the typed client's read of each for the titles. */
249
+ const useReads = () => {
250
+ const screen = useScreen()
251
+ const client = useClient()
252
+ const kayans = useMemo(() => Object.values(screen.kayan), [screen])
253
+ // The client is left out of the dependencies: the host makes a new one on
254
+ // every render of the page, and each posts to the same route.
255
+ const api = useMemo<FindApi>(
256
+ () => Object.fromEntries(kayans.map((one) => [one.name, { find: (input: Row) => client({ kayan: one.name, operation: 'find', input }) as Promise<FoundPage> }])),
257
+ [kayans],
258
+ )
259
+ return { kayans, api }
260
+ }
261
+
262
+ /** Every person and value an entry names, as rows the title read groups by the kayan each points at. */
263
+ const namedIn = (entries: readonly Entry[]) =>
264
+ entries.flatMap((entry) => [
265
+ { actor: [entry.actor, entry.impersonatedBy].filter(Boolean) },
266
+ ...changesOf(entry).flatMap(([name, change]) => [{ [name]: change.from }, { [name]: change.to }]),
267
+ ])
268
+
269
+ /**
270
+ * The panel a page names as a component node: a record's last few entries. It
271
+ * reads the row the page draws and the kayan it belongs to off the screen, so
272
+ * one file serves every kayan's edit page, and links the rest on the Activity
273
+ * page, filtered to this record.
274
+ */
275
+ export function Activity(_: ComponentProps) {
276
+ const { t, locale } = useLocale()
277
+ const kayan = useKayan()
278
+ const record = useRecord()
279
+ const client = useClient()
280
+ const screen = useScreen()
281
+ const viewer = useUser().id
282
+ const people = useAccess().collections?.user?.read === true
283
+ const id = record?.id === undefined ? undefined : String(record.id)
284
+ // The row as this page last drew it, and every save the form beside it
285
+ // landed: a save, a draft, a publish or an autosave writes an entry, and the
286
+ // panel reads its entries again. A draft save moves no `updatedAt` of the row
287
+ // the page holds, so the count is what says it landed.
288
+ const saves = useContext(FormContext)?.saves ?? 0
289
+ const seen = `${id}@${String(record?.updatedAt ?? '')}@${saves}`
290
+ const [read, setRead] = useState<{ seen: string; docs: Entry[]; more: boolean } | { seen: string; failed: true }>()
291
+
292
+ useEffect(() => {
293
+ if (!id) return
294
+ let live = true
295
+ const where = { and: [{ kayan: { equals: kayan.name } }, { record: { equals: id } }] }
296
+ ;(client({ kayan: 'auditLog', operation: 'find', input: { where, sort: '-createdAt', limit: latest, page: 1, depth: 0 } }) as Promise<FoundPage>).then(
297
+ (found) => live && setRead({ seen, docs: found.docs as Entry[], more: found.hasNextPage }),
298
+ () => live && setRead({ seen, failed: true }),
299
+ )
300
+ return () => {
301
+ live = false
302
+ }
303
+ }, [kayan.name, seen])
304
+
305
+ const { kayans, api } = useReads()
306
+ const shown = read?.seen === seen ? read : undefined
307
+ const entries = shown && 'docs' in shown ? shown.docs : []
308
+ const titles = useRelationshipTitles({ fields: [...kayan.fields, actorField], docs: namedIn(entries), context: { api, client: kayans, locale } })
309
+ // The Activity page, where the screen has one, which the rest of this record's entries are on.
310
+ const page = screen.pages.activity?.path
311
+ const all = page && id ? `${screen.path}${page}?${new URLSearchParams({ kayan: kayan.name, record: id })}` : undefined
312
+
313
+ return (
314
+ <Card data-panel="activity">
315
+ <CardHeader>
316
+ <CardTitle>{t('auditLog.activity', 'Activity')}</CardTitle>
317
+ </CardHeader>
318
+ <CardContent>
319
+ {shown === undefined ? (
320
+ <p className="text-muted-foreground text-sm">{t('auditLog.loading', 'Loading activity…')}</p>
321
+ ) : 'failed' in shown ? (
322
+ <p className="text-destructive text-sm">{t('auditLog.failed', 'The activity could not be read.')}</p>
323
+ ) : !entries.length ? (
324
+ <p className="text-muted-foreground text-sm">{t('auditLog.empty', 'Nothing has changed here yet.')}</p>
325
+ ) : (
326
+ <div className="grid gap-4">
327
+ <ActivityEntries entries={entries} kayan={kayan} titles={titles} t={t} locale={locale} viewer={viewer} people={people} />
328
+ {shown.more && all ? (
329
+ <a data-all-activity href={all} className="text-foreground text-sm underline underline-offset-4 max-sm:inline-flex max-sm:min-h-11 max-sm:items-center">
330
+ {t('auditLog.all', 'All activity')}
331
+ </a>
332
+ ) : null}
333
+ </div>
334
+ )}
335
+ </CardContent>
336
+ </Card>
337
+ )
338
+ }
339
+
340
+ /** One filter's label above its box, the label naming the box for a screen reader. */
341
+ function Labelled({ id, label, children }: { id: string; label: string; children: ReactNode }) {
342
+ return (
343
+ <div className="grid min-w-0 gap-1.5">
344
+ <Label htmlFor={id} className="text-xs">{label}</Label>
345
+ {children}
346
+ </div>
347
+ )
348
+ }
349
+
350
+ /** A choice among `options`, or none: shadcn's select, its trigger naming what is chosen in words. */
351
+ function Choice({ id, value, options, any, onChange }: { id: string; value: string | undefined; options: readonly { value: string; label: string }[]; any: string; onChange: (value: string | undefined) => void }) {
352
+ return (
353
+ <Select value={value ?? ''} onValueChange={(next: string | null) => onChange(next || undefined)}>
354
+ <SelectTrigger id={id} className="w-full max-sm:data-[size=default]:h-11">
355
+ <SelectValue>{options.find((one) => one.value === value)?.label ?? any}</SelectValue>
356
+ </SelectTrigger>
357
+ <SelectContent>
358
+ <SelectItem value="">{any}</SelectItem>
359
+ {options.map((one) => (
360
+ <SelectItem key={one.value} value={one.value}>{one.label}</SelectItem>
361
+ ))}
362
+ </SelectContent>
363
+ </Select>
364
+ )
365
+ }
366
+
367
+ /**
368
+ * The Activity: entries newest first, searched, filtered and paged on the
369
+ * server. On one record (`kayan` and `record` given) it filters by person,
370
+ * column, action and day; across the organization by the kayan too, and each
371
+ * entry names its record with a link to it. It is read-only: nothing here
372
+ * writes, and the log declares no write a caller could run.
373
+ */
374
+ export function ActivityLog({ kayan: fixed, record, query, onQuery, refresh = 0 }: {
375
+ kayan?: ClientKayan
376
+ record?: string
377
+ query: ActivityQuery
378
+ onQuery: (query: ActivityQuery) => void
379
+ /** A count that reads the page again when it moves, such as the saves of the form beside it. */
380
+ refresh?: number
381
+ }) {
382
+ const { t, locale } = useLocale()
383
+ const client = useClient()
384
+ const screen = useScreen()
385
+ const viewer = useUser().id
386
+ const access = useAccess()
387
+ const people = access.collections?.user?.read === true
388
+ const copy = { t, locale }
389
+ const { kayans, api } = useReads()
390
+ const ids = { person: useId(), kayan: useId(), field: useId(), action: useId(), from: useId(), to: useId() }
391
+ const filters = fixed ? { ...query, kayan: fixed.name, record } : query
392
+ const asked = JSON.stringify([whereOf(filters), query.page, query.limit, refresh])
393
+ const [read, setRead] = useState<{ asked: string; page: FoundPage & PageInfo } | { asked: string; failed: true }>()
394
+ useEffect(() => {
395
+ let live = true
396
+ ;(client({ kayan: 'auditLog', operation: 'find', input: { where: whereOf(filters), sort: '-createdAt', limit: query.limit, page: query.page, depth: 0 } }) as Promise<FoundPage & PageInfo>).then(
397
+ (page) => live && setRead({ asked, page }),
398
+ () => live && setRead({ asked, failed: true }),
399
+ )
400
+ return () => {
401
+ live = false
402
+ }
403
+ }, [asked])
404
+
405
+ const shown = read?.asked === asked ? read : undefined
406
+ const entries = shown && 'page' in shown ? (shown.page.docs as Entry[]) : []
407
+ // A filter changed reads from the first page again, as a list's does.
408
+ const refine = (next: ActivityFilters) => onQuery({ ...query, ...next, page: 1 })
409
+ const any = t('auditLog.any', 'Any') ?? 'Any'
410
+ const kayan = fixed ?? screen.kayan[query.kayan ?? '']
411
+ // The kayan the viewer lists, which are the kayan whose entries it reads.
412
+ const listed = screen.nav.flatMap((entry) => (entry.kayan && screen.kayan[entry.kayan] ? [screen.kayan[entry.kayan]!] : []))
413
+ const fields = kayan ? kayan.fields.filter((field) => !technical.has(field.name) && field.kind !== 'join') : []
414
+ const filtered = filterKeys.some((key) => key !== 'kayan' && key !== 'record' ? query[key] : !fixed && query[key])
415
+ // Each record an entry names, titled by its own kayan's read.
416
+ const recordFields = [...new Set(entries.map((entry) => entry.kayan ?? ''))].filter((name) => screen.kayan[name]).map(recordField)
417
+ const titles = useRelationshipTitles({
418
+ fields: [...(kayan?.fields ?? []), ...(fixed ? [] : Object.values(screen.kayan).flatMap((one) => one?.fields ?? [])), actorField, ...recordFields],
419
+ docs: [...namedIn(entries), ...(fixed ? [] : entries.map((entry) => ({ [`record:${entry.kayan}`]: entry.record })))],
420
+ context: { api, client: kayans, locale },
421
+ })
422
+ const recordOf = (entry: Entry, of: ClientKayan | undefined) => {
423
+ if (!of || !entry.record) return null
424
+ const row = titles.get(rowKey(of.name, entry.record))
425
+ const name = row && typeof row === 'object' && !(row instanceof Error) ? rowTitle({ kayan: of, row: row as Row, locale }) ?? titleOf({ kayan: of, copy }) : row === true ? missingRow({ t, gone: true }) : titleOf({ kayan: of, copy })
426
+ const edit = of.pages.edit?.path
427
+ const said = `${titleOf({ kayan: of, copy })}: ${name}`
428
+ // A record gone, or one with no page to open, is named and not linked.
429
+ return edit && row !== true ? <a href={`${screen.path}${edit.replace(':id', encodeURIComponent(entry.record))}`} className="text-foreground underline underline-offset-4">{said}</a> : <span className="text-foreground">{said}</span>
430
+ }
431
+
432
+ return (
433
+ <section data-activity-log className="grid min-w-0 gap-4">
434
+ <div role="group" aria-label={t('auditLog.filters', 'Filter the activity') ?? 'Filter the activity'} className="grid gap-3">
435
+ <Search value={query.search} fields={[t('auditLog.searchChanges', 'changed values') ?? 'changed values']} locale={locale} t={t} onSearch={(search) => refine({ search })} />
436
+ {/* The server searches a change's text only in the kayan whose every column the reader reads,
437
+ and a reader graded column by column may read some kayan in part: the page holds no list of
438
+ the columns it does not read, so it says so rather than guessing which. */}
439
+ {Array.isArray(access.permissions) ? <p data-search-limited className="text-muted-foreground text-xs">{t('auditLog.searchLimited', 'The search covers the records whose every field you may read.')}</p> : null}
440
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
441
+ {people && screen.kayan.auditLog ? (
442
+ <Labelled id={ids.person} label={t('auditLog.person', 'Person') ?? 'Person'}>
443
+ <RelationshipControl
444
+ id={ids.person}
445
+ field={actorField}
446
+ kayan={screen.kayan.auditLog}
447
+ client={kayans}
448
+ api={api}
449
+ value={query.actor ?? null}
450
+ values={{}}
451
+ setValue={() => {}}
452
+ onChange={(actor) => refine({ actor: typeof actor === 'string' ? actor : undefined })}
453
+ readOnly={false}
454
+ t={t}
455
+ locale={locale}
456
+ />
457
+ </Labelled>
458
+ ) : null}
459
+ {fixed ? null : (
460
+ <Labelled id={ids.kayan} label={t('auditLog.type', 'Record type') ?? 'Record type'}>
461
+ <Choice id={ids.kayan} value={query.kayan} any={any} options={listed.map((one) => ({ value: one.name, label: titleOf({ kayan: one, copy, many: true }) }))} onChange={(next) => refine({ kayan: next, field: undefined, record: undefined })} />
462
+ </Labelled>
463
+ )}
464
+ <Labelled id={ids.field} label={t('auditLog.field', 'Field') ?? 'Field'}>
465
+ <Choice id={ids.field} value={query.field} any={any} options={fields.map((field) => ({ value: field.name, label: labelFor({ kayan: kayan!, field, copy }) }))} onChange={(field) => refine({ field })} />
466
+ </Labelled>
467
+ <Labelled id={ids.action} label={t('auditLog.actionFilter', 'Action') ?? 'Action'}>
468
+ <Choice id={ids.action} value={query.action} any={any} options={Object.keys(actionWheres).map((name) => ({ value: name, label: t(`auditLog.filter.${name}`, name) ?? name }))} onChange={(action) => refine({ action })} />
469
+ </Labelled>
470
+ {(['from', 'to'] as const).map((edge) => (
471
+ <Labelled key={edge} id={ids[edge]} label={t(`auditLog.${edge}`, edge) ?? edge}>
472
+ <DatePicker
473
+ id={ids[edge]}
474
+ field={{ name: edge, kind: 'date', label: { [locale]: t(`auditLog.${edge}`, edge) ?? edge }, rules: {} } as unknown as ClientField}
475
+ kayan={screen.kayan.auditLog!}
476
+ value={query[edge] ?? null}
477
+ values={{}}
478
+ setValue={() => {}}
479
+ onChange={(day) => refine({ [edge]: typeof day === 'string' ? day : undefined })}
480
+ readOnly={false}
481
+ t={t}
482
+ locale={locale}
483
+ />
484
+ </Labelled>
485
+ ))}
486
+ </div>
487
+ {filtered ? (
488
+ <div className="flex flex-wrap items-center gap-2">
489
+ {!fixed && query.record ? <p className="text-muted-foreground text-sm">{t('auditLog.oneRecord', 'One record\'s activity.')}</p> : null}
490
+ <Button type="button" variant="outline" size="sm" className="max-sm:h-11" onClick={() => onQuery({ page: 1, limit: query.limit })}>
491
+ {t('auditLog.clear', 'Clear filters')}
492
+ </Button>
493
+ </div>
494
+ ) : null}
495
+ </div>
496
+ {/* One short line a screen reader hears as a read lands, never the whole list. */}
497
+ <p aria-live="polite" className={`text-sm ${shown && 'failed' in shown ? 'text-destructive' : 'text-muted-foreground'}`}>
498
+ {shown === undefined
499
+ ? t('auditLog.loading', 'Loading activity…')
500
+ : 'failed' in shown
501
+ ? t('auditLog.failed', 'The activity could not be read.')
502
+ : !entries.length
503
+ ? filtered ? t('auditLog.nothing', 'No activity matches these filters.') : t('auditLog.empty', 'Nothing has changed here yet.')
504
+ : (t('auditLog.found', 'Entries found: {count}') ?? 'Entries found: {count}').replace('{count}', () => shown.page.totalDocs.toLocaleString(locale))}
505
+ </p>
506
+ {entries.length ? <ActivityEntries entries={entries} kayan={fixed} kayans={screen.kayan} record={fixed ? undefined : recordOf} titles={titles} t={t} locale={locale} viewer={viewer} people={people} /> : null}
507
+ {shown && 'page' in shown && shown.page.totalDocs > 0 ? (
508
+ <Pagination page={shown.page} t={t} locale={locale} onPage={(page) => onQuery({ ...query, page })} onLimit={(limit) => onQuery({ ...query, limit, page: 1 })} />
509
+ ) : null}
510
+ </section>
511
+ )
512
+ }
513
+
514
+ /** The Activity tab of a record's edit page: the record's whole history, beside its Versions. */
515
+ export function ActivityTab(_: ComponentProps) {
516
+ const kayan = useKayan()
517
+ const record = useRecord()
518
+ const saves = useContext(FormContext)?.saves ?? 0
519
+ const [query, setQuery] = useState<ActivityQuery>({ page: 1, limit: 10 })
520
+ return record?.id === undefined ? null : (
521
+ <Card>
522
+ <CardContent>
523
+ <ActivityLog kayan={kayan} record={String(record.id)} query={query} onQuery={setQuery} refresh={saves} />
524
+ </CardContent>
525
+ </Card>
526
+ )
527
+ }