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
@@ -1,57 +1,36 @@
1
- import { defaultFields, draftFields, folderOf, invalid, versionOperations, type Ctx, type ReadTools, type WriteTools, defaultOperations } from 'mithaq'
1
+ import { defaultFields, draftFields, folderOf, invalid, type Ctx, type ReadTools, type WriteTools } from 'mithaq'
2
2
  import { z } from 'zod'
3
- import { defineKayan, organization } from '../mithaq.ts'
4
- import { activity, appPage, danger, platformList, platformRecord, topPage } from '../pages.ts'
5
- import { readsPast } from '../tenant-access.ts'
3
+ import { defineKayan, organization } from '@asas/mithaq'
4
+ import { activity, activityTab, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
5
+ import { readsPast } from '@asas/mithaq/roles/tenant-access.ts'
6
6
  import { sessions } from '../sessions/sessions.ts'
7
- import { media } from '../media/media.ts'
8
- import { onOffer } from './ui/on-offer.ts'
9
- import { expectedUpdatedAt } from '../steps/expected-updated-at.ts'
7
+ import { seatsTaken } from '../sessions/seats.ts'
8
+ import { expectedUpdatedAt } from '@asas/mithaq/steps/expected-updated-at.ts'
9
+ import { archivedRow, marks, notArchived, reason, reasonInput, unarchived } from '../domain/archive.ts'
10
10
 
11
11
  /**
12
12
  * Page pieces only this kayan draws with. What both screens share is reached
13
13
  * through `ui`, `dashboardUi` and `portalUi` from `../mithaq.ts`.
14
14
  */
15
15
  const ui = folderOf(new URL('./ui/', import.meta.url))
16
+ /** The portal's pages, which the courses feature draws for its students. */
17
+ const portalUi = folderOf(new URL('../portal/ui/', import.meta.url))
16
18
  /** What a fee column draws on every screen. */
17
19
  const feeCell = ui('fee-cell.tsx#FeeCell')
18
20
  /** What the list's Status column draws. */
19
21
  const statusCell = ui('status-cell.tsx#StatusCell')
20
22
 
21
- /**
22
- * What `archive` parses. The error messages are this project's own copy keys: a
23
- * refusal's message is the key the form resolves. Without them the form draws what zod
24
- * wrote, in English.
25
- */
26
- const archiveInput = z.object({
27
- id: z.guid(),
28
- reason: z
29
- .string({ error: 'courses.operations.archive.reason.required' })
30
- .max(200, { error: 'courses.operations.archive.reason.maxLength' }),
31
- })
32
-
33
- /** The box `archive`'s dialog asks for on every screen that draws its button. */
34
- const reason = {
35
- kind: 'text',
36
- label: { en: 'Reason', ar: 'السبب' },
37
- rules: { required: true, maxLength: 200 },
38
- } as const
39
-
40
- /**
41
- * The one step `archive` adds: it turns the operation's `reason` into the two
42
- * columns the kayan has, so `reason` never has to be a field.
43
- */
44
- const markArchived = (ctx: Ctx<z.infer<typeof archiveInput>>): Ctx => {
45
- const { reason } = ctx.data
46
- // Spread `ctx.data` to keep what the runner filled before this step, such as
47
- // `updatedAt`. `reason` reaches no column: the store step writes the kayan's own fields alone.
48
- return { ...ctx, data: { ...ctx.data, archived: true, archiveReason: reason } }
49
- }
50
-
51
23
  /** When the access code is drawn: while the form holds a live delivery, redrawn as the delivery changes. */
52
24
  const live = { delivery: { equals: 'live' } }
53
- /** When an archive button is drawn: over a row not archived yet, on every page that draws one. */
54
- const unarchived = { or: [{ 'record.archived': { exists: false } }, { 'record.archived': { equals: false } }] }
25
+ /** The edit page's header pill for an archived course, beside its title and draft status. */
26
+ const archivedBadge = { component: ui('archived-badge.tsx#ArchivedBadge'), when: archivedRow }
27
+ /** Active, Drafts, Archived and All: Active is what a list opens on, so an archived course is out of the way. */
28
+ const stateTabs = [
29
+ { tab: 'active', label: { en: 'Active', ar: 'النشطة' }, where: notArchived },
30
+ { tab: 'drafts', label: { en: 'Drafts', ar: 'المسودات' }, where: { and: [notArchived, { _status: { equals: 'draft' } }] } },
31
+ { tab: 'archived', label: { en: 'Archived', ar: 'المؤرشفة' }, where: { archived: { equals: true } } },
32
+ { tab: 'all', label: { en: 'All', ar: 'الكل' } },
33
+ ] as const
55
34
 
56
35
  /** Whether the actor runs the catalogue and so reads every course and every session. */
57
36
  export const runsCatalogue = (user: unknown): boolean => readsPast(user, 'courses.archive') || readsPast(user, 'screen.dashboard')
@@ -73,7 +52,7 @@ const archivedToArchivers = ({ user }: { user: unknown }) =>
73
52
  const details = [
74
53
  { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'cover', 'details'] },
75
54
  { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }] },
76
- { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', { field: 'accessCode', when: live }, 'notifications'] },
55
+ { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', { field: 'accessCode', when: live }, 'notifications', { field: 'selfEnroll', component: 'switch' }] },
77
56
  ] as const
78
57
  const catalogue = { section: 'catalogue', label: { en: 'Catalogue', ar: 'الدليل' }, fields: ['tags'] } as const
79
58
  /**
@@ -119,11 +98,19 @@ const createSessions = async (
119
98
  return ctx
120
99
  }
121
100
 
122
- /** What `findWithSessions` puts on the course, one entry per session it may read. */
101
+ /**
102
+ * What `findWithSessions` puts on the course, one entry per session it may
103
+ * read, with the seats it has left: `null` where it holds any number. A reader
104
+ * who sees only its own seats, a student, could not count the others'.
105
+ */
123
106
  const sessionRow = z.object({
124
107
  id: z.guid(),
125
108
  startsAt: z.date(),
126
109
  capacity: z.number().nullable(),
110
+ venue: z.string().nullish(),
111
+ format: z.string().nullish(),
112
+ archived: z.boolean().nullish(),
113
+ seatsLeft: z.number().nullable(),
127
114
  })
128
115
 
129
116
  /**
@@ -141,7 +128,13 @@ const attachSessions = async (ctx: Ctx, tools: ReadTools): Promise<Ctx> => {
141
128
  where: { course: { equals: course.id } },
142
129
  limit: 0,
143
130
  })
144
- return { ...ctx, data: { ...course, sessions: found.docs } }
131
+ // Each session was read under the reader's access, so only the seats of a session it may read are counted.
132
+ // ponytail: one count per session, a grouped count when a course holds many sessions.
133
+ const counted = await Promise.all(found.docs.map(async (one) => ({
134
+ ...one,
135
+ seatsLeft: typeof one.capacity === 'number' ? Math.max(0, one.capacity - (await seatsTaken({ session: one.id }))) : null,
136
+ })))
137
+ return { ...ctx, data: { ...course, sessions: counted } }
145
138
  }
146
139
 
147
140
  export const courses = defineKayan({
@@ -166,18 +159,18 @@ export const courses = defineKayan({
166
159
  },
167
160
  },
168
161
  targets: {
169
- /** Every organization's rows on the platform, read-only, with each row's record page and its sessions. */
170
- platform: {
162
+ /** Every organization's rows on the backoffice, read-only, with each row's record page and its sessions. */
163
+ backoffice: {
171
164
  useAsTitle: 'title',
172
165
  pages: {
173
- ...platformList('/courses', { columns: ['title', 'organization', 'fee', 'archived', 'createdAt'], filters: ['organization', 'title', 'fee', 'archived'], search: ['title'], sort: '-createdAt' }),
174
- ...platformRecord({
166
+ ...backofficeList('/courses', { columns: ['title', 'organization', 'fee', 'archived', 'createdAt'], filters: ['organization', 'title', 'fee', 'archived'], search: ['title'], sort: '-createdAt' }),
167
+ ...backofficeRecord({
175
168
  path: '/courses/:id',
176
169
  main: [{ tabs: [
177
170
  { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [
178
171
  { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'details'] },
179
172
  { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }, 'audience', 'topics'] },
180
- { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', 'accessCode', 'level', 'opensOn'] },
173
+ { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', 'accessCode', 'level', 'opensOn', 'selfEnroll'] },
181
174
  { section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['archiveReason'] },
182
175
  ] },
183
176
  { tab: 'sessions', label: { en: 'Sessions', ar: 'الجلسات' }, fields: [{ list: {
@@ -204,10 +197,9 @@ export const courses = defineKayan({
204
197
  * through are the service target's own.
205
198
  */
206
199
  service: {
207
- operations: {
208
- ...defaultOperations,
209
- // A course keeps its versions, so it answers the three that read and restore them.
210
- ...versionOperations,
200
+ // The defaults hold versions' three operations too, since a course keeps its versions.
201
+ operations: ({ defaults }) => ({
202
+ ...defaults,
211
203
  /**
212
204
  * The shipped create list with one step added and one shape widened:
213
205
  * `sessions` is part of the create signature without being a column, since
@@ -254,13 +246,26 @@ export const courses = defineKayan({
254
246
  // What a screen's button asks for beyond the row's id, said as fields: the
255
247
  // dialog draws `reason` through the control every text field uses and keys
256
248
  // its label `courses.operations.archive.reason`. `buildAsas` refuses a
257
- // field it names no key for. The portal declares the same box, because a
258
- // screen that declares none has no dialog, sends no reason and is refused.
259
- targets: { dashboard: { fields: { reason } }, portal: { fields: { reason } } },
260
- // The schema is named at the top of the file beside the step that reads it,
261
- // so one shape types both the parse and `ctx.data`.
262
- input: () => archiveInput,
263
- steps: ({ steps }) => [markArchived, steps.update],
249
+ // field it names no key for. A screen that declares none has no dialog.
250
+ targets: { dashboard: { fields: { reason } } },
251
+ input: () => reasonInput('courses.operations.archive'),
252
+ steps: ({ steps }) => [marks(true), steps.update],
253
+ },
254
+ /** Brings an archived course back into the catalogue, with the reason, which the activity keeps. */
255
+ unarchive: {
256
+ targets: { dashboard: { fields: { reason } } },
257
+ input: () => reasonInput('courses.operations.unarchive'),
258
+ steps: ({ steps }) => [marks(false), steps.update],
259
+ },
260
+ /**
261
+ * Payload's Unpublish: the course goes back to a draft, kept as it is and
262
+ * read by draft readers alone until it is published again. The caller
263
+ * sends the status it asks for, since an operation of its own keeps the
264
+ * row's status unless the call names one.
265
+ */
266
+ unpublish: {
267
+ input: ({ id }) => id.extend({ _status: z.literal('draft') }),
268
+ steps: ({ steps }) => [steps.update],
264
269
  },
265
270
  /**
266
271
  * A read list with one step of its own and an `output` written the way
@@ -273,13 +278,15 @@ export const courses = defineKayan({
273
278
  // puts that refusal back.
274
279
  input: ({ row }) => row.pick({ id: true }).strict(),
275
280
  steps: ({ steps }) => [steps.findOne, attachSessions],
276
- output: ({ row }) => row.extend({ sessions: z.array(sessionRow) }),
281
+ // Every column optional but the id: the output is read after the reader's projection, which leaves out a
282
+ // column the reader holds no key for, as a student's leaves out the access code.
283
+ output: ({ row }) => row.partial().required({ id: true }).extend({ sessions: z.array(sessionRow) }),
277
284
  },
278
- },
285
+ }),
279
286
  },
280
287
  dashboard: {
281
288
  // What this kayan is for, drawn under the list heading and the form title.
282
- // One string per declared locale, keyed `courses.description`.
289
+ // One string per declared locale, keyed `courses.page.description`.
283
290
  description: {
284
291
  en: 'Everything on offer in the catalogue, and what a seat on each costs.',
285
292
  ar: 'كل ما هو معروض في الدليل، وما يكلفه مقعد في كل منها.',
@@ -297,12 +304,12 @@ export const courses = defineKayan({
297
304
  page: appPage([{ layout: 'table', main: [{ list: {
298
305
  // `_status` says whether each row is a draft or published, since this
299
306
  // list reads every row's latest draft.
300
- columns: ['title', 'fee', 'archived', 'opensOn', '_status', 'details', 'cover'],
307
+ columns: ['title', 'fee', 'opensOn', '_status', 'details', 'cover'],
301
308
  filters: ['title', 'fee', 'archived'],
302
309
  // `details` is searched because this list names it: a rich text field
303
310
  // offers itself to no search box on its own.
304
311
  search: ['title', 'summary', 'details'],
305
- empty: 'noCourses',
312
+ empty: ui('no-courses.tsx#NoCourses'),
306
313
  // Over the ticked rows: a delete behind its question, and one dialog that
307
314
  // writes the fields it names over every one of them.
308
315
  bulk: [
@@ -314,16 +321,15 @@ export const courses = defineKayan({
314
321
  body: { en: 'This cannot be undone.', ar: 'لا يمكن التراجع عن هذا.' },
315
322
  },
316
323
  },
317
- { button: 'updateMany', label: { en: 'Update', ar: 'تحديث' }, fields: ['fee', 'archived'] },
324
+ // Archived is not among them: a course leaves and comes back through its own operations.
325
+ { button: 'updateMany', label: { en: 'Update', ar: 'تحديث' }, fields: ['fee', 'notifications'] },
326
+ ],
327
+ // On each row, the edit page's own archive or unarchive, asking for its reason.
328
+ row: [
329
+ { button: 'archive', when: unarchived, label: { en: 'Archive', ar: 'أرشفة' }, fields: ['reason'] },
330
+ { button: 'unarchive', when: archivedRow, label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' }, fields: ['reason'] },
318
331
  ],
319
- // On each row not archived yet, the edit page's own archive,
320
- // asking for its reason.
321
- row: [{
322
- button: 'archive',
323
- when: unarchived,
324
- label: { en: 'Archive', ar: 'أرشفة' },
325
- fields: ['reason'],
326
- }],
332
+ tabs: stateTabs,
327
333
  } }] }]),
328
334
  },
329
335
  // The edit page's Details and Catalogue, and nothing that needs a row yet:
@@ -339,16 +345,27 @@ export const courses = defineKayan({
339
345
  // field it draws, and no id, tenant or timestamp.
340
346
  edit: {
341
347
  path: '/courses/:id',
342
- page: appPage([{ tabs: [
348
+ page: { ...appPage([{ tabs: [
343
349
  { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [{
344
350
  layout: 'record',
345
351
  main: [{ form: details }],
346
352
  // Beside the form, what was done to this row, by whom and when.
347
353
  side: [archivedCatalogue, activity],
348
- // Where a button sits is where it is drawn: the archive in the record
349
- // layout's danger zone, asking before it runs and only over a row not
350
- // archived yet, before the delete every edit page ends its danger zone with.
354
+ // Where a button sits is where it is drawn: in the record layout's danger
355
+ // zone, Unpublish over a published course, Archive over one not archived and
356
+ // Unarchive over one that is, each asking before it runs, before the delete
357
+ // every edit page ends its danger zone with.
351
358
  danger: danger({
359
+ button: 'unpublish',
360
+ when: { 'record._status': { equals: 'published' } },
361
+ label: { en: 'Unpublish', ar: 'إلغاء النشر' },
362
+ component: ui('unpublish-button.tsx#UnpublishButton'),
363
+ reload: true,
364
+ confirm: {
365
+ title: { en: 'Unpublish this course?', ar: 'إلغاء نشر هذه الدورة؟' },
366
+ body: { en: 'It goes back to a draft that no student sees, until it is published again.', ar: 'تعود مسودة لا يراها أي طالب حتى تُنشر مجددا.' },
367
+ },
368
+ }, {
352
369
  button: 'archive',
353
370
  when: unarchived,
354
371
  label: { en: 'Archive', ar: 'أرشفة' },
@@ -358,6 +375,16 @@ export const courses = defineKayan({
358
375
  title: { en: 'Archive this course?', ar: 'أرشفة هذه الدورة؟' },
359
376
  body: { en: 'It leaves the catalogue for every student.', ar: 'تخرج من الدليل لكل الطلاب.' },
360
377
  },
378
+ }, {
379
+ button: 'unarchive',
380
+ when: archivedRow,
381
+ label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' },
382
+ fields: ['reason'],
383
+ reload: true,
384
+ confirm: {
385
+ title: { en: 'Unarchive this course?', ar: 'إلغاء أرشفة هذه الدورة؟' },
386
+ body: { en: 'It is back in the catalogue as it was.', ar: 'تعود إلى الدليل كما كانت.' },
387
+ },
361
388
  }),
362
389
  }] },
363
390
  // The sessions are rows of another kayan pointing at this one: a list of
@@ -386,7 +413,9 @@ export const courses = defineKayan({
386
413
  { tab: 'versions', label: { en: 'Versions', ar: 'الإصدارات' }, fields: [
387
414
  { component: 'versions', when: { '$user.collections.courses.operations.findVersions': { equals: true } } },
388
415
  ] },
389
- ] }]),
416
+ // Beside them, the row's whole activity, searched and filtered; the side column keeps the last few.
417
+ activityTab,
418
+ ] }]), header: [{ title: {} }, archivedBadge, { controls: {} }] },
390
419
  },
391
420
  board: {
392
421
  path: '/courses/board',
@@ -395,62 +424,16 @@ export const courses = defineKayan({
395
424
  },
396
425
  },
397
426
  /**
398
- * A shorter list and form than the dashboard's: `currency` as `radio` where
399
- * the dashboard draws `select`, and `audience` as a row of buttons, from the
400
- * one declaration of each field. Its row archive asks for a reason as the
401
- * dashboard's does, and its edit page's archive is a component of the
402
- * portal's own. It lists the courses on offer alone: an archived course is
403
- * left out of its list and its home's count, and its page opens for no
404
- * student or instructor.
427
+ * The students' own pages, each drawn whole by the courses feature's portal: the catalogue of what is on
428
+ * offer, and a course's page with its sessions and, where the course lets students enroll themselves, the
429
+ * seats they take. Both read the courses that are published and not archived alone.
405
430
  */
406
431
  portal: {
407
432
  useAsTitle: 'title',
408
- baseFilter: onOffer,
409
433
  description: { en: 'The courses on offer.', ar: 'الدورات المعروضة.' },
410
434
  pages: {
411
- list: {
412
- path: '/courses',
413
- // Opens by title and 25 to a page, which the URL overrides, with
414
- // the same archive on each row not archived yet.
415
- page: topPage([{ layout: 'page', main: [{ list: {
416
- columns: ['title', 'fee'],
417
- filters: ['title'],
418
- search: ['title'],
419
- empty: ui('no-courses-on-offer.tsx#NoCourses'),
420
- sort: 'title',
421
- limit: 25,
422
- row: [{
423
- button: 'archive',
424
- when: unarchived,
425
- label: { en: 'Archive', ar: 'أرشفة' },
426
- fields: ['reason'],
427
- }],
428
- } }] }]),
429
- },
430
- // The dashboard's record layout: the form's fields in sections, and the
431
- // archive in the danger zone under it, only over a row not archived yet,
432
- // drawn by the portal's own button, which writes the reason in the viewer's locale.
433
- edit: {
434
- path: '/courses/:id',
435
- page: topPage([{
436
- layout: 'record',
437
- main: [{ form: [
438
- { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'cover', 'details'] },
439
- { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }, { field: 'audience', component: 'buttonGroup' }] },
440
- { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', { field: 'accessCode', when: live }, 'opensOn'] },
441
- ] }],
442
- // Drawn only for a viewer who may archive, so a danger zone with
443
- // nothing in it is not drawn at all.
444
- danger: [{
445
- button: 'archive',
446
- when: { and: [unarchived, { '$user.collections.courses.operations.archive': { equals: true } }] },
447
- label: { en: 'Archive', ar: 'أرشفة' },
448
- reload: true,
449
- component: 'archive',
450
- confirm: { title: { en: 'Archive this course?', ar: 'أرشفة هذه الدورة؟' }, body: { en: 'Students stop seeing it.', ar: 'يتوقف الطلاب عن رؤيتها.' } },
451
- }],
452
- }]),
453
- },
435
+ list: { path: '/courses', component: portalUi('catalogue.tsx#Catalogue') },
436
+ view: { path: '/courses/:id', component: portalUi('course.tsx#Course') },
454
437
  },
455
438
  },
456
439
  },
@@ -513,7 +496,7 @@ export const courses = defineKayan({
513
496
  zod: z.number({ error: 'courses.fee.invalid' }).nonnegative({ error: 'courses.fee.min' }).nullish(),
514
497
  dashboard: { filter: true, cell: feeCell },
515
498
  portal: { cell: feeCell },
516
- platform: { cell: feeCell },
499
+ backoffice: { cell: feeCell },
517
500
  },
518
501
  },
519
502
  currency: {
@@ -558,7 +541,9 @@ export const courses = defineKayan({
558
541
  kind: 'text', label: { en: 'Access code', ar: 'رمز الدخول' }, rules: { maxLength: 6 },
559
542
  targets: { dashboard: { component: 'otp' } },
560
543
  },
561
- archived: { kind: 'checkbox', label: { en: 'Archived', ar: 'مؤرشف' }, targets: { dashboard: { filter: true } } },
544
+ // Written by `archive` and `unarchive` alone, which record why: no create or
545
+ // plain update names it, so no key past theirs archives a course.
546
+ archived: { kind: 'checkbox', label: { en: 'Archived', ar: 'مؤرشف' }, rules: { providedBy: { create: 'never', update: 'application' } }, targets: { dashboard: { filter: true } } },
562
547
  // What a course covers, several at once out of a closed list. A course names
563
548
  // at most two, so a third box ticked is refused before the save. No floor: an
564
549
  // optional list left empty passes any `minRows`, and a list that is there
@@ -574,12 +559,12 @@ export const courses = defineKayan({
574
559
  ],
575
560
  },
576
561
  // The day enrolment opens, a calendar day with no time of its own, which
577
- // the portal draws as the platform's own day picker.
562
+ // the portal draws as the backoffice's own day picker.
578
563
  opensOn: { kind: 'date', label: { en: 'Opens on', ar: 'يفتح في' } },
579
564
  archiveReason: {
580
565
  kind: 'text',
581
566
  label: { en: 'Archive reason', ar: 'سبب الأرشفة' },
582
- rules: { maxLength: 200 },
567
+ rules: { maxLength: 200, providedBy: { create: 'never', update: 'application' } },
583
568
  },
584
569
  // The many-to-many: no column on this table at all, and a join table
585
570
  // `courses_tags` the store writes in the same transaction the course is
@@ -607,6 +592,10 @@ export const courses = defineKayan({
607
592
  // The course's cover, one of the organization's images. Its row cannot be
608
593
  // deleted while a course points at it, since an upload restricts. Declared
609
594
  // last, for the same column order.
610
- cover: { kind: 'upload', relationTo: () => media, label: { en: 'Cover image', ar: 'صورة الغلاف' } },
595
+ cover: { kind: 'upload', relationTo: 'media', label: { en: 'Cover image', ar: 'صورة الغلاف' } },
596
+ // Whether a student takes a seat in this course's sessions from the portal: only where it is true, so a course
597
+ // that never says is one whose staff enroll each student. No default, which every create would then write, and
598
+ // a role without the column's key would be refused. Declared last, for the same column order.
599
+ selfEnroll: { kind: 'checkbox', label: { en: 'Students may enroll themselves', ar: 'يسجّل الطلاب أنفسهم' } },
611
600
  },
612
601
  })
@@ -0,0 +1,33 @@
1
+ import { actorPermissions, type Where } from 'mithaq'
2
+ import { serverTargets } from '@asas/mithaq'
3
+ import { currentOrganization } from '@asas/mithaq/roles/tenant-access.ts'
4
+ import { courses } from './courses.ts'
5
+
6
+ /**
7
+ * The images an actor reads as covers: the cover of a published course it
8
+ * reads, of the organization its session has active, under the courses kayan's
9
+ * own read access for this actor. So a student or an instructor reads no image
10
+ * of a draft, of an archived course or of none. The media feature asks it of
11
+ * an actor that holds no `media.create`, and holds no import of this file.
12
+ *
13
+ * The organization is read off the trusted snapshot and named here, since the
14
+ * courses are read straight through the store, past the runner's scope. An
15
+ * image has no field that points at a course, so a row filter cannot reach
16
+ * one, and the covers are read first and named by id.
17
+ */
18
+ export const coverReads = async ({ user }: { user: unknown }): Promise<Where | false> => {
19
+ const held = actorPermissions(user)
20
+ if (held !== true && !held?.has('courses.find')) return false
21
+ const active = currentOrganization(user)
22
+ const readable = active && (await courses.access!.read!({ user }))
23
+ if (!readable) return false
24
+ const published: Where = { and: [{ organization: { equals: active } }, { _status: { equals: 'published' } }] }
25
+ const found = await serverTargets.drizzle.find({ kayan: 'courses', args: {
26
+ limit: 0,
27
+ page: 1,
28
+ sort: { field: 'id', direction: 'asc' },
29
+ where: readable === true ? published : { and: [readable, published] },
30
+ } })
31
+ const ids = [...new Set(found.docs.map((row) => row.cover).filter((id): id is string => typeof id === 'string'))]
32
+ return ids.length ? { id: { in: ids } } : false
33
+ }
@@ -0,0 +1,9 @@
1
+ // The edit page's header pill for an archived course, beside the title and the
2
+ // draft status, so a course out of the catalogue says so on every tab.
3
+ import { useLocale } from 'mithaq/admin/react'
4
+ import { Badge } from '@/components/ui/badge'
5
+
6
+ export function ArchivedBadge() {
7
+ const { t } = useLocale()
8
+ return <Badge data-status="archived" className="bg-destructive/10 text-destructive">{t('version.archived', 'Archived')}</Badge>
9
+ }
@@ -3,7 +3,7 @@
3
3
  // the dot of "ر.س." on the wrong side.
4
4
  import type { CellProps } from 'mithaq/admin'
5
5
  import { useLocale } from 'mithaq/admin/react'
6
- import { noValue } from '@/components/mithaq/controls.tsx'
6
+ import { noValue } from 'mithaq-admin/format.tsx'
7
7
 
8
8
  const amount = (text: string) => <bdi dir="ltr">{text}</bdi>
9
9
 
@@ -1,20 +1,21 @@
1
1
  // The list reads each row's latest draft, so a published course whose newest version is a draft
2
2
  // and a never-published course both arrive as `draft`. The plain read finds only courses with a
3
- // published copy, which tells the two apart.
3
+ // published copy, which tells the two apart. An archived course says so over any of the three.
4
4
  import { use } from 'react'
5
5
  import { useQuery } from '@tanstack/react-query'
6
6
  import type { CellProps, Row } from 'mithaq/admin'
7
7
  import { ListContext, useClient, useLocale, useScreen } from 'mithaq/admin/react'
8
8
  import { Badge } from '@/components/ui/badge'
9
- import { noValue } from '@/components/mithaq/controls.tsx'
9
+ import { noValue } from 'mithaq-admin/format.tsx'
10
10
  import { coursesOf } from '../../enrollments/ui/session-cell.tsx'
11
11
 
12
12
  const pills = {
13
13
  draft: 'bg-muted text-muted-foreground',
14
14
  published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
15
15
  changed: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
16
+ archived: 'bg-destructive/10 text-destructive',
16
17
  }
17
- const words = { draft: 'Draft', published: 'Published', changed: 'Changed' }
18
+ const words = { draft: 'Draft', published: 'Published', changed: 'Changed', archived: 'Archived' }
18
19
 
19
20
  export function StatusCell({ value, row }: CellProps) {
20
21
  const client = useClient()
@@ -24,6 +25,7 @@ export function StatusCell({ value, row }: CellProps) {
24
25
  const ids = [...new Set((use(ListContext)?.page.docs ?? [row]).flatMap((one: Row) => (one._status === 'draft' ? [String(one.id)] : [])))].sort()
25
26
  const { data: published, isError } = useQuery({ queryKey: ['status-cell', ids], queryFn: () => coursesOf({ client, ids, maxLimit }), enabled: ids.length > 0, staleTime: 30_000 })
26
27
  if (value !== 'draft' && value !== 'published') return noValue
28
+ if (row.archived === true) return <Badge data-status="archived" className={pills.archived}>{t('version.archived', words.archived)}</Badge>
27
29
  // Loading until the read answers, and a draft where it failed, which is what the row itself holds.
28
30
  if (value === 'draft' && !published && !isError) return t('admin.loading', 'Loading…')
29
31
  const status = value === 'draft' && published?.has(String(row.id)) ? 'changed' : value
@@ -0,0 +1,15 @@
1
+ // The danger zone's Unpublish: one click, behind the page's confirm, sending the
2
+ // draft status `unpublish` takes, as Payload's Unpublish does. The course stays
3
+ // as it is, read by draft readers alone, until it is published again.
4
+ import type { ButtonProps } from 'mithaq/admin'
5
+ import { useLocale } from 'mithaq/admin/react'
6
+ import { Button } from '@/components/ui/button'
7
+
8
+ export function UnpublishButton({ busy, operation, run }: ButtonProps) {
9
+ const { t } = useLocale()
10
+ return (
11
+ <Button type="button" variant="outline" data-button={operation} disabled={busy} onClick={() => void run({ _status: 'draft' })}>
12
+ {t('courses.operations.unpublish', 'Unpublish')}
13
+ </Button>
14
+ )
15
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod'
2
+ import type { Ctx } from 'mithaq'
3
+
4
+ /** The box an archive's or an unarchive's dialog asks for on every screen that draws its button. */
5
+ export const reason = {
6
+ kind: 'text',
7
+ label: { en: 'Reason', ar: 'السبب' },
8
+ rules: { required: true, maxLength: 200 },
9
+ } as const
10
+
11
+ /**
12
+ * What `archive` and `unarchive` parse: the row and why. The error messages are
13
+ * the kayan's own copy keys, which the form resolves; without them it draws
14
+ * what zod wrote, in English.
15
+ */
16
+ export const reasonInput = (key: `${string}.operations.${'archive' | 'unarchive'}`) => z.object({
17
+ id: z.guid(),
18
+ reason: z
19
+ .string({ error: `${key}.reason.required` })
20
+ .max(200, { error: `${key}.reason.maxLength` }),
21
+ })
22
+
23
+ /**
24
+ * The one step each adds: it turns the operation's `reason` into the two
25
+ * columns the kayan has, so `reason` never has to be a field. `ctx.data` is
26
+ * spread to keep what the runner filled before it, such as `updatedAt`, and
27
+ * `reason` reaches no column: the store step writes the kayan's own fields alone.
28
+ * An empty reason, which a bulk archive sends, keeps none.
29
+ */
30
+ export const marks = (archived: boolean) => (ctx: Ctx<{ reason: string }>): Ctx => ({ ...ctx, data: { ...ctx.data, archived, archiveReason: ctx.data.reason || null } })
31
+
32
+ /** Over a row not archived yet, where an archive button is drawn. Both arms, since a row from before the column holds `null`. */
33
+ export const unarchived = { or: [{ 'record.archived': { exists: false } }, { 'record.archived': { equals: false } }] } as const
34
+
35
+ /** Over an archived row, where an unarchive button and the Archived badge are drawn. */
36
+ export const archivedRow = { 'record.archived': { equals: true } } as const
37
+
38
+ /** A list's tabs over the same column: what is running, what was archived, and everything. */
39
+ export const notArchived = { or: [{ archived: { exists: false } }, { archived: { equals: false } }] } as const
@@ -0,0 +1,6 @@
1
+ // The courses the portal offers: every course not archived, of those a plain
2
+ // read answers, which are the published ones. The portal's catalogue, its home
3
+ // and its course page read these alone. Plain data in a file of its own, so a
4
+ // page reads it without the kayan's declaration. A course with no `archived`
5
+ // value is not archived either.
6
+ export const onOffer = { or: [{ archived: { exists: false } }, { archived: { equals: false } }] }