create-protobase 0.23.13 → 0.24.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 (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,49 @@
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('real estate pages', () => {
7
+ it('name only resources, fields, filters and actions the example 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
+
15
+ it('has a list and record view for every table', () => {
16
+ const { resources, views } = configExports(config)
17
+ const viewed = new Set(views.map((view) => view.toModel().resource))
18
+ expect(resources.map((source) => source.toModel().name).filter((name) => !viewed.has(name))).toEqual([])
19
+ expect(resources).toHaveLength(19)
20
+ })
21
+
22
+ it('lists a lease\'s rent charges on its page, newest period first, a year of them', () => {
23
+ const { resources, views } = configExports(config)
24
+ const charges = resources.map((source) => source.toModel()).find((model) => model.name === 'rent_charges')!
25
+ const lease = views.map((view) => view.toModel()).find((view) => view.resource === 'leases')!
26
+ const section = lease.layout.find((item) => item.kind === 'related' && item.title === 'Rent charges')
27
+ expect(section).toMatchObject({ resource: 'rent_charges', field: 'lease_id', sort: 'period desc', limit: 12 })
28
+ expect(section?.kind === 'related' && section.columns?.filter((column) => !Object.hasOwn(charges.fields, column))).toEqual([])
29
+ expect(section?.kind === 'related' && section.columns?.slice(-2)).toEqual(['paid_amount', 'outstanding'])
30
+ })
31
+
32
+ it('shows what is still owed on a rent charge: a read-only, filterable column formatted like the amount', () => {
33
+ const { resources, views } = configExports(config)
34
+ const charges = resources.map((source) => source.toModel()).find((model) => model.name === 'rent_charges')!
35
+ const view = views.map((entry) => entry.toModel()).find((entry) => entry.resource === 'rent_charges')!
36
+ expect(charges.fields.outstanding).toMatchObject({ type: 'decimal', readOnly: true, filterable: true, sortable: true })
37
+ expect(view.fields.outstanding).toMatchObject({ prefix: '€', decimals: 2 })
38
+ expect(view.list?.columns.slice(-2)).toEqual(['paid_amount', 'outstanding'])
39
+ })
40
+
41
+ it('lists a rent charge\'s payments on its page, newest first', () => {
42
+ const { resources, views } = configExports(config)
43
+ const payments = resources.map((source) => source.toModel()).find((model) => model.name === 'payments')!
44
+ const charge = views.map((view) => view.toModel()).find((view) => view.resource === 'rent_charges')!
45
+ const section = charge.layout.find((item) => item.kind === 'related' && item.title === 'Payments')
46
+ expect(section).toMatchObject({ resource: 'payments', field: 'charge_id', sort: 'paid_on desc', columns: ['paid_on', 'amount', 'method', 'reference'] })
47
+ expect(section?.kind === 'related' && section.columns?.filter((column) => !Object.hasOwn(payments.fields, column))).toEqual([])
48
+ })
49
+ })
@@ -0,0 +1,20 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** billing.payments. Postgres refuses payments that add up to more than their charge. */
5
+ export const payments = resource('payments')
6
+ .table('billing.payments')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ charge_id: f.relation('rent_charges').filterable().sortable(),
11
+ paid_on: f.date().filterable().sortable(),
12
+ amount: f.decimal({ precision: 10, scale: 2 }),
13
+ method: f.enum(['direct_debit', 'bank_transfer', 'ideal']).filterable().sortable(),
14
+ reference: f.text().optional(),
15
+ created_at: f.timestamp().readOnly().dbDefault(),
16
+ updated_at: f.timestamp().readOnly().dbDefault(),
17
+ })
18
+ .primaryKey((r) => r.id)
19
+ .tenant((r) => r.organization_id)
20
+ .access(access('payments'))
@@ -0,0 +1,14 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { payments } from './data'
3
+
4
+ export const paymentsView = view<typeof payments>('payments')
5
+ .names({ singular: 'Payment', plural: 'Payments' })
6
+ .fields((r) => ({
7
+ charge_id: r.charge_id.label('Charge'),
8
+ paid_on: r.paid_on.label('Paid on'),
9
+ amount: r.amount.prefix('€').decimals(2),
10
+ method: r.method.help('Direct debit is collected on the first; transfers and iDEAL come in when the tenant pays.').format('badge').valueLabels({ direct_debit: 'Direct debit', bank_transfer: 'Bank transfer', ideal: 'iDEAL' }),
11
+ }))
12
+ .list((r) => ({ columns: [r.charge_id, r.paid_on, r.amount, r.method, r.reference], sort: [[r.paid_on, 'desc']] }))
13
+ .filters((r, w) => [w.facets(r.method), w.dateRange(r.paid_on, { presets: ['7d', '30d', '90d', 'year'] })])
14
+ .chart((r) => ({ field: r.paid_on, range: '90d', granularity: 'week' }))
@@ -0,0 +1,26 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** portfolio.properties: a building or a house, with its location as latitude and longitude. */
5
+ export const properties = resource('properties')
6
+ .table('portfolio.properties')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ owner_id: f.relation('owners').filterable().sortable(),
11
+ name: f.text(),
12
+ kind: f.enum(['apartment_building', 'single_family', 'mixed_use']).filterable().sortable(),
13
+ street: f.text(),
14
+ house_number: f.text(),
15
+ postal_code: f.text().regex(/^[1-9]\d{3} [A-Z]{2}$/, 'A Dutch postal code, such as 1017 CT'),
16
+ city: f.text().filterable().sortable(),
17
+ latitude: f.decimal({ precision: 8, scale: 6 }).filterable().sortable(),
18
+ longitude: f.decimal({ precision: 9, scale: 6 }),
19
+ built_year: f.integer().min(1500).max(2100),
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
+ .search((r) => [r.name])
26
+ .access(access('properties'))
@@ -0,0 +1,21 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { properties } from './data'
3
+
4
+ export const propertiesView = view<typeof properties>('properties')
5
+ .title((r) => r.name)
6
+ .names({ singular: 'Property', plural: 'Properties' })
7
+ .fields((r) => ({
8
+ kind: r.kind.format('badge').valueLabels({ apartment_building: 'Apartment building', single_family: 'Single-family', mixed_use: 'Mixed-use' }),
9
+ owner_id: r.owner_id.label('Owner'),
10
+ latitude: r.latitude.help('Decimal degrees, WGS84.').decimals(6),
11
+ longitude: r.longitude.help('Decimal degrees, WGS84.').decimals(6),
12
+ built_year: r.built_year.label('Built'),
13
+ }))
14
+ .list((r) => ({ columns: [r.name, r.kind, r.city, r.postal_code, r.owner_id, r.built_year], search: [r.name] }))
15
+ .filters((r, w) => [w.facets(r.kind), w.facets(r.city), w.facets(r.owner_id, { search: true })])
16
+ .layout((r) => [
17
+ l.section('Property', [r.name, r.kind, r.owner_id, r.built_year]),
18
+ l.section('Address', [r.street, r.house_number, r.postal_code, r.city]),
19
+ l.section('Location', [r.latitude, r.longitude], { help: 'Where the property is on a map.' }),
20
+ l.sidebar([r.kind, r.city, r.owner_id]),
21
+ ])
@@ -0,0 +1,22 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** billing.rent_charges: one per lease and month. Unique: (lease_id, period). `paid_amount` follows the payments; `outstanding` is a generated column, amount minus paid. */
5
+ export const rentCharges = resource('rent_charges')
6
+ .table('billing.rent_charges')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ lease_id: f.relation('leases').filterable().sortable(),
11
+ period: f.date().filterable().sortable(),
12
+ due_on: f.date().filterable().sortable(),
13
+ description: f.text(),
14
+ amount: f.decimal({ precision: 10, scale: 2 }).filterable().sortable(),
15
+ paid_amount: f.decimal({ precision: 10, scale: 2 }).readOnly().default('0'),
16
+ outstanding: f.decimal({ precision: 10, scale: 2 }).readOnly().filterable().sortable(),
17
+ created_at: f.timestamp().readOnly().dbDefault(),
18
+ updated_at: f.timestamp().readOnly().dbDefault(),
19
+ })
20
+ .primaryKey((r) => r.id)
21
+ .tenant((r) => r.organization_id)
22
+ .access(access('rent_charges'))
@@ -0,0 +1,27 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { rentCharges } from './data'
3
+
4
+ export const rentChargesView = view<typeof rentCharges>('rent_charges')
5
+ .title((r) => r.description)
6
+ .names({ singular: 'Rent charge', plural: 'Rent charges' })
7
+ .fields((r) => ({
8
+ lease_id: r.lease_id.label('Lease'),
9
+ period: r.period.help('The month the charge is for.'),
10
+ due_on: r.due_on.label('Due'),
11
+ amount: r.amount.help('Rent plus service costs for the month.').prefix('€').decimals(2),
12
+ paid_amount: r.paid_amount.label('Paid').help('The sum of the payments on this charge.').prefix('€').decimals(2),
13
+ outstanding: r.outstanding.help('What is still owed: the amount minus the payments.').prefix('€').decimals(2),
14
+ }))
15
+ .list((r) => ({ columns: [r.description, r.lease_id, r.due_on, r.amount, r.paid_amount, r.outstanding], sort: [[r.period, 'desc']] }))
16
+ .filters((r, w) => [w.dateRange(r.period, { presets: ['90d', 'quarter', 'year'] }), w.range(r.amount, { histogram: true })])
17
+ .layout((r) => [
18
+ l.section('Charge', [r.lease_id, r.period, r.due_on, r.description, r.amount]),
19
+ l.related('Payments', {
20
+ resource: 'payments',
21
+ field: 'charge_id',
22
+ sort: 'paid_on desc',
23
+ columns: ['paid_on', 'amount', 'method', 'reference'],
24
+ empty: 'No payments yet.',
25
+ }),
26
+ l.sidebar([r.amount, r.paid_amount, r.outstanding]),
27
+ ])
@@ -0,0 +1,49 @@
1
+ import { resolveAccess } from '@protobase/schema'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { arrears, leases, payments, tenants, tickets } from './index'
4
+
5
+ const as = (role: string) => ({ user: { id: 6, roles: [role] } })
6
+
7
+ describe('real estate roles', () => {
8
+ // role, read, create, update, delete
9
+ it.each([
10
+ ['admin', true, true, true, true],
11
+ ['manager', true, true, true, false],
12
+ ['finance', true, false, false, false],
13
+ ['maintenance', false, false, false, false],
14
+ ] as const)('leases as %s', async (role, read, create, update, remove) => {
15
+ const access = await resolveAccess(leases, as(role))
16
+ expect(access.operations).toEqual({ list: read, read, create, update, delete: remove })
17
+ })
18
+
19
+ it.each([
20
+ ['admin', true, true, true, true],
21
+ ['manager', true, false, false, false],
22
+ ['finance', true, true, true, true],
23
+ ['maintenance', false, false, false, false],
24
+ ] as const)('payments as %s', async (role, read, create, update, remove) => {
25
+ const access = await resolveAccess(payments, as(role))
26
+ expect(access.operations).toEqual({ list: read, read, create, update, delete: remove })
27
+ })
28
+
29
+ it.each(['admin', 'manager', 'finance', 'maintenance'])('arrears are read-only for %s', async (role) => {
30
+ const access = await resolveAccess(arrears, as(role))
31
+ expect(access.operations).toMatchObject({ create: false, update: false, delete: false, read: role !== 'maintenance' })
32
+ })
33
+
34
+ it('lets maintenance change only the tickets assigned to them', async () => {
35
+ const access = await resolveAccess(tickets, as('maintenance'))
36
+ expect(access.operations).toMatchObject({ read: true, create: true, update: true, delete: false })
37
+ expect(access.rowFilter.read).toBeUndefined()
38
+ expect(access.rowFilter.update).toMatchObject({ kind: 'compare', field: { name: 'assigned_to' }, value: { value: 6 } })
39
+ })
40
+
41
+ it.each([
42
+ ['admin', true], ['manager', true], ['finance', true], ['maintenance', false],
43
+ ] as const)("shows a tenant's income and IBAN to %s: %s", async (role, visible) => {
44
+ const access = await resolveAccess(tenants, as(role))
45
+ expect(access.readableFields).toContain('phone')
46
+ expect(access.readableFields.includes('monthly_income')).toBe(visible)
47
+ expect(access.readableFields.includes('iban')).toBe(visible)
48
+ })
49
+ })
@@ -0,0 +1,45 @@
1
+ import { defineRoles } from '@protobase/schema'
2
+
3
+ /** Resources plus the area that gates tenants' income and bank details. */
4
+ export type RealEstateName =
5
+ | 'organizations' | 'users' | 'amenities' | 'owners' | 'properties' | 'units' | 'unit_amenities' | 'valuations'
6
+ | 'tenants' | 'leases' | 'lease_tenants' | 'deposits' | 'rent_charges' | 'payments' | 'arrears'
7
+ | 'vendors' | 'tickets' | 'work_orders' | 'inspections'
8
+ | 'screening'
9
+
10
+ /**
11
+ * Capabilities are `<resource|area>.<action>[.own]`. Property managers run the portfolio and the leases, finance the
12
+ * money, maintenance the tickets; `.own` tickets are those assigned to the user. The `screening` area gates a tenant's
13
+ * income and IBAN, which maintenance staff, who call tenants to plan a visit, do not see.
14
+ */
15
+ export const roles = defineRoles<RealEstateName>({
16
+ admin: ['*'],
17
+ manager: [
18
+ 'owners.read', 'owners.create', 'owners.update',
19
+ 'properties.read', 'properties.create', 'properties.update',
20
+ 'units.read', 'units.create', 'units.update', 'unit_amenities.*', 'amenities.read', 'valuations.read',
21
+ 'tenants.read', 'tenants.create', 'tenants.update', 'screening.read',
22
+ 'leases.read', 'leases.create', 'leases.update', 'lease_tenants.*', 'deposits.read',
23
+ 'rent_charges.read', 'payments.read', 'arrears.read',
24
+ 'tickets.read', 'tickets.create', 'tickets.update', 'work_orders.read', 'vendors.read', 'inspections.*',
25
+ 'organizations.read', 'users.read',
26
+ ],
27
+ finance: [
28
+ 'rent_charges.*', 'payments.*', 'arrears.read', 'deposits.*', 'valuations.*',
29
+ 'leases.read', 'lease_tenants.read', 'tenants.read', 'screening.read',
30
+ 'units.read', 'properties.read', 'owners.read', 'work_orders.read', 'vendors.read',
31
+ 'organizations.read', 'users.read',
32
+ ],
33
+ maintenance: [
34
+ 'tickets.read', 'tickets.create', 'tickets.update.own', 'work_orders.*', 'inspections.*', 'vendors.*',
35
+ 'properties.read', 'units.read', 'amenities.read', 'unit_amenities.read', 'tenants.read', 'users.read',
36
+ ],
37
+ })
38
+
39
+ /** The standard rule set for a resource: every operation needs the matching capability. */
40
+ export const access = (name: RealEstateName) => ({
41
+ read: roles.can(`${name}.read`),
42
+ create: roles.can(`${name}.create`),
43
+ update: roles.can(`${name}.update`),
44
+ delete: roles.can(`${name}.delete`),
45
+ })
@@ -0,0 +1,18 @@
1
+ import { createAdmin, configExports } from '@protobase/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import * as config from './index'
4
+
5
+ describe('real estate sidebar and user menu', () => {
6
+ const { resources, views, pages, userMenu } = configExports(config)
7
+
8
+ it('has recent groups that fit their resources', () => {
9
+ const grouped = views.map((view) => view.toModel()).filter((view) => view.nav?.recent)
10
+ expect(grouped.map((view) => view.resource).sort()).toEqual(['tickets', 'units'])
11
+ // createAdmin refuses a group or a user menu entry that does not fit, before any request.
12
+ expect(() => createAdmin({ resources, views, pages, userMenu, db: {} as never, authenticate: () => { throw new Error('No requests in this test') } })).not.toThrow()
13
+ })
14
+
15
+ it('moves the account pages into the user menu', () => {
16
+ expect(userMenu?.toUserMenuModel().items.map((item) => (item.kind === 'resource' ? item.resource : item.kind === 'page' ? item.page : item.label))).toEqual(['organizations', 'accounts', 'Documentation', 'Support'])
17
+ })
18
+ })
@@ -0,0 +1,23 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access, roles } from '../roles'
3
+
4
+ /** leasing.tenants. Income and IBAN are screening data: only roles holding `screening.read` see them, and the IBAN only when revealed. */
5
+ export const tenants = resource('tenants')
6
+ .table('leasing.tenants')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ first_name: f.text(),
11
+ last_name: f.text().filterable().sortable(),
12
+ email: f.text(),
13
+ phone: f.text().optional(),
14
+ date_of_birth: f.date(),
15
+ iban: f.text().sensitive().access({ read: roles.can('screening.read') }),
16
+ monthly_income: f.decimal({ precision: 10, scale: 2 }).access({ read: roles.can('screening.read') }),
17
+ created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
18
+ updated_at: f.timestamp().readOnly().dbDefault(),
19
+ })
20
+ .primaryKey((r) => r.id)
21
+ .tenant((r) => r.organization_id)
22
+ .search((r) => [r.first_name, r.last_name, r.email, r.phone.digitsEnd()])
23
+ .access(access('tenants'))
@@ -0,0 +1,27 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { tenants } from './data'
3
+
4
+ export const tenantsView = view<typeof tenants>('tenants')
5
+ .title((r) => r.last_name)
6
+ .searchResult((r) => ({ title: [r.first_name, r.last_name], subtitle: r.email }))
7
+ .names({ singular: 'Tenant', plural: 'Tenants' })
8
+ .fields((r) => ({
9
+ iban: r.iban.label('IBAN').format('code'),
10
+ monthly_income: r.monthly_income.help('Gross income per month, checked when the lease was signed.').prefix('€').decimals(2),
11
+ }))
12
+ .list((r) => ({ columns: [r.last_name, r.first_name, r.email, r.phone, r.created_at], sort: [[r.created_at, 'desc']], search: [r.last_name, r.email] }))
13
+ .filters((r, w) => [w.dateRange(r.created_at, { presets: ['30d', '90d', 'year'] })])
14
+ .layout((r) => [
15
+ l.section('Tenant', [r.first_name, r.last_name, r.date_of_birth, r.email, r.phone]),
16
+ l.section('Screening', [r.monthly_income, r.iban], { help: 'Seen by property managers and finance only. Showing the IBAN is logged.' }),
17
+ l.related('Homes', {
18
+ resource: 'leases',
19
+ field: 'id',
20
+ through: { resource: 'lease_tenants', field: 'tenant_id', key: 'lease_id' },
21
+ // Running indefinite leases have no end date, which sorts first
22
+ sort: 'end_date desc, start_date desc',
23
+ columns: ['number', 'unit_id.property_id', 'unit_id', 'start_date', 'end_date', 'monthly_rent'],
24
+ help: 'The properties and units this tenant rents or rented, current leases first.',
25
+ empty: 'This tenant has not signed a lease.',
26
+ }),
27
+ ])
@@ -0,0 +1,33 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /**
5
+ * maintenance.tickets. Unique: (organization_id, number). The status only moves forward (reported, scheduled, then done
6
+ * or cancelled); Postgres refuses any other step and stamps `closed_at`. Maintenance staff change the tickets assigned
7
+ * to them (`.own`).
8
+ */
9
+ export const tickets = resource('tickets')
10
+ .table('maintenance.tickets')
11
+ .fields({
12
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
13
+ organization_id: f.relation('organizations').filterable().sortable(),
14
+ number: f.text().filterable().sortable(),
15
+ property_id: f.relation('properties').filterable().sortable(),
16
+ unit_id: f.relation('units').optional().filterable().sortable(),
17
+ reported_by: f.relation('tenants').optional().filterable().sortable(),
18
+ assigned_to: f.relation('users').optional().filterable().sortable(),
19
+ category: f.enum(['heating', 'plumbing', 'electrical', 'leak', 'mould', 'locks', 'appliances', 'windows_doors', 'garden', 'pests']).filterable().sortable(),
20
+ priority: f.enum(['low', 'normal', 'high', 'urgent']).default('normal').filterable().sortable(),
21
+ status: f.enum(['reported', 'scheduled', 'done', 'cancelled']).default('reported').filterable().sortable(),
22
+ title: f.text(),
23
+ description: f.text().optional(),
24
+ reported_at: f.timestamp().filterable().sortable().dbDefault(),
25
+ closed_at: f.timestamp().readOnly().optional(),
26
+ created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
27
+ updated_at: f.timestamp().readOnly().dbDefault(),
28
+ })
29
+ .primaryKey((r) => r.id)
30
+ .tenant((r) => r.organization_id)
31
+ .owner((r) => r.assigned_to)
32
+ .search((r) => [r.number, r.title])
33
+ .access(access('tickets'))
@@ -0,0 +1,58 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { tickets } from './data'
3
+
4
+ export const ticketsView = view<typeof tickets>('tickets')
5
+ .title((r) => r.number)
6
+ .names({ singular: 'Ticket', plural: 'Tickets' })
7
+ .nav({
8
+ recent: {
9
+ status: 'priority',
10
+ tones: { urgent: 'danger', high: 'warning', normal: 'info' },
11
+ pulse: ['urgent'],
12
+ filter: 'status = "reported" OR status = "scheduled"',
13
+ orderBy: 'reported_at desc',
14
+ },
15
+ })
16
+ .fields((r) => ({
17
+ number: r.number.format('code'),
18
+ category: r.category.valueLabels({
19
+ heating: 'Heating',
20
+ plumbing: 'Plumbing',
21
+ electrical: 'Electrical',
22
+ leak: 'Leak',
23
+ mould: 'Mould',
24
+ locks: 'Locks',
25
+ appliances: 'Appliances',
26
+ windows_doors: 'Windows & doors',
27
+ garden: 'Garden',
28
+ pests: 'Pests',
29
+ }),
30
+ property_id: r.property_id.label('Property'),
31
+ unit_id: r.unit_id.label('Unit').help('Empty when the whole building is affected.'),
32
+ reported_by: r.reported_by.label('Reported by'),
33
+ assigned_to: r.assigned_to.label('Assigned to').help('The maintenance coordinator who plans the work.'),
34
+ priority: r.priority.format('badge').valueLabels({ low: 'Low', normal: 'Normal', high: 'High', urgent: 'Urgent' }),
35
+ status: r.status.help('Reported, then scheduled once a vendor visit is planned, then done or cancelled.').format('badge').valueLabels({ reported: 'Reported', scheduled: 'Scheduled', done: 'Done', cancelled: 'Cancelled' }),
36
+ reported_at: r.reported_at.label('Reported'),
37
+ closed_at: r.closed_at.label('Closed'),
38
+ }))
39
+ .list((r) => ({ columns: [r.number, r.title, r.property_id, r.category, r.priority, r.status, r.reported_at], sort: [[r.reported_at, 'desc']], search: [r.number, r.title] }))
40
+ .filters((r, w) => [
41
+ w.facets(r.status),
42
+ w.facets(r.priority),
43
+ w.facets(r.category),
44
+ w.facets(r.assigned_to),
45
+ w.dateRange(r.reported_at, { presets: ['7d', '30d', '90d', 'year'] }),
46
+ ])
47
+ .chart((r) => ({ field: r.reported_at, range: '90d', granularity: 'week' }))
48
+ .layout((r) => [
49
+ l.section('Problem', [r.title, r.description, r.category, r.priority]),
50
+ l.section('Where', [r.property_id, r.unit_id, r.reported_by]),
51
+ l.section('Handling', [r.status, r.assigned_to, r.reported_at, r.closed_at]),
52
+ l.sidebar([r.status, r.priority, r.assigned_to]),
53
+ ])
54
+ .actions((a) => [
55
+ a.update('schedule', { label: 'Mark scheduled', icon: 'calendar', set: { status: 'scheduled' } }),
56
+ a.update('complete', { label: 'Mark done', icon: 'check', set: { status: 'done' }, confirm: 'Close this ticket as done?' }),
57
+ a.update('cancel', { label: 'Cancel', icon: 'x', set: { status: 'cancelled' }, confirm: 'Cancel this ticket?' }),
58
+ ])
@@ -0,0 +1,15 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** portfolio.unit_amenities, the join between units and amenities. */
5
+ export const unitAmenities = resource('unit_amenities')
6
+ .table('portfolio.unit_amenities')
7
+ .fields({
8
+ unit_id: f.relation('units').filterable().sortable(),
9
+ amenity_code: f.relation('amenities').filterable().sortable(),
10
+ organization_id: f.relation('organizations').filterable().sortable(),
11
+ added_at: f.timestamp().dbDefault(),
12
+ })
13
+ .primaryKey((r) => [r.unit_id, r.amenity_code])
14
+ .tenant((r) => r.organization_id)
15
+ .access(access('unit_amenities'))
@@ -0,0 +1,8 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { unitAmenities } from './data'
3
+
4
+ export const unitAmenitiesView = view<typeof unitAmenities>('unit_amenities')
5
+ .nav({ hidden: true })
6
+ .names({ singular: 'Unit amenity', plural: 'Unit amenities' })
7
+ .fields((r) => ({ amenity_code: r.amenity_code.label('Amenity') }))
8
+ .list((r) => ({ columns: [r.unit_id, r.amenity_code, r.added_at] }))
@@ -0,0 +1,24 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** portfolio.units: a home that is let on its own. Unique: (property_id, label) */
5
+ export const units = resource('units')
6
+ .table('portfolio.units')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ property_id: f.relation('properties').filterable().sortable(),
11
+ label: f.text(),
12
+ floor: f.integer().default(0),
13
+ bedrooms: f.integer().min(0),
14
+ area_m2: f.integer().min(1).column('area_m2'),
15
+ energy_label: f.enum(['A++++', 'A+++', 'A++', 'A+', 'A', 'B', 'C', 'D', 'E', 'F', 'G']).filterable().sortable(),
16
+ base_rent: f.decimal({ precision: 10, scale: 2 }).filterable().sortable(),
17
+ status: f.enum(['let', 'vacant', 'renovation']).default('vacant').filterable().sortable(),
18
+ created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
19
+ updated_at: f.timestamp().readOnly().dbDefault(),
20
+ })
21
+ .primaryKey((r) => r.id)
22
+ .tenant((r) => r.organization_id)
23
+ .search((r) => [r.label])
24
+ .access(access('units'))
@@ -0,0 +1,41 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { units } from './data'
3
+
4
+ /** A lease that covers today; `end_date` is its last day. */
5
+ const running = 'start_date <= now() AND (isNull(end_date) OR end_date >= now())'
6
+
7
+ export const unitsView = view<typeof units>('units')
8
+ .title((r) => r.label)
9
+ .names({ singular: 'Unit', plural: 'Units' })
10
+ .nav({ recent: { status: 'status', tones: { vacant: 'warning', renovation: 'info' }, filter: 'status != "let"' } })
11
+ .fields((r) => ({
12
+ property_id: r.property_id.label('Property'),
13
+ area_m2: r.area_m2.label('Area').help('Living area in m².').suffix(' m²'),
14
+ energy_label: r.energy_label.format('badge'),
15
+ base_rent: r.base_rent.label('Market rent').help('What a new lease would ask today, per month.').prefix('€').decimals(2),
16
+ status: r.status.help('Let while a lease runs; vacant or under renovation between leases.').format('badge').valueLabels({ let: 'Let', vacant: 'Vacant', renovation: 'Renovation' }),
17
+ }))
18
+ .list((r) => ({ columns: [r.label, r.property_id, r.status, r.bedrooms, r.area_m2, r.energy_label, r.base_rent], search: [r.label] }))
19
+ .filters((r, w) => [w.facets(r.status), w.facets(r.energy_label), w.range(r.base_rent, { histogram: true })])
20
+ .layout((r) => [
21
+ l.section('Unit', [r.property_id, r.label, r.floor, r.bedrooms, r.area_m2, r.energy_label]),
22
+ l.section('Letting', [r.status, r.base_rent]),
23
+ l.related('Let to', {
24
+ resource: 'lease_tenants',
25
+ field: 'lease_id',
26
+ through: { resource: 'leases', field: 'unit_id', filter: running },
27
+ // `primary` sorts after `co_signer`, so the primary tenant comes first
28
+ sort: 'role desc',
29
+ columns: ['tenant_id', 'role', 'tenant_id.email', 'tenant_id.phone'],
30
+ help: 'The primary tenant and co-signers of the running lease.',
31
+ empty: 'Vacant: no lease runs today.',
32
+ }),
33
+ l.related('Running lease', {
34
+ resource: 'leases',
35
+ field: 'unit_id',
36
+ filter: running,
37
+ columns: ['number', 'term', 'start_date', 'end_date', 'monthly_rent', 'service_costs'],
38
+ empty: 'Vacant: no lease runs today.',
39
+ }),
40
+ l.sidebar([r.status, r.base_rent]),
41
+ ])
@@ -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 { access } 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', 'manager', 'finance', 'maintenance']).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(access('users'))
@@ -0,0 +1,11 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { users } from './data'
3
+
4
+ export const usersView = view<typeof users>('users')
5
+ .title((r) => r.name)
6
+ .names({ singular: 'User', plural: 'Users' })
7
+ // Reached from the records that name a user; the profile menu's Users is the accounts page.
8
+ .nav({ hidden: true })
9
+ .fields((r) => ({ role: r.role.format('badge').valueLabels({ admin: 'Admin', manager: 'Manager', finance: 'Finance', maintenance: 'Maintenance' }) }))
10
+ .list((r) => ({ columns: [r.name, r.email, r.role, r.active] }))
11
+ .filters((r, w) => [w.facets(r.role)])
@@ -0,0 +1,19 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** portfolio.valuations: a property's value over time. Unique: (property_id, source, valued_on) */
5
+ export const valuations = resource('valuations')
6
+ .table('portfolio.valuations')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ property_id: f.relation('properties').filterable().sortable(),
11
+ valued_on: f.date().filterable().sortable(),
12
+ value: f.decimal({ precision: 14, scale: 2 }),
13
+ source: f.enum(['woz', 'appraisal']).filterable().sortable(),
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(access('valuations'))
@@ -0,0 +1,13 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { valuations } from './data'
3
+
4
+ export const valuationsView = view<typeof valuations>('valuations')
5
+ .names({ singular: 'Valuation', plural: 'Valuations' })
6
+ .fields((r) => ({
7
+ property_id: r.property_id.label('Property'),
8
+ valued_on: r.valued_on.label('Valuation date'),
9
+ value: r.value.prefix('€').decimals(0),
10
+ source: r.source.help('WOZ is the municipal value set every year; an appraisal is a valuer\'s report.').format('badge').valueLabels({ woz: 'WOZ', appraisal: 'Appraisal' }),
11
+ }))
12
+ .list((r) => ({ columns: [r.property_id, r.valued_on, r.source, r.value], sort: [[r.valued_on, 'desc']] }))
13
+ .filters((r, w) => [w.facets(r.source), w.dateRange(r.valued_on, { presets: ['year'] })])