create-mithaq 0.1.1 → 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 (348) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/index.js +457 -63
  3. package/package.json +1 -1
  4. package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
  5. package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
  6. package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
  7. package/template/apps/asas-backoffice/src/components.ts +35 -0
  8. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  9. package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
  10. package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
  11. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  12. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  13. package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
  14. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  15. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  16. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  17. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
  18. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
  19. package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
  20. package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
  21. package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
  22. package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
  23. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  24. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  25. package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
  26. package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
  27. package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
  28. package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
  29. package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
  30. package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
  31. package/template/apps/asas-backoffice/src/server.ts +30 -0
  32. package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
  33. package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
  34. package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
  35. package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
  36. package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
  37. package/template/apps/asas-tanstack/package.json +4 -2
  38. package/template/apps/asas-tanstack/src/api.ts +1 -1
  39. package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
  40. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  41. package/template/apps/asas-tanstack/src/components.ts +35 -0
  42. package/template/apps/asas-tanstack/src/copy.ts +416 -79
  43. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  44. package/template/apps/asas-tanstack/src/links.ts +1 -1
  45. package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
  46. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  47. package/template/apps/asas-tanstack/src/redirect.ts +20 -1
  48. package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
  49. package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
  50. package/template/apps/asas-tanstack/src/router.tsx +12 -1
  51. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
  52. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
  53. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
  54. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
  55. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
  56. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
  57. package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
  58. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
  59. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  60. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
  61. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
  62. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
  63. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  64. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
  65. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
  66. package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
  67. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
  68. package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
  69. package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
  70. package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
  71. package/template/apps/asas-tanstack/src/screens.tsx +46 -26
  72. package/template/apps/asas-tanstack/src/server.ts +10 -0
  73. package/template/apps/asas-tanstack/src/slide.ts +1 -1
  74. package/template/apps/asas-tanstack/src/styles.css +7 -3
  75. package/template/apps/asas-tanstack/tsconfig.json +2 -2
  76. package/template/apps/asas-tanstack/vite.config.ts +7 -1
  77. package/template/package.json +3 -3
  78. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  79. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  80. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  81. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  82. package/template/templates/asas/.env.example +22 -5
  83. package/template/templates/asas/AGENTS.md +101 -0
  84. package/template/templates/asas/CLAUDE.md +1 -0
  85. package/template/templates/asas/README.md +1056 -0
  86. package/template/templates/asas/package.json +19 -4
  87. package/template/templates/asas/src/asas.ts +81 -56
  88. package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
  89. package/template/templates/asas/src/dev-setup.ts +112 -0
  90. package/template/templates/asas/src/local-database.ts +22 -0
  91. package/template/templates/asas/src/messages/account.json +129 -103
  92. package/template/templates/asas/src/messages/auditLog.json +223 -105
  93. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  94. package/template/templates/asas/src/messages/courses.json +256 -214
  95. package/template/templates/asas/src/messages/enrollments.json +45 -27
  96. package/template/templates/asas/src/messages/invitation.json +72 -56
  97. package/template/templates/asas/src/messages/localUsers.json +34 -28
  98. package/template/templates/asas/src/messages/media.json +31 -21
  99. package/template/templates/asas/src/messages/member.json +59 -39
  100. package/template/templates/asas/src/messages/memberRoles.json +37 -31
  101. package/template/templates/asas/src/messages/mithaq.json +206 -162
  102. package/template/templates/asas/src/messages/organization.json +63 -41
  103. package/template/templates/asas/src/messages/organizationRole.json +59 -47
  104. package/template/templates/asas/src/messages/roles.json +59 -45
  105. package/template/templates/asas/src/messages/session.json +99 -79
  106. package/template/templates/asas/src/messages/sessions.json +110 -80
  107. package/template/templates/asas/src/messages/settings.json +51 -53
  108. package/template/templates/asas/src/messages/tags.json +31 -21
  109. package/template/templates/asas/src/messages/user.json +109 -87
  110. package/template/templates/asas/src/messages/verification.json +59 -47
  111. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  112. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  113. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  114. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  115. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  116. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  117. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  118. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  119. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  120. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  121. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  122. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  123. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  124. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  125. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  126. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  127. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  128. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  129. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  130. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  131. package/template/templates/asas/src/migrations/schema.ts +1 -0
  132. package/template/templates/asas/src/mithaq.ts +4 -406
  133. package/template/templates/asas/src/organizations.ts +3 -0
  134. package/template/templates/asas/src/seed.ts +147 -45
  135. package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
  136. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  137. package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
  138. package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
  139. package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
  140. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
  141. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  142. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  143. package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
  144. package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
  145. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  146. package/template/templates/asas/src/ui-portal/home.tsx +9 -13
  147. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  148. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  149. package/template/templates/features/audit-log/index.ts +4 -0
  150. package/template/templates/features/audit-log/package.json +13 -0
  151. package/template/templates/features/auth/index.ts +11 -0
  152. package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
  153. package/template/templates/{auth → features/auth}/package.json +2 -3
  154. package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
  155. package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
  156. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
  157. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
  158. package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
  159. package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
  160. package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
  161. package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
  162. package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
  163. package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
  164. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
  165. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
  166. package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
  167. package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
  168. package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
  169. package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
  170. package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
  171. package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
  172. package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
  173. package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
  174. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  175. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  176. package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
  177. package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
  178. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  179. package/template/templates/features/courses/domain/archive.ts +39 -0
  180. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  181. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  182. package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
  183. package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
  184. package/template/templates/features/courses/index.ts +8 -0
  185. package/template/templates/features/courses/package.json +24 -0
  186. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  187. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  188. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  189. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  190. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  191. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  192. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  193. package/template/templates/features/courses/sessions/seats.ts +42 -0
  194. package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
  195. package/template/templates/features/media/index.ts +10 -0
  196. package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
  197. package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
  198. package/template/templates/features/media/package.json +19 -0
  199. package/template/templates/features/roles/index.ts +4 -0
  200. package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
  201. package/template/templates/features/roles/package.json +21 -0
  202. package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
  203. package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
  204. package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
  205. package/template/templates/features/settings/index.ts +3 -0
  206. package/template/templates/features/settings/package.json +13 -0
  207. package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
  208. package/template/templates/features/tags/index.ts +3 -0
  209. package/template/templates/features/tags/package.json +14 -0
  210. package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
  211. package/template/templates/mithaq/package.json +33 -0
  212. package/template/templates/mithaq/src/mail/index.ts +32 -0
  213. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  214. package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
  215. package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
  216. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  217. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  218. package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
  219. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  220. package/template/templates/mithaq/src/mithaq.ts +593 -0
  221. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  222. package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
  223. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  224. package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
  225. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  226. package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
  227. package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
  228. package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
  229. package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
  230. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  231. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  232. package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
  233. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  234. package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
  235. package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
  236. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
  237. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
  238. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
  239. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
  240. package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
  241. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
  242. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
  243. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
  244. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
  245. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
  246. package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
  247. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
  248. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
  249. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
  250. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
  251. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
  252. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
  253. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
  254. package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
  255. package/template/apps/asas-platform/src/redirect.ts +0 -1
  256. package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
  257. package/template/apps/asas-platform/src/router.tsx +0 -7
  258. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
  259. package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
  260. package/template/apps/asas-platform/src/server.ts +0 -20
  261. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
  262. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
  263. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
  264. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
  265. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
  266. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
  267. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
  268. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
  269. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
  270. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
  271. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
  272. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
  273. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
  274. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
  275. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
  276. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
  277. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
  278. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
  279. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
  280. package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
  281. package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
  282. package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
  283. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
  284. package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
  285. package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
  286. package/template/templates/asas/src/rich-text-nodes.ts +0 -11
  287. package/template/templates/asas/src/roles.ts +0 -16
  288. package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
  289. package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
  290. package/template/templates/auth/src/index.ts +0 -690
  291. /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
  292. /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
  293. /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
  294. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
  295. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
  296. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
  297. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
  298. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
  299. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
  300. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
  301. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
  302. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
  303. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
  304. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
  305. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
  306. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
  307. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
  308. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
  309. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
  310. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
  311. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
  312. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
  313. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
  314. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
  315. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
  316. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
  317. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
  318. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
  319. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
  320. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
  321. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
  322. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
  323. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
  324. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
  325. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
  326. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
  327. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
  328. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
  329. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
  330. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
  331. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
  332. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
  333. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
  334. /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
  335. /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
  336. /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
  337. /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
  338. /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
  339. /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
  340. /package/template/templates/{asas → mithaq}/components.json +0 -0
  341. /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
  342. /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
  343. /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
  344. /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
  345. /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
  346. /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
  347. /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
  348. /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
@@ -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
+ }
@@ -7,6 +7,7 @@ import {
7
7
  Building2,
8
8
  CalendarDays,
9
9
  ClipboardList,
10
+ History,
10
11
  LayoutDashboard,
11
12
  Settings2,
12
13
  ShieldCheck,
@@ -21,8 +22,10 @@ import type { ShellProps } from 'mithaq/admin'
21
22
  import { useLocale, useNav, useScreen, useUser } from 'mithaq/admin/react'
22
23
  import { authArm } from '@/api.ts'
23
24
  import { locales, switchLocale } from '@/copy.ts'
24
- import { DiscardDialog } from '@/components/mithaq/form.tsx'
25
- import { NavSidebar, NavUserMenu, OrganizationSwitcher, type NavOrganization } from '@/components/mithaq/nav.tsx'
25
+ import { DiscardDialog } from 'mithaq-admin/discard-dialog.tsx'
26
+ import { NavSidebar } from 'mithaq-admin/nav.tsx'
27
+ import { NavUserMenu } from 'mithaq-admin/nav-user.tsx'
28
+ import { OrganizationSwitcher, type NavOrganization } from 'mithaq-admin/organization-switcher.tsx'
26
29
  import { buttonVariants } from '@/components/ui/button'
27
30
  import { Separator } from '@/components/ui/separator'
28
31
  import {
@@ -39,6 +42,7 @@ import {
39
42
  SidebarTrigger,
40
43
  } from '@/components/ui/sidebar'
41
44
  import { navLabel } from './screen-nav.tsx'
45
+ import { nameIn, showOrganization } from '../mithaq/auth/organizations.ts'
42
46
 
43
47
  export const regions = ['header', 'main'] as const
44
48
 
@@ -55,6 +59,8 @@ const icons: Record<string, LucideIcon> = {
55
59
  '/roles': ShieldCheck,
56
60
  '/member-roles': UserCog,
57
61
  '/settings': Settings2,
62
+ '/activity': History,
63
+ '/history': History,
58
64
  }
59
65
  export const iconOf = (path: string) => (Object.hasOwn(icons, path) ? icons[path]! : Table2)
60
66
 
@@ -84,7 +90,7 @@ export function UserMenu() {
84
90
  // Only once the provider has answered: its sign-out answers 200 with a
85
91
  // session or without one, so a refusal stays where it is. `replace`,
86
92
  // so Back does not return to the page signed out of. The sign-out that
87
- // ends an impersonation answers the platform's address, where the staff
93
+ // ends an impersonation answers the backoffice's address, where the staff
88
94
  // member goes next.
89
95
  onSignOut={() => void authArm('sign-out').then((answer) => answer.ok && window.location.replace(String(answer.body?.url ?? '/auth/sign-in')))}
90
96
  />
@@ -98,11 +104,11 @@ export function UserMenu() {
98
104
  * Switching reloads the page, because every loader reads its rows under the active organization on the server.
99
105
  */
100
106
  function Organizations() {
101
- const { t } = useLocale()
107
+ const { locale, t } = useLocale()
102
108
  const { organizations = [], activeOrganizationId } = useUser()
103
109
  return (
104
110
  <OrganizationSwitcher
105
- organizations={organizations}
111
+ organizations={organizations.map((one) => ({ ...one, name: nameIn(one, locale) }))}
106
112
  activeOrganizationId={activeOrganizationId}
107
113
  t={t}
108
114
  onSwitch={(organizationId) => void authArm('organization/set-active', { organizationId }).then((answer) => answer.ok && window.location.reload())}
@@ -183,17 +189,18 @@ const measureHeader = (header: HTMLElement | null) => {
183
189
  }
184
190
 
185
191
  /**
186
- * The one way from a tenant screen to the platform app, in the header, for an actor whose own `user.role` holds a platform role that enters it.
187
- * The link grants nothing here: the platform app's own server decides entry.
192
+ * The one way from a tenant screen to the backoffice app, in the header, for an actor whose own `user.role` holds a backoffice role that enters it.
193
+ * The link grants nothing here: the backoffice app's own server decides entry.
188
194
  * Below xl it is its icon alone, a 44 pixel square on a phone, its words kept for a screen reader, so the page's title keeps the header's width.
195
+ * A quiet ghost button at the row's end, so the page's own actions beside the title lead.
189
196
  */
190
- export function PlatformLink() {
197
+ export function BackofficeLink() {
191
198
  const { t } = useLocale()
192
- const platform = useUser().screens?.find((one) => one.name === 'platform')
193
- if (!platform) return null
199
+ const backoffice = useUser().screens?.find((one) => one.name === 'backoffice')
200
+ if (!backoffice) return null
194
201
  return (
195
- <a href={platform.path} data-platform-link="" className={buttonVariants({ variant: 'outline', size: 'sm', className: 'ms-auto max-sm:size-11 max-xl:px-2.5 max-sm:px-0 [[data-node=controls]:not(:empty)~&]:ms-0' })}>
196
- <span className="max-xl:sr-only">{t('dashboard.openPlatform', 'Open the platform')}</span>
202
+ <a href={backoffice.path} data-backoffice-link="" className={buttonVariants({ variant: 'ghost', size: 'sm', className: 'text-muted-foreground ms-auto max-sm:size-11 max-xl:px-2.5 max-sm:px-0 [[data-node=controls]:not(:empty)~&]:ms-0' })}>
203
+ <span className="max-xl:sr-only">{t('dashboard.openBackoffice', 'Open the backoffice')}</span>
197
204
  <ArrowUpRight data-icon="inline-end" className="rtl:-scale-x-100" />
198
205
  </a>
199
206
  )
@@ -211,7 +218,8 @@ export function AppShell({ regions, brand, home }: ShellProps<readonly ['header'
211
218
  the sidebar, whose props reach no element on a phone, where it is a
212
219
  sheet. */}
213
220
  <nav aria-label={t('admin.navigation', 'Navigation')} className="flex min-h-0 flex-1 flex-col">
214
- <SidebarHeader>{brand ?? <Organizations />}</SidebarHeader>
221
+ {/* With no organization shown, the app's own name sits where the switcher would. */}
222
+ <SidebarHeader>{brand ?? (!showOrganization ? <span data-brand="" className="truncate px-2 py-1.5 text-base font-semibold">{t('marketing.brand', 'Asas')}</span> : <Organizations />)}</SidebarHeader>
215
223
  <SidebarContent>
216
224
  <NavMain home={home} />
217
225
  </SidebarContent>
@@ -230,10 +238,10 @@ export function AppShell({ regions, brand, home }: ShellProps<readonly ['header'
230
238
  <SidebarTrigger aria-label={t('admin.toggleSidebar', 'Toggle Sidebar')} className="-ms-1 max-md:size-11" />
231
239
  <Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
232
240
  {regions.header}
233
- <PlatformLink />
241
+ <BackofficeLink />
234
242
  </div>
235
243
  </header>
236
- <div className="flex min-w-0 flex-1 flex-col px-4 py-4 lg:px-6 lg:py-6">
244
+ <div className="flex min-w-0 flex-1 flex-col px-4 py-4 lg:px-6">
237
245
  {/* `min-w-0` so a table wider than the page scrolls rather than
238
246
  pushing the sidebar off it. A div and not a `main`, because the
239
247
  inset around it is already the page's one main landmark. */}
@@ -245,16 +253,16 @@ export function AppShell({ regions, brand, home }: ShellProps<readonly ['header'
245
253
  }
246
254
 
247
255
  /**
248
- * The platform's frame, the dashboard's with two changes: the app's name where the organization switcher sits, since staff work above every organization and switch to none of them,
256
+ * The backoffice's frame, the dashboard's with two changes: the app's name where the organization switcher sits, since staff work above every organization and switch to none of them,
249
257
  * and the home entry named as its page names itself, so the nav and the page say one word.
250
258
  */
251
- export function PlatformShell(props: ShellProps<readonly ['header', 'main'], ReactNode>) {
259
+ export function BackofficeShell(props: ShellProps<readonly ['header', 'main'], ReactNode>) {
252
260
  const { t } = useLocale()
253
261
  return (
254
262
  <AppShell
255
263
  {...props}
256
- brand={<span data-brand="" className="truncate px-2 py-1.5 text-base font-semibold">{t('platform.brand', 'Asas platform')}</span>}
257
- home={t('platform.title', 'Platform')}
264
+ brand={<span data-brand="" className="truncate px-2 py-1.5 text-base font-semibold">{t('backoffice.brand', 'Asas backoffice')}</span>}
265
+ home={t('backoffice.title', 'Backoffice')}
258
266
  />
259
267
  )
260
268
  }