create-protobase 0.23.13 → 0.23.14

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 (240) hide show
  1. package/dist/ask-preset.js +25 -0
  2. package/dist/create-project.js +7 -6
  3. package/dist/index.js +19 -9
  4. package/dist/next-steps.js +8 -2
  5. package/dist/presets.js +17 -0
  6. package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
  7. package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
  8. package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
  9. package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
  10. package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
  11. package/dist/templates/erp/.env.example +14 -0
  12. package/dist/templates/erp/AGENTS.md +24 -0
  13. package/dist/templates/erp/auth/auth.ts +68 -0
  14. package/dist/templates/erp/components/orders-per-day.tsx +21 -0
  15. package/dist/templates/erp/config/bottom-bar.ts +9 -0
  16. package/dist/templates/erp/config/categories/data.ts +20 -0
  17. package/dist/templates/erp/config/categories/ui.ts +11 -0
  18. package/dist/templates/erp/config/companies/data.ts +23 -0
  19. package/dist/templates/erp/config/companies/ui.ts +22 -0
  20. package/dist/templates/erp/config/company_tags/data.ts +16 -0
  21. package/dist/templates/erp/config/company_tags/ui.ts +7 -0
  22. package/dist/templates/erp/config/countries/data.ts +13 -0
  23. package/dist/templates/erp/config/countries/ui.ts +6 -0
  24. package/dist/templates/erp/config/currencies/data.ts +14 -0
  25. package/dist/templates/erp/config/currencies/ui.ts +6 -0
  26. package/dist/templates/erp/config/emp_master/data.ts +19 -0
  27. package/dist/templates/erp/config/emp_master/ui.ts +7 -0
  28. package/dist/templates/erp/config/index.ts +42 -0
  29. package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
  30. package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
  31. package/dist/templates/erp/config/invoices/data.ts +26 -0
  32. package/dist/templates/erp/config/invoices/ui.ts +38 -0
  33. package/dist/templates/erp/config/jobs.test.ts +23 -0
  34. package/dist/templates/erp/config/jobs.ts +29 -0
  35. package/dist/templates/erp/config/locations/data.ts +18 -0
  36. package/dist/templates/erp/config/locations/ui.ts +6 -0
  37. package/dist/templates/erp/config/order_lines/data.ts +20 -0
  38. package/dist/templates/erp/config/order_lines/ui.ts +7 -0
  39. package/dist/templates/erp/config/orders/data.ts +27 -0
  40. package/dist/templates/erp/config/orders/ui.ts +41 -0
  41. package/dist/templates/erp/config/organizations/data.ts +17 -0
  42. package/dist/templates/erp/config/organizations/ui.ts +6 -0
  43. package/dist/templates/erp/config/overview/page.tsx +80 -0
  44. package/dist/templates/erp/config/pages.test.ts +14 -0
  45. package/dist/templates/erp/config/people/data.ts +21 -0
  46. package/dist/templates/erp/config/people/ui.ts +6 -0
  47. package/dist/templates/erp/config/products/data.ts +25 -0
  48. package/dist/templates/erp/config/products/ui.ts +6 -0
  49. package/dist/templates/erp/config/roles.test.ts +56 -0
  50. package/dist/templates/erp/config/roles.ts +54 -0
  51. package/dist/templates/erp/config/shell.test.ts +24 -0
  52. package/dist/templates/erp/config/stock_levels/data.ts +16 -0
  53. package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
  54. package/dist/templates/erp/config/stock_moves/data.ts +22 -0
  55. package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
  56. package/dist/templates/erp/config/tags/data.ts +17 -0
  57. package/dist/templates/erp/config/tags/ui.ts +6 -0
  58. package/dist/templates/erp/config/user-menu.ts +9 -0
  59. package/dist/templates/erp/config/users/data.ts +19 -0
  60. package/dist/templates/erp/config/users/ui.ts +9 -0
  61. package/dist/templates/erp/config/warehouses/data.ts +19 -0
  62. package/dist/templates/erp/config/warehouses/ui.ts +6 -0
  63. package/dist/templates/erp/data.ts +3 -0
  64. package/dist/templates/erp/db/connection.ts +7 -0
  65. package/dist/templates/erp/db/drop.ts +9 -0
  66. package/dist/templates/erp/db/kysely.ts +13 -0
  67. package/dist/templates/erp/db/migrations/001_core.sql +48 -0
  68. package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
  69. package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
  70. package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
  71. package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
  72. package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
  73. package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
  74. package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
  75. package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
  76. package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
  77. package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
  78. package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
  79. package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
  80. package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
  81. package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
  82. package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
  83. package/dist/templates/erp/package.json +38 -0
  84. package/dist/templates/erp/protobase.config.ts +22 -0
  85. package/dist/templates/erp/protobase.ui.tsx +14 -0
  86. package/dist/templates/erp/seed/auth-organizations.ts +11 -0
  87. package/dist/templates/erp/seed/base-organization.test.ts +17 -0
  88. package/dist/templates/erp/seed/base-organization.ts +10 -0
  89. package/dist/templates/erp/seed/base.ts +22 -0
  90. package/dist/templates/erp/seed/builders/categories.ts +18 -0
  91. package/dist/templates/erp/seed/builders/company.ts +61 -0
  92. package/dist/templates/erp/seed/builders/invoice.ts +31 -0
  93. package/dist/templates/erp/seed/builders/order.ts +66 -0
  94. package/dist/templates/erp/seed/builders/product.ts +46 -0
  95. package/dist/templates/erp/seed/calendar.ts +60 -0
  96. package/dist/templates/erp/seed/cli.ts +54 -0
  97. package/dist/templates/erp/seed/copy.ts +22 -0
  98. package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
  99. package/dist/templates/erp/seed/data/layout.ts +19 -0
  100. package/dist/templates/erp/seed/data/names.ts +44 -0
  101. package/dist/templates/erp/seed/data/reference.ts +53 -0
  102. package/dist/templates/erp/seed/format.ts +24 -0
  103. package/dist/templates/erp/seed/maintenance.ts +63 -0
  104. package/dist/templates/erp/seed/rng.ts +36 -0
  105. package/dist/templates/erp/seed/scales.ts +9 -0
  106. package/dist/templates/erp/seed/steps/catalog.ts +34 -0
  107. package/dist/templates/erp/seed/steps/crm.ts +62 -0
  108. package/dist/templates/erp/seed/steps/hr.ts +26 -0
  109. package/dist/templates/erp/seed/steps/ledger.ts +88 -0
  110. package/dist/templates/erp/seed/steps/reference.ts +24 -0
  111. package/dist/templates/erp/seed/steps/sales.ts +58 -0
  112. package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
  113. package/dist/templates/erp/seed/world.ts +86 -0
  114. package/dist/templates/erp/vitest.config.ts +4 -0
  115. package/dist/templates/real-estate/.env.example +9 -0
  116. package/dist/templates/real-estate/AGENTS.md +24 -0
  117. package/dist/templates/real-estate/_.gitignore +3 -0
  118. package/dist/templates/real-estate/_bunfig.toml +4 -0
  119. package/dist/templates/real-estate/auth/auth.ts +36 -0
  120. package/dist/templates/real-estate/config/amenities/data.ts +12 -0
  121. package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
  122. package/dist/templates/real-estate/config/arrears/data.ts +19 -0
  123. package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
  124. package/dist/templates/real-estate/config/deposits/data.ts +21 -0
  125. package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
  126. package/dist/templates/real-estate/config/index.ts +41 -0
  127. package/dist/templates/real-estate/config/inspections/data.ts +22 -0
  128. package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
  129. package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
  130. package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
  131. package/dist/templates/real-estate/config/leases/data.ts +29 -0
  132. package/dist/templates/real-estate/config/leases/ui.ts +33 -0
  133. package/dist/templates/real-estate/config/organizations/data.ts +17 -0
  134. package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
  135. package/dist/templates/real-estate/config/overview/page.tsx +57 -0
  136. package/dist/templates/real-estate/config/owners/data.ts +22 -0
  137. package/dist/templates/real-estate/config/owners/ui.ts +17 -0
  138. package/dist/templates/real-estate/config/pages.test.ts +49 -0
  139. package/dist/templates/real-estate/config/payments/data.ts +20 -0
  140. package/dist/templates/real-estate/config/payments/ui.ts +14 -0
  141. package/dist/templates/real-estate/config/properties/data.ts +26 -0
  142. package/dist/templates/real-estate/config/properties/ui.ts +21 -0
  143. package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
  144. package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
  145. package/dist/templates/real-estate/config/roles.test.ts +49 -0
  146. package/dist/templates/real-estate/config/roles.ts +45 -0
  147. package/dist/templates/real-estate/config/shell.test.ts +18 -0
  148. package/dist/templates/real-estate/config/tenants/data.ts +23 -0
  149. package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
  150. package/dist/templates/real-estate/config/tickets/data.ts +33 -0
  151. package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
  152. package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
  153. package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
  154. package/dist/templates/real-estate/config/units/data.ts +24 -0
  155. package/dist/templates/real-estate/config/units/ui.ts +41 -0
  156. package/dist/templates/real-estate/config/user-menu.ts +9 -0
  157. package/dist/templates/real-estate/config/users/data.ts +19 -0
  158. package/dist/templates/real-estate/config/users/ui.ts +11 -0
  159. package/dist/templates/real-estate/config/valuations/data.ts +19 -0
  160. package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
  161. package/dist/templates/real-estate/config/vendors/data.ts +21 -0
  162. package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
  163. package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
  164. package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
  165. package/dist/templates/real-estate/db/connection.ts +7 -0
  166. package/dist/templates/real-estate/db/drop.ts +9 -0
  167. package/dist/templates/real-estate/db/kysely.ts +13 -0
  168. package/dist/templates/real-estate/db/migrate.ts +30 -0
  169. package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
  170. package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
  171. package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
  172. package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
  173. package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
  174. package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
  175. package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
  176. package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
  177. package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
  178. package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
  179. package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
  180. package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
  181. package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
  182. package/dist/templates/real-estate/node-types.d.ts +1 -0
  183. package/dist/templates/real-estate/package.json +38 -0
  184. package/dist/templates/real-estate/protobase.config.ts +5 -0
  185. package/dist/templates/real-estate/protobase.ui.tsx +16 -0
  186. package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
  187. package/dist/templates/real-estate/seed/base-organization.ts +10 -0
  188. package/dist/templates/real-estate/seed/base.ts +18 -0
  189. package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
  190. package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
  191. package/dist/templates/real-estate/seed/builders/property.ts +98 -0
  192. package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
  193. package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
  194. package/dist/templates/real-estate/seed/calendar.ts +77 -0
  195. package/dist/templates/real-estate/seed/cli.ts +50 -0
  196. package/dist/templates/real-estate/seed/copy.ts +22 -0
  197. package/dist/templates/real-estate/seed/data/names.ts +31 -0
  198. package/dist/templates/real-estate/seed/data/reference.ts +63 -0
  199. package/dist/templates/real-estate/seed/format.ts +24 -0
  200. package/dist/templates/real-estate/seed/iban.ts +15 -0
  201. package/dist/templates/real-estate/seed/maintenance.ts +63 -0
  202. package/dist/templates/real-estate/seed/rng.ts +36 -0
  203. package/dist/templates/real-estate/seed/scales.ts +9 -0
  204. package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
  205. package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
  206. package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
  207. package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
  208. package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
  209. package/dist/templates/real-estate/seed/world.ts +92 -0
  210. package/dist/templates/real-estate/tsconfig.json +16 -0
  211. package/dist/templates/real-estate/vitest.config.ts +4 -0
  212. package/dist/templates/scratch/_.gitignore +3 -0
  213. package/dist/templates/scratch/_bunfig.toml +4 -0
  214. package/dist/templates/scratch/db/migrate.ts +30 -0
  215. package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
  216. package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
  217. package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
  218. package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
  219. package/dist/templates/scratch/node-types.d.ts +1 -0
  220. package/dist/{template → templates/scratch}/package.json +5 -5
  221. package/dist/templates/scratch/tsconfig.json +16 -0
  222. package/package.json +2 -2
  223. /package/dist/{template → templates/erp}/_.gitignore +0 -0
  224. /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
  225. /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
  226. /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
  227. /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
  228. /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
  229. /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
  230. /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
  231. /package/dist/{template → templates/erp}/tsconfig.json +0 -0
  232. /package/dist/{template → templates/scratch}/.env.example +0 -0
  233. /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
  234. /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
  235. /package/dist/{template → templates/scratch}/config/index.ts +0 -0
  236. /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
  237. /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
  238. /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
  239. /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
  240. /package/dist/{template → templates/scratch}/vitest.config.ts +0 -0
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { WriteEvent } from '@protobase/server'
3
+ import { overdueCheck } from './jobs'
4
+
5
+ const event = (operation: WriteEvent['operation'], after?: Record<string, unknown>, resource = 'invoices') =>
6
+ ({ operation, resource: { name: resource }, user: { id: 1, roles: [] }, ...(after && { after }) }) as unknown as WriteEvent
7
+
8
+ describe('overdueCheck', () => {
9
+ it('checks a sent invoice the day after it is due, once per invoice', () => {
10
+ expect(overdueCheck(event('update', { id: '42', status: 'sent', due_at: '2026-11-01' }))).toEqual({
11
+ invoiceId: '42',
12
+ runAt: new Date('2026-11-02T00:00:00Z'),
13
+ key: 'invoice:42',
14
+ })
15
+ })
16
+
17
+ it('leaves drafts, paid invoices, deletes and other resources alone', () => {
18
+ expect(overdueCheck(event('create', { id: '1', status: 'draft', due_at: '2026-11-01' }))).toBeUndefined()
19
+ expect(overdueCheck(event('update', { id: '1', status: 'paid', due_at: '2026-11-01' }))).toBeUndefined()
20
+ expect(overdueCheck(event('delete'))).toBeUndefined()
21
+ expect(overdueCheck(event('update', { id: '1', status: 'sent' }, 'orders'))).toBeUndefined()
22
+ })
23
+ })
@@ -0,0 +1,29 @@
1
+ import { sql } from 'kysely'
2
+ import { defineJob, type WriteEvent } from '@protobase/server'
3
+
4
+ /** An invoice still sent and unpaid once its due date has passed becomes overdue. */
5
+ export const markInvoiceOverdue = defineJob<{ invoiceId: string }>({
6
+ name: 'mark-invoice-overdue',
7
+ run: async ({ invoiceId }, { db }) => {
8
+ await sql`update sales.invoices set status = 'overdue' where id = ${invoiceId} and status = 'sent' and due_at < current_date`.execute(db)
9
+ },
10
+ })
11
+
12
+ /** Every night, the sent invoices that passed their due date, such as imported ones that never had a check enqueued. */
13
+ export const markOverdueInvoices = defineJob({
14
+ name: 'mark-overdue-invoices',
15
+ schedule: '0 2 * * *',
16
+ timezone: 'Europe/Amsterdam',
17
+ run: async (_, { db }) => {
18
+ await sql`update sales.invoices set status = 'overdue' where status = 'sent' and due_at < current_date`.execute(db)
19
+ },
20
+ })
21
+
22
+ const day = 86_400_000
23
+
24
+ /** For a sent invoice, the check that it is paid: the day after it is due, one waiting check per invoice. */
25
+ export const overdueCheck = (event: WriteEvent) => {
26
+ if (event.resource.name !== 'invoices' || event.operation === 'delete' || event.after?.status !== 'sent') return undefined
27
+ const invoiceId = String(event.after.id)
28
+ return { invoiceId, runAt: new Date(Date.parse(String(event.after.due_at)) + day), key: `invoice:${invoiceId}` }
29
+ }
@@ -0,0 +1,18 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** inventory.locations. Unique: (warehouse_id, path) */
5
+ export const locations = resource('locations')
6
+ .table('inventory.locations')
7
+ .fields({
8
+ id: f.integer().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ warehouse_id: f.relation('warehouses').filterable().sortable(),
11
+ path: f.text().readOnly().filterable(),
12
+ name: f.text(),
13
+ created_at: f.timestamp().readOnly().dbDefault(),
14
+ updated_at: f.timestamp().readOnly().dbDefault(),
15
+ })
16
+ .primaryKey((r) => r.id)
17
+ .tenant((r) => r.organization_id)
18
+ .access(erpAccess('locations'))
@@ -0,0 +1,6 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { locations } from './data'
3
+
4
+ export const locationsView = view<typeof locations>('locations')
5
+ .names({ singular: 'Location', plural: 'Locations' })
6
+ .list((r) => ({ columns: [r.path, r.name, r.warehouse_id] }))
@@ -0,0 +1,20 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** sales.order_lines */
5
+ export const orderLines = resource('order_lines')
6
+ .table('sales.order_lines')
7
+ .fields({
8
+ order_id: f.relation('orders').filterable().sortable(),
9
+ line_no: f.integer().sortable(),
10
+ organization_id: f.relation('organizations').filterable().sortable(),
11
+ product_id: f.relation('products').filterable().sortable(),
12
+ description: f.text(),
13
+ quantity: f.integer(),
14
+ unit_price: f.decimal({ precision: 12, scale: 2 }),
15
+ created_at: f.timestamp().readOnly().dbDefault(),
16
+ updated_at: f.timestamp().readOnly().dbDefault(),
17
+ })
18
+ .primaryKey((r) => [r.order_id, r.line_no])
19
+ .tenant((r) => r.organization_id)
20
+ .access(erpAccess('order_lines'))
@@ -0,0 +1,7 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { orderLines } from './data'
3
+
4
+ export const orderLinesView = view<typeof orderLines>('order_lines')
5
+ .nav({ hidden: true })
6
+ .names({ singular: 'Order line', plural: 'Order lines' })
7
+ .list((r) => ({ columns: [r.quantity, r.unit_price, r.product_id] }))
@@ -0,0 +1,27 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** sales.orders. Unique: (organization_id, number) */
5
+ export const orders = resource('orders')
6
+ .table('sales.orders')
7
+ .fields({
8
+ id: f.uuid().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ number: f.text().filterable().sortable(),
11
+ company_id: f.relation('companies').filterable().sortable(),
12
+ person_id: f.relation('people').optional().filterable().sortable(),
13
+ owner_id: f.relation('users').filterable().sortable(),
14
+ status: f.enum(['draft', 'confirmed', 'picking', 'shipped', 'delivered', 'cancelled']).filterable().sortable(),
15
+ currency_code: f.relation('currencies'),
16
+ discount_percent: f.decimal({ precision: 5, scale: 2 }).default('0'),
17
+ total: f.decimal({ precision: 14, scale: 2 }).readOnly().default('0').filterable().sortable(),
18
+ paid: f.boolean().default(false).filterable().sortable(),
19
+ notes: f.text().optional(),
20
+ created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
21
+ updated_at: f.timestamp().readOnly().dbDefault(),
22
+ })
23
+ .primaryKey((r) => r.id)
24
+ .tenant((r) => r.organization_id)
25
+ .owner((r) => r.owner_id)
26
+ .search((r) => [r.number])
27
+ .access(erpAccess('orders'))
@@ -0,0 +1,41 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { orders } from './data'
3
+
4
+ export const ordersView = view<typeof orders>('orders')
5
+ .title((r) => r.number)
6
+ .names({ singular: 'Order', plural: 'Orders' })
7
+ .nav({
8
+ recent: {
9
+ status: 'status',
10
+ tones: { draft: 'neutral', confirmed: 'info', picking: 'warning', shipped: 'success' },
11
+ pulse: ['picking'],
12
+ filter: 'status != "delivered" AND status != "cancelled"',
13
+ orderBy: 'created_at desc',
14
+ },
15
+ })
16
+ .fields((r) => ({
17
+ status: r.status.help('Where the order is in its life. Moving it forward can notify the customer.').format('badge')
18
+ .valueLabels({ draft: 'Draft', confirmed: 'Confirmed', picking: 'Picking', shipped: 'Shipped', delivered: 'Delivered', cancelled: 'Cancelled' }),
19
+ total: r.total.help('Order total including VAT.').prefix('€').decimals(2),
20
+ discount_percent: r.discount_percent.label('Discount').help('Percentage taken off the order total.').format('percent'),
21
+ paid: r.paid.help('Set when the payment has been received.'),
22
+ created_at: r.created_at.label('Created').format('absolute'),
23
+ }))
24
+ .list((r) => ({
25
+ columns: [r.number, r.company_id, r.status, r.total, r.discount_percent, r.paid, r.owner_id, r.created_at],
26
+ sort: [[r.created_at, 'desc']],
27
+ search: [r.number],
28
+ }))
29
+ .filters((r, w) => [
30
+ w.facets(r.status),
31
+ w.facets(r.owner_id),
32
+ w.dateRange(r.created_at, { presets: ['7d', '30d', '90d', 'quarter', 'year'] }),
33
+ w.range(r.total, { histogram: true }),
34
+ w.toggle(r.paid),
35
+ ])
36
+ .chart((r) => ({ field: r.created_at, range: '30d', granularity: 'day' }))
37
+ .saveFeedback('toast')
38
+ .actions((a) => [
39
+ a.update('markPaid', { label: 'Mark as paid', icon: 'wallet', set: { paid: true }, confirm: 'Record that the customer paid this order?' }),
40
+ a.action('createInvoice', { label: 'Create invoice', icon: 'file-text' }),
41
+ ])
@@ -0,0 +1,17 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** core.organizations. Unique: (slug) */
5
+ export const organizations = resource('organizations')
6
+ .table('core.organizations')
7
+ .fields({
8
+ id: f.integer().readOnly().filterable().sortable().dbDefault(),
9
+ name: f.text(),
10
+ slug: f.text().filterable().sortable(),
11
+ country_code: f.relation('countries').filterable().sortable(),
12
+ currency_code: f.relation('currencies').filterable().sortable(),
13
+ created_at: f.timestamp().readOnly().dbDefault(),
14
+ updated_at: f.timestamp().readOnly().dbDefault(),
15
+ })
16
+ .primaryKey((r) => r.id)
17
+ .access(erpAccess('organizations'))
@@ -0,0 +1,6 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { organizations } from './data'
3
+
4
+ export const organizationsView = view<typeof organizations>('organizations')
5
+ .names({ singular: 'Organization', plural: 'Organizations' })
6
+ .list((r) => ({ columns: [r.name, r.slug, r.country_code, r.currency_code] }))
@@ -0,0 +1,80 @@
1
+ /** @jsxImportSource @protobase/layout */
2
+ import { Action, Card, CardRow, Chart, Field, Grid, ModalForm, Page, RecordCard, Show, Stat, Table, component, page } from '@protobase/layout'
3
+
4
+ /** Orders per day for the last `days` days, drawn by protobase.ui.tsx. */
5
+ export const OrdersPerDay = component<{ days: 7 | 30 | 90 }>('OrdersPerDay')
6
+
7
+ /** The sales team's start page: what needs attention today, and the leads to follow up. */
8
+ export const salesOverview = page(
9
+ 'overview',
10
+ <Page title="Sales overview" description="What needs attention today: unpaid orders, overdue invoices and new leads.">
11
+ <Show when="orders.count = 0">
12
+ <Card title="No orders yet">Create the first order from Orders; this page fills up as the team sells.</Card>
13
+ </Show>
14
+ <Grid columns={4}>
15
+ <Stat label="Draft orders" resource="orders" filter="status = 'draft'" />
16
+ <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" />
17
+ <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" />
18
+ <Stat label="Open leads" resource="companies" filter="status = 'lead'" />
19
+ </Grid>
20
+ <Grid columns={3}>
21
+ <RecordCard
22
+ title="Biggest unpaid delivery"
23
+ resource="orders"
24
+ filter="status = 'delivered' AND paid = false"
25
+ sort="total desc"
26
+ empty="Every delivered order is paid."
27
+ actions={<Action name="markPaid" variant="primary" />}
28
+ >
29
+ <Grid columns={2}>
30
+ <Field name="number" />
31
+ <Field name="total" />
32
+ <Field name="company_id" label="Customer" />
33
+ <Field name="company_id.city" label="City" />
34
+ </Grid>
35
+ <Show when="company_id.status = 'dormant'">The customer has gone quiet: call before sending a reminder.</Show>
36
+ </RecordCard>
37
+ <RecordCard
38
+ title="Oldest overdue invoice"
39
+ resource="invoices"
40
+ filter="status = 'overdue'"
41
+ sort="issued_at"
42
+ empty="Nothing is overdue."
43
+ actions={[<Action name="send" label="Send reminder" />, <Action name="recordPayment" variant="primary" />]}
44
+ >
45
+ <Grid columns={2}>
46
+ <Field name="number" />
47
+ <Field name="total" />
48
+ <Field name="company_id" label="Customer" />
49
+ <Field name="due_at" label="Due" />
50
+ </Grid>
51
+ </RecordCard>
52
+ <Card title="Orders per day" description="The last 30 days.">
53
+ <OrdersPerDay days={30} />
54
+ </Card>
55
+ </Grid>
56
+ <Grid columns={2}>
57
+ <Chart title="Orders by status" resource="orders" groupBy="status" type="donut" />
58
+ <Chart title="Revenue by customer" description="Orders not cancelled." resource="orders" groupBy="company_id" sum="total" filter="status != 'cancelled'" limit={5} />
59
+ </Grid>
60
+ <Grid columns={2}>
61
+ <Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issued_at" columns={['number', 'company_id', 'due_at', 'total']} limit={5} empty="Nothing is overdue." />
62
+ <Table title="Latest orders" resource="orders" sort="created_at desc" columns={['number', 'company_id', 'status', 'total']} limit={5} />
63
+ </Grid>
64
+ <CardRow
65
+ title="New leads"
66
+ description="Companies that have not ordered yet."
67
+ resource="companies"
68
+ filter="status = 'lead'"
69
+ sort="created_at desc"
70
+ limit={8}
71
+ actions={<ModalForm mode="create" label="Add lead" resource="companies" fields={['name', 'city', 'country_code', 'email']} values={{ status: 'lead' }} />}
72
+ >
73
+ <Field name="name" label={false} />
74
+ <Field name="city" />
75
+ <Field name="email" />
76
+ <ModalForm mode="edit" label="Edit" fields={['name', 'city', 'email', 'phone', 'status']} />
77
+ </CardRow>
78
+ </Page>,
79
+ { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },
80
+ )
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { checkPages } from '@protobase/layout'
3
+ import { configExports } from '@protobase/server'
4
+ import * as config from './index'
5
+
6
+ describe('ERP pages', () => {
7
+ it('name only resources, fields, filters and actions the ERP has', () => {
8
+ const { resources, views, pages } = configExports(config)
9
+ const models = Object.fromEntries(resources.map((source) => [source.toModel().name, source.toModel()]))
10
+ const actions = (resource: string) => new Set(views.map((view) => view.toModel()).filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
11
+ expect(pages.map((entry) => entry.name)).toEqual(['accounts', 'overview'])
12
+ expect(() => checkPages(pages.map((entry) => entry.toModel()), { models, actions })).not.toThrow()
13
+ })
14
+ })
@@ -0,0 +1,21 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** crm.people */
5
+ export const people = resource('people')
6
+ .table('crm.people')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ company_id: f.relation('companies').filterable().sortable(),
11
+ first_name: f.text(),
12
+ last_name: f.text(),
13
+ email: f.text().optional(),
14
+ phone: f.text().optional(),
15
+ job_title: f.text().optional(),
16
+ created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
17
+ updated_at: f.timestamp().readOnly().dbDefault(),
18
+ })
19
+ .primaryKey((r) => r.id)
20
+ .tenant((r) => r.organization_id)
21
+ .access(erpAccess('people'))
@@ -0,0 +1,6 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { people } from './data'
3
+
4
+ export const peopleView = view<typeof people>('people')
5
+ .names({ singular: 'People', plural: 'People' })
6
+ .list((r) => ({ columns: [r.first_name, r.last_name, r.email, r.phone, r.job_title] }))
@@ -0,0 +1,25 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** catalog.products. Unique: (organization_id, sku) */
5
+ export const products = resource('products')
6
+ .table('catalog.products')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ category_id: f.relation('categories').filterable().sortable(),
11
+ sku: f.text().filterable().sortable(),
12
+ name: f.text().filterable(),
13
+ description: f.text().optional(),
14
+ price: f.decimal({ precision: 12, scale: 2 }),
15
+ currency_code: f.relation('currencies'),
16
+ attributes: f.json().dbDefault(),
17
+ active: f.boolean().default(true),
18
+ deleted_at: f.timestamp().optional(),
19
+ created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
20
+ updated_at: f.timestamp().readOnly().dbDefault(),
21
+ })
22
+ .primaryKey((r) => r.id)
23
+ .softDelete((r) => r.deleted_at)
24
+ .tenant((r) => r.organization_id)
25
+ .access(erpAccess('products'))
@@ -0,0 +1,6 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { products } from './data'
3
+
4
+ export const productsView = view<typeof products>('products')
5
+ .names({ singular: 'Product', plural: 'Products' })
6
+ .list((r) => ({ columns: [r.sku, r.name, r.price, r.active] }))
@@ -0,0 +1,56 @@
1
+ import { resolveAccess } from '@protobase/schema'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { empMaster, orders, stockMoves } from './index'
4
+
5
+ const as = (role: string) => ({ user: { id: 3, roles: [role] } })
6
+
7
+ describe('ERP roles on orders', () => {
8
+ const rows = [
9
+ // role, read, create, update, delete, read filter, update filter
10
+ ['admin', true, true, true, true, undefined, undefined],
11
+ ['auditor', true, false, false, false, undefined, undefined],
12
+ ['manager', true, true, true, false, undefined, undefined],
13
+ ['sales', true, true, true, false, 'compare', 'compare'],
14
+ ['accountant', true, false, false, false, undefined, undefined],
15
+ ['warehouse', true, false, true, false, undefined, undefined],
16
+ ['editor', false, false, false, false, undefined, undefined],
17
+ ['integration', true, true, false, false, undefined, undefined],
18
+ ] as const
19
+
20
+ it.each(rows)('%s', async (role, read, create, update, remove, readFilter, updateFilter) => {
21
+ const access = await resolveAccess(orders, as(role))
22
+ expect(access.operations).toEqual({ list: read, read, create, update, delete: remove })
23
+ expect(access.rowFilter.read?.kind).toBe(readFilter)
24
+ expect(access.rowFilter.update?.kind).toBe(updateFilter)
25
+ if (readFilter) expect(access.rowFilter.read).toMatchObject({ field: { name: 'owner_id' }, value: { value: 3 } })
26
+ expect(access.readableFields.length > 0).toBe(read)
27
+ })
28
+ })
29
+
30
+ describe('hidden columns', () => {
31
+ // accountant holds costs.read but has no access to stock_moves at all
32
+ const unitCost = [
33
+ ['admin', true], ['auditor', true], ['manager', true], ['accountant', false],
34
+ ['sales', false], ['warehouse', false], ['editor', false], ['integration', false],
35
+ ] as const
36
+
37
+ it.each(unitCost)('stock_moves.unit_cost for %s: %s', async (role, visible) => {
38
+ const access = await resolveAccess(stockMoves, as(role))
39
+ expect(access.readableFields.includes('unit_cost')).toBe(visible)
40
+ if (role === 'warehouse') expect(access.readableFields).toContain('quantity')
41
+ })
42
+
43
+ it.each(['admin', 'auditor', 'manager', 'accountant', 'sales', 'warehouse', 'editor', 'integration'])(
44
+ 'emp_master.sal_amt for %s is admin only',
45
+ async (role) => {
46
+ const access = await resolveAccess(empMaster, as(role))
47
+ expect(access.readableFields.includes('sal_amt')).toBe(role === 'admin')
48
+ if (role === 'manager') expect(access.readableFields).toContain('emp_name')
49
+ },
50
+ )
51
+
52
+ it('a field with only a read rule follows its resource for writes', async () => {
53
+ const access = await resolveAccess(stockMoves, as('warehouse'))
54
+ expect(access.writableFields.create).toContain('unit_cost')
55
+ })
56
+ })
@@ -0,0 +1,54 @@
1
+ import { defineRoles } from '@protobase/schema'
2
+
3
+ /** Resources plus the area that gates hidden columns. */
4
+ export type ErpName =
5
+ | 'categories' | 'products' | 'countries' | 'currencies' | 'organizations' | 'users'
6
+ | 'companies' | 'company_tags' | 'people' | 'tags' | 'emp_master' | 'locations'
7
+ | 'stock_levels' | 'stock_moves' | 'warehouses' | 'invoice_lines' | 'invoices'
8
+ | 'order_lines' | 'orders'
9
+ | 'costs'
10
+
11
+ /**
12
+ * Capabilities are `<resource|area>.<action>[.<scope>]`. `.own` rows are those whose `.owner()`
13
+ * field is the user's id. The `costs` area gates cost columns; salaries are admin-only via `roles.is('admin')`.
14
+ */
15
+ export const roles = defineRoles<ErpName>({
16
+ admin: ['*'],
17
+ auditor: ['*.read'],
18
+ manager: ['*.read', '*.create', '*.update', 'costs.read'],
19
+ sales: [
20
+ 'orders.read.own', 'orders.create', 'orders.update.own',
21
+ 'order_lines.read', 'order_lines.create', 'order_lines.update', 'order_lines.delete',
22
+ 'companies.read', 'companies.create', 'companies.update',
23
+ 'people.read', 'people.create', 'people.update',
24
+ 'company_tags.read', 'company_tags.create', 'company_tags.delete', 'tags.read',
25
+ 'products.read', 'categories.read', 'countries.read', 'currencies.read', 'stock_levels.read',
26
+ ],
27
+ accountant: [
28
+ 'invoices.*', 'invoice_lines.*',
29
+ 'orders.read', 'order_lines.read', 'companies.read',
30
+ 'products.read', 'categories.read', 'countries.read', 'currencies.read',
31
+ 'costs.read',
32
+ ],
33
+ warehouse: [
34
+ 'stock_levels.*', 'stock_moves.*', 'locations.*', 'warehouses.read',
35
+ 'orders.read', 'orders.update', 'order_lines.read',
36
+ 'products.read', 'categories.read',
37
+ ],
38
+ editor: [
39
+ 'products.*', 'categories.*', 'tags.*', 'company_tags.*',
40
+ 'countries.read', 'currencies.read',
41
+ ],
42
+ integration: [
43
+ 'orders.read', 'orders.create', 'order_lines.read', 'order_lines.create',
44
+ 'products.read', 'companies.read', 'stock_levels.read',
45
+ ],
46
+ })
47
+
48
+ /** The standard rule set for a resource: every operation needs the matching capability. */
49
+ export const erpAccess = (name: ErpName) => ({
50
+ read: roles.can(`${name}.read`),
51
+ create: roles.can(`${name}.create`),
52
+ update: roles.can(`${name}.update`),
53
+ delete: roles.can(`${name}.delete`),
54
+ })
@@ -0,0 +1,24 @@
1
+ import { createAdmin, configExports } from '@protobase/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import * as config from './index'
4
+ import { roles } from './roles'
5
+
6
+ describe('ERP sidebar, user menu and bottom bar', () => {
7
+ const { resources, views, pages, userMenu, bottomBars } = configExports(config)
8
+
9
+ it('has recent groups that fit their resources', () => {
10
+ const grouped = views.map((view) => view.toModel()).filter((view) => view.nav?.recent)
11
+ expect(grouped.map((view) => view.resource).sort()).toEqual(['invoices', 'orders'])
12
+ // createAdmin refuses a group or a user menu entry that does not fit, before any request.
13
+ expect(() => createAdmin({ resources, views, pages, userMenu, db: {} as never, authenticate: () => { throw new Error('No requests in this test') } })).not.toThrow()
14
+ })
15
+
16
+ it('moves the account pages into the user menu', () => {
17
+ expect(userMenu?.toUserMenuModel().items.map((item) => (item.kind === 'resource' ? item.resource : item.kind === 'page' ? item.page : item.label))).toEqual(['organizations', 'accounts', 'Documentation', 'Support'])
18
+ })
19
+
20
+ it('gives the warehouse role a bottom bar that fits the resources and roles', () => {
21
+ expect(bottomBars.map((bar) => bar.toBottomBarModel())).toEqual([expect.objectContaining({ label: 'Warehouse', roles: ['warehouse'] })])
22
+ expect(() => createAdmin({ resources, views, bottomBars, db: {} as never, authenticate: () => { throw new Error('No requests in this test') }, options: { roles } })).not.toThrow()
23
+ })
24
+ })
@@ -0,0 +1,16 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** inventory.stock_levels */
5
+ export const stockLevels = resource('stock_levels')
6
+ .table('inventory.stock_levels')
7
+ .fields({
8
+ product_id: f.relation('products').filterable().sortable(),
9
+ location_id: f.relation('locations').filterable().sortable(),
10
+ organization_id: f.relation('organizations').filterable().sortable(),
11
+ quantity: f.integer(),
12
+ updated_at: f.timestamp().readOnly().dbDefault(),
13
+ })
14
+ .primaryKey((r) => [r.product_id, r.location_id])
15
+ .tenant((r) => r.organization_id)
16
+ .access(erpAccess('stock_levels'))
@@ -0,0 +1,6 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { stockLevels } from './data'
3
+
4
+ export const stockLevelsView = view<typeof stockLevels>('stock_levels')
5
+ .names({ singular: 'Stock level', plural: 'Stock levels' })
6
+ .list((r) => ({ columns: [r.quantity] }))
@@ -0,0 +1,22 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess, roles } from '../roles'
3
+
4
+ /** inventory.stock_moves */
5
+ export const stockMoves = resource('stock_moves')
6
+ .table('inventory.stock_moves')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ product_id: f.relation('products').filterable().sortable(),
11
+ warehouse_id: f.relation('warehouses').filterable().sortable(),
12
+ location_id: f.relation('locations').filterable().sortable(),
13
+ kind: f.enum(['receipt', 'issue', 'transfer', 'adjustment']).filterable().sortable(),
14
+ quantity: f.integer().filterable().sortable(),
15
+ unit_cost: f.decimal({ precision: 12, scale: 2 }).access({ read: roles.can('costs.read') }),
16
+ reference: f.text().optional(),
17
+ moved_at: f.timestamp().filterable().sortable(),
18
+ user_id: f.relation('users').optional().filterable().sortable(),
19
+ })
20
+ .primaryKey((r) => r.id)
21
+ .tenant((r) => r.organization_id)
22
+ .access(erpAccess('stock_moves'))
@@ -0,0 +1,25 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { stockMoves } from './data'
3
+
4
+ export const stockMovesView = view<typeof stockMoves>('stock_moves')
5
+ .names({ singular: 'Stock move', plural: 'Stock moves' })
6
+ .fields((r) => ({
7
+ moved_at: r.moved_at.label('Moved at').format('absolute'),
8
+ product_id: r.product_id.label('Product'),
9
+ kind: r.kind.label('Type').help('Receipts add stock, issues remove it, transfers move it between locations.').format('badge').valueLabels({ receipt: 'Receipt', issue: 'Issue', transfer: 'Transfer', adjustment: 'Adjustment' }),
10
+ quantity: r.quantity.help('Negative for issues.'),
11
+ warehouse_id: r.warehouse_id.label('Warehouse'),
12
+ unit_cost: r.unit_cost.label('Unit cost').prefix('€').decimals(2),
13
+ user_id: r.user_id.label('By'),
14
+ }))
15
+ .list((r) => ({
16
+ columns: [r.moved_at, r.product_id, r.kind, r.quantity, r.warehouse_id, r.unit_cost, r.reference, r.user_id],
17
+ sort: [[r.moved_at, 'desc']],
18
+ }))
19
+ .filters((r, w) => [
20
+ w.facets(r.kind),
21
+ w.facets(r.warehouse_id),
22
+ w.dateRange(r.moved_at, { presets: ['7d', '30d', '90d', 'quarter', 'year'] }),
23
+ w.range(r.quantity, { histogram: true }),
24
+ ])
25
+ .chart((r) => ({ field: r.moved_at, range: '30d', granularity: 'day' }))
@@ -0,0 +1,17 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** crm.tags. Unique: (organization_id, name) */
5
+ export const tags = resource('tags')
6
+ .table('crm.tags')
7
+ .fields({
8
+ id: f.integer().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ name: f.text().filterable().sortable(),
11
+ color: f.text(),
12
+ created_at: f.timestamp().readOnly().dbDefault(),
13
+ updated_at: f.timestamp().readOnly().dbDefault(),
14
+ })
15
+ .primaryKey((r) => r.id)
16
+ .tenant((r) => r.organization_id)
17
+ .access(erpAccess('tags'))
@@ -0,0 +1,6 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { tags } from './data'
3
+
4
+ export const tagsView = view<typeof tags>('tags')
5
+ .names({ singular: 'Tag', plural: 'Tags' })
6
+ .list((r) => ({ columns: [r.name, r.color] }))
@@ -0,0 +1,9 @@
1
+ import { userMenu } from '@protobase/schema'
2
+
3
+ /** Account pages under the signed-in user instead of in the sidebar, then help. */
4
+ export const accountMenu = userMenu((m) => [
5
+ m.resource('organizations', { label: 'Organization', icon: 'building' }),
6
+ m.page('accounts', { label: 'Users', icon: 'users' }),
7
+ m.link('Documentation', 'https://docs.protobase.net', { icon: 'book-open' }),
8
+ m.link('Support', 'mailto:support@example.com', { icon: 'life-buoy' }),
9
+ ])
@@ -0,0 +1,19 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { erpAccess } from '../roles'
3
+
4
+ /** core.users. Unique: (organization_id, email) */
5
+ export const users = resource('users')
6
+ .table('core.users')
7
+ .fields({
8
+ id: f.integer().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ email: f.text().filterable().sortable(),
11
+ name: f.text(),
12
+ role: f.enum(['admin', 'sales', 'finance', 'warehouse']).optional().filterable().sortable(),
13
+ active: f.boolean().default(true),
14
+ created_at: f.timestamp().readOnly().dbDefault(),
15
+ updated_at: f.timestamp().readOnly().dbDefault(),
16
+ })
17
+ .primaryKey((r) => r.id)
18
+ .tenant((r) => r.organization_id)
19
+ .access(erpAccess('users'))