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,42 @@
1
+ import type { Db } from '../../db/connection'
2
+ import { DAY, windowStart } from '../calendar'
3
+ import { copyRows } from '../copy'
4
+ import { aislesPerZone, binsPerAisle, zones } from '../data/layout'
5
+ import { iso, row } from '../format'
6
+ import type { World } from '../world'
7
+
8
+ const createdAt = iso(windowStart - 4 * 365 * DAY)
9
+ const label = (prefix: string, number: number) => `${prefix}_${String(number).padStart(2, '0')}`
10
+
11
+ function* warehouseRows(world: World) {
12
+ for (const org of world.orgs) {
13
+ for (const warehouse of org.warehouses) yield row(warehouse.id, org.id, warehouse.code, warehouse.name, warehouse.city, warehouse.country, createdAt, createdAt)
14
+ }
15
+ }
16
+
17
+ // Depth-first, matching the offsets in data/layout.ts.
18
+ function* locationRows(world: World) {
19
+ for (const org of world.orgs) {
20
+ for (const warehouse of org.warehouses) {
21
+ const root = warehouse.code.toLowerCase().replace('-', '_')
22
+ let id = warehouse.firstLocationId
23
+ const location = (path: string, name: string) => row(id++, org.id, warehouse.id, path, name, createdAt, createdAt)
24
+ for (let zone = 0; zone < zones; zone++) {
25
+ const zoneLabel = `zone_${String.fromCharCode(97 + zone)}`
26
+ yield location(`${root}.${zoneLabel}`, `Zone ${String.fromCharCode(65 + zone)}`)
27
+ for (let aisle = 1; aisle <= aislesPerZone; aisle++) {
28
+ const aisleLabel = label('aisle', aisle)
29
+ yield location(`${root}.${zoneLabel}.${aisleLabel}`, `Zone ${String.fromCharCode(65 + zone)} / Aisle ${aisle}`)
30
+ for (let bin = 1; bin <= binsPerAisle; bin++) {
31
+ yield location(`${root}.${zoneLabel}.${aisleLabel}.bin_${bin}`, `Zone ${String.fromCharCode(65 + zone)} / Aisle ${aisle} / Bin ${bin}`)
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ export const seedWarehouses = async (sql: Db, world: World) => {
40
+ await copyRows(sql, 'inventory.warehouses (id, organization_id, code, name, city, country_code, created_at, updated_at)', warehouseRows(world))
41
+ await copyRows(sql, 'inventory.locations (id, organization_id, warehouse_id, path, name, created_at, updated_at)', locationRows(world))
42
+ }
@@ -0,0 +1,86 @@
1
+ import { categories } from './builders/categories'
2
+ import { timestamps } from './calendar'
3
+ import { locationsPerWarehouse } from './data/layout'
4
+ import { orgSpecs, tagNames } from './data/reference'
5
+ import { rngAt, weighted } from './rng'
6
+ import { scales, type ScaleName } from './scales'
7
+
8
+ export const employeesPerOrg = 40
9
+
10
+ // Splits a scale total over the organizations by share; the last one takes the remainder.
11
+ const splitTotal = (total: number) => {
12
+ const parts: number[] = []
13
+ for (const [index, spec] of orgSpecs.entries()) {
14
+ parts.push(index === orgSpecs.length - 1 ? total - parts.reduce((sum, part) => sum + part, 0) : Math.round(total * spec.share))
15
+ }
16
+ return parts
17
+ }
18
+
19
+ // Every table's ids are allocated here, so builders can compute foreign keys without lookups.
20
+ export const buildWorld = (name: ScaleName) => {
21
+ const scale = scales[name]
22
+ const companies = splitTotal(scale.companies)
23
+ const orders = splitTotal(scale.orders)
24
+ const moves = splitTotal(scale.moves)
25
+ const next = { user: 1, company: 1, person: 1, tag: 1, category: 1, product: 1, order: 0, warehouse: 1, location: 1, move: 1, employee: 1 }
26
+
27
+ const orgs = orgSpecs.map((spec, index) => {
28
+ const companyCount = companies[index]!
29
+ const personStarts = new Uint32Array(companyCount + 1)
30
+ for (let ordinal = 0; ordinal < companyCount; ordinal++) {
31
+ personStarts[ordinal + 1] = personStarts[ordinal]! + weighted(rngAt(spec.seed + 3, ordinal), [[1, 30], [2, 40], [3, 30]] as const)
32
+ }
33
+ const users = spec.users.map(([first, initial, role], position) => ({
34
+ id: next.user + position,
35
+ name: `${first} ${initial}`,
36
+ email: `${first.toLowerCase()}@${spec.slug}.example`,
37
+ role,
38
+ }))
39
+ const warehouses = spec.warehouses.map(([code, warehouseName, city, country], position) => ({
40
+ id: next.warehouse + position,
41
+ code,
42
+ name: warehouseName,
43
+ city,
44
+ country,
45
+ firstLocationId: next.location + position * locationsPerWarehouse,
46
+ }))
47
+ const org = {
48
+ ...spec,
49
+ users,
50
+ salesUsers: users.filter((user) => user.role === 'sales'),
51
+ warehouseUsers: users.filter((user) => user.role === 'warehouse'),
52
+ warehouses,
53
+ companyCount,
54
+ companyFirstId: next.company,
55
+ personStarts,
56
+ personFirstId: next.person,
57
+ tagFirstId: next.tag,
58
+ categoryFirstId: next.category,
59
+ productCount: scale.productsPerOrg,
60
+ productFirstId: next.product,
61
+ orderCount: orders[index]!,
62
+ orderFirstId: next.order,
63
+ orderTimes: timestamps(orders[index]!, spec.seed + 5),
64
+ moveCount: moves[index]!,
65
+ moveFirstId: next.move,
66
+ employeeFirstId: next.employee,
67
+ }
68
+ next.user += users.length
69
+ next.company += companyCount
70
+ next.person += personStarts[companyCount]!
71
+ next.tag += tagNames.length
72
+ next.category += categories.length
73
+ next.product += scale.productsPerOrg
74
+ next.order += org.orderCount
75
+ next.warehouse += warehouses.length
76
+ next.location += warehouses.length * locationsPerWarehouse
77
+ next.move += org.moveCount
78
+ next.employee += employeesPerOrg
79
+ return org
80
+ })
81
+
82
+ return { name, orgs }
83
+ }
84
+
85
+ export type World = ReturnType<typeof buildWorld>
86
+ export type Org = World['orgs'][number]
@@ -0,0 +1,4 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ // The unit tests next to the config and the seed; none of them needs a database.
4
+ export default defineConfig({ test: { include: ['config/**/*.test.ts', 'seed/**/*.test.ts'] } })
@@ -0,0 +1,9 @@
1
+ # The real estate example's own settings. The nearest .env wins, so this file, not the repository's, is the one
2
+ # `protobase dev`, `protobase serve` and the db:* scripts read here. The Postgres container is the repository's (`bun run db:up`).
3
+ DATABASE_URL=postgres://protobase:protobase@localhost:55432/real_estate
4
+
5
+ # Admin store (Better Auth users, sessions, signing keys): the `auth` schema of DATABASE_URL, created by `db:migrate`.
6
+ BETTER_AUTH_URL=http://localhost:5173
7
+ # Signs cookies and encrypts the token keys. Generate your own: openssl rand -base64 32
8
+ BETTER_AUTH_SECRET=change-me-generate-with-openssl-rand-base64-32
9
+ # TRUSTED_ORIGINS=
@@ -0,0 +1,24 @@
1
+ # Working on this app
2
+
3
+ A Protobase app: each resource's data config in `config/<name>/data.ts` and its screens in `config/<name>/ui.ts`, all
4
+ exported from `config/index.ts`, over a Postgres database. Reference: https://docs.protobase.net.
5
+
6
+ ## Database changes
7
+
8
+ - Every schema change is a new migration file in `db/migrations/`, numbered after the last one (`007_viewings.sql`).
9
+ Never edit or remove a migration that exists: it has already run on the live database.
10
+ - Expand only, where possible: add tables, columns and indexes, and keep what the running app still reads. Rename or
11
+ drop only when asked to, once nothing uses it.
12
+ - A new table gets `organization_id integer not null references core.organizations (id)`, like the others; everyone
13
+ works in organization 1 (`auth/auth.ts`).
14
+
15
+ ## Never touch
16
+
17
+ - `auth/` and the `auth` schema: sign-in and accounts.
18
+ - `.github/workflows/`.
19
+
20
+ ## Scripts
21
+
22
+ The platform runs `db:migrate` (the auth schema's migrations included), `typecheck` and `test`, with their settings in
23
+ the environment: keep them working. Run `typecheck` and `test` before you finish. `db:seed:base` loads the amenities and organization 1,
24
+ and `db:seed` the sample data, replacing everything in the app's tables.
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ dist
3
+ .env
@@ -0,0 +1,4 @@
1
+ # `bun run` runs scripts and package bins on Bun even when Node is on PATH, whose `#!/usr/bin/env node` would otherwise
2
+ # start Node.
3
+ [run]
4
+ bun = true
@@ -0,0 +1,36 @@
1
+ import { PostgresDialect } from 'kysely'
2
+ import pg from 'pg'
3
+ import { betterAuthAuthenticator, createAuth, handleDroppedConnections } from '@protobase/server'
4
+ import { databaseUrl } from '../db/connection'
5
+
6
+ const secret = process.env.BETTER_AUTH_SECRET
7
+ if (!secret) throw new Error('BETTER_AUTH_SECRET is not set; generate one with `openssl rand -base64 32` and put it in .env')
8
+
9
+ // One pool across hot reloads of this module
10
+ const store = globalThis as { __realEstateAdminPool?: pg.Pool }
11
+ store.__realEstateAdminPool ??= handleDroppedConnections(new pg.Pool({ connectionString: databaseUrl, max: 5 }))
12
+
13
+ /** The admin store (Better Auth's users, sessions and signing keys) lives in this schema, next to the example's own schemas. */
14
+ export const authSchema = 'auth'
15
+
16
+ const pool = store.__realEstateAdminPool
17
+
18
+ // Tickets name their assignee by core.users' integer id, so a new user takes the next value of that table's sequence;
19
+ // their row there comes with the account (db/migrations/013_auth_users.sql).
20
+ const nextUserId = async () => {
21
+ const { rows } = await pool.query<{ id: string }>("select nextval(pg_get_serial_sequence('core.users', 'id'))::text as id")
22
+ return rows[0]!.id
23
+ }
24
+
25
+ export const auth = createAuth({
26
+ database: { dialect: new PostgresDialect({ pool }), type: 'postgres', schemaName: authSchema, transaction: true },
27
+ baseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:5173',
28
+ secret,
29
+ // No defaultRole on purpose: every account is created with an explicit role (`protobase users create --role`).
30
+ roles: ['admin', 'manager', 'finance', 'maintenance'],
31
+ generateUserId: nextUserId,
32
+ trustedOrigins: process.env.TRUSTED_ORIGINS?.split(',').filter(Boolean),
33
+ })
34
+
35
+ /** Organizations are not used yet: everyone works in organization 1. */
36
+ export const authenticate = betterAuthAuthenticator({ auth, tenant: 1 })
@@ -0,0 +1,12 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** portfolio.amenities, a reference table shared by every organization. */
5
+ export const amenities = resource('amenities')
6
+ .table('portfolio.amenities')
7
+ .fields({
8
+ code: f.text().filterable().sortable(),
9
+ name: f.text(),
10
+ })
11
+ .primaryKey((r) => r.code)
12
+ .access(access('amenities'))
@@ -0,0 +1,7 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { amenities } from './data'
3
+
4
+ export const amenitiesView = view<typeof amenities>('amenities')
5
+ .names({ singular: 'Amenity', plural: 'Amenities' })
6
+ .fields((r) => ({ code: r.code.format('code') }))
7
+ .list((r) => ({ columns: [r.name, r.code] }))
@@ -0,0 +1,19 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { roles } from '../roles'
3
+
4
+ /**
5
+ * billing.arrears, a view: the leases that still owe rent, with what they owe. It reads the open charges only, through
6
+ * a partial index, so it stays quick however much history there is. Read-only: payments change it.
7
+ */
8
+ export const arrears = resource('arrears')
9
+ .table('billing.arrears')
10
+ .fields({
11
+ lease_id: f.relation('leases').readOnly().filterable().sortable(),
12
+ organization_id: f.relation('organizations').readOnly().filterable().sortable(),
13
+ open_charges: f.integer().readOnly().filterable().sortable(),
14
+ balance: f.decimal({ precision: 12, scale: 2 }).readOnly().filterable().sortable(),
15
+ oldest_due_on: f.date().readOnly().filterable().sortable(),
16
+ })
17
+ .primaryKey((r) => r.lease_id)
18
+ .tenant((r) => r.organization_id)
19
+ .access({ read: roles.can('arrears.read'), create: () => false, update: () => false, delete: () => false })
@@ -0,0 +1,21 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { arrears } from './data'
3
+
4
+ export const arrearsView = view<typeof arrears>('arrears')
5
+ .names({ singular: 'Arrears', plural: 'Arrears' })
6
+ .help('Leases with rent outstanding: every charge minus its payments.')
7
+ .fields((r) => ({
8
+ lease_id: r.lease_id.label('Lease'),
9
+ open_charges: r.open_charges.label('Open months').help('Charges not yet paid in full.'),
10
+ balance: r.balance.label('Outstanding').prefix('€').decimals(2),
11
+ oldest_due_on: r.oldest_due_on.label('Oldest due'),
12
+ }))
13
+ .list((r) => ({ columns: [r.lease_id, r.balance, r.open_charges, r.oldest_due_on], sort: [[r.balance, 'desc']] }))
14
+ .filters((r, w) => [w.range(r.balance, { histogram: true }), w.range(r.open_charges)])
15
+ .actions((a) => [
16
+ a.action('remind', {
17
+ label: 'Send reminder',
18
+ icon: 'mail',
19
+ inputs: (i) => [i.text('note', { label: 'Note', help: 'Added to the mail under what is outstanding.', multiline: true, optional: true })],
20
+ }),
21
+ ])
@@ -0,0 +1,21 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** leasing.deposits: one per lease. */
5
+ export const deposits = resource('deposits')
6
+ .table('leasing.deposits')
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
+ amount: f.decimal({ precision: 10, scale: 2 }),
12
+ received_on: f.date(),
13
+ status: f.enum(['held', 'returned', 'partially_returned', 'withheld']).default('held').filterable().sortable(),
14
+ returned_amount: f.decimal({ precision: 10, scale: 2 }).default('0'),
15
+ returned_on: f.date().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(access('deposits'))
@@ -0,0 +1,17 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { deposits } from './data'
3
+
4
+ export const depositsView = view<typeof deposits>('deposits')
5
+ .names({ singular: 'Deposit', plural: 'Deposits' })
6
+ .fields((r) => ({
7
+ lease_id: r.lease_id.label('Lease'),
8
+ amount: r.amount.help('Usually two months of the first rent.').prefix('€').decimals(2),
9
+ status: r.status.help('Held while the lease runs; settled after the move-out inspection.').format('badge').valueLabels({ held: 'Held', returned: 'Returned', partially_returned: 'Partially returned', withheld: 'Withheld' }),
10
+ returned_amount: r.returned_amount.prefix('€').decimals(2),
11
+ }))
12
+ .list((r) => ({ columns: [r.lease_id, r.amount, r.status, r.received_on, r.returned_on, r.returned_amount] }))
13
+ .filters((r, w) => [w.facets(r.status)])
14
+ .layout((r) => [
15
+ l.section('Deposit', [r.lease_id, r.amount, r.received_on]),
16
+ l.section('Settlement', [r.status, r.returned_amount, r.returned_on], { help: 'Fill in after the move-out inspection.' }),
17
+ ])
@@ -0,0 +1,41 @@
1
+ export { organizations } from './organizations/data'
2
+ export { organizationsView } from './organizations/ui'
3
+ export { users } from './users/data'
4
+ export { usersView } from './users/ui'
5
+ export { amenities } from './amenities/data'
6
+ export { amenitiesView } from './amenities/ui'
7
+ export { owners } from './owners/data'
8
+ export { ownersView } from './owners/ui'
9
+ export { properties } from './properties/data'
10
+ export { propertiesView } from './properties/ui'
11
+ export { units } from './units/data'
12
+ export { unitsView } from './units/ui'
13
+ export { unitAmenities } from './unit_amenities/data'
14
+ export { unitAmenitiesView } from './unit_amenities/ui'
15
+ export { valuations } from './valuations/data'
16
+ export { valuationsView } from './valuations/ui'
17
+ export { tenants } from './tenants/data'
18
+ export { tenantsView } from './tenants/ui'
19
+ export { leases } from './leases/data'
20
+ export { leasesView } from './leases/ui'
21
+ export { leaseTenants } from './lease_tenants/data'
22
+ export { leaseTenantsView } from './lease_tenants/ui'
23
+ export { deposits } from './deposits/data'
24
+ export { depositsView } from './deposits/ui'
25
+ export { rentCharges } from './rent_charges/data'
26
+ export { rentChargesView } from './rent_charges/ui'
27
+ export { payments } from './payments/data'
28
+ export { paymentsView } from './payments/ui'
29
+ export { arrears } from './arrears/data'
30
+ export { arrearsView } from './arrears/ui'
31
+ export { vendors } from './vendors/data'
32
+ export { vendorsView } from './vendors/ui'
33
+ export { tickets } from './tickets/data'
34
+ export { ticketsView } from './tickets/ui'
35
+ export { workOrders } from './work_orders/data'
36
+ export { workOrdersView } from './work_orders/ui'
37
+ export { inspections } from './inspections/data'
38
+ export { inspectionsView } from './inspections/ui'
39
+ export { accountMenu } from './user-menu'
40
+ export { usersPage } from '@protobase/layout'
41
+ export { portfolioOverview } from './overview/page'
@@ -0,0 +1,22 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** maintenance.inspections: at move-in, at move-out, and now and then in between. */
5
+ export const inspections = resource('inspections')
6
+ .table('maintenance.inspections')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ unit_id: f.relation('units').filterable().sortable(),
11
+ lease_id: f.relation('leases').optional().filterable().sortable(),
12
+ inspector_id: f.relation('users').filterable().sortable(),
13
+ kind: f.enum(['move_in', 'move_out', 'periodic']).filterable().sortable(),
14
+ inspected_on: f.date().filterable().sortable(),
15
+ condition: f.enum(['good', 'fair', 'poor']).filterable().sortable(),
16
+ notes: f.text().optional(),
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('inspections'))
@@ -0,0 +1,15 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { inspections } from './data'
3
+
4
+ export const inspectionsView = view<typeof inspections>('inspections')
5
+ .names({ singular: 'Inspection', plural: 'Inspections' })
6
+ .fields((r) => ({
7
+ unit_id: r.unit_id.label('Unit'),
8
+ lease_id: r.lease_id.label('Lease'),
9
+ inspector_id: r.inspector_id.label('Inspector'),
10
+ kind: r.kind.format('badge').valueLabels({ move_in: 'Move-in', move_out: 'Move-out', periodic: 'Periodic' }),
11
+ inspected_on: r.inspected_on.label('Date'),
12
+ condition: r.condition.format('badge').valueLabels({ good: 'Good', fair: 'Fair', poor: 'Poor' }),
13
+ }))
14
+ .list((r) => ({ columns: [r.unit_id, r.kind, r.inspected_on, r.condition, r.inspector_id], sort: [[r.inspected_on, 'desc']] }))
15
+ .filters((r, w) => [w.facets(r.kind), w.facets(r.condition), w.dateRange(r.inspected_on, { presets: ['30d', '90d', 'year'] })])
@@ -0,0 +1,16 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** leasing.lease_tenants: who signed a lease, one primary tenant and any co-signers. */
5
+ export const leaseTenants = resource('lease_tenants')
6
+ .table('leasing.lease_tenants')
7
+ .fields({
8
+ lease_id: f.relation('leases').filterable().sortable(),
9
+ tenant_id: f.relation('tenants').filterable().sortable(),
10
+ organization_id: f.relation('organizations').filterable().sortable(),
11
+ role: f.enum(['primary', 'co_signer']).filterable().sortable(),
12
+ added_at: f.timestamp().dbDefault(),
13
+ })
14
+ .primaryKey((r) => [r.lease_id, r.tenant_id])
15
+ .tenant((r) => r.organization_id)
16
+ .access(access('lease_tenants'))
@@ -0,0 +1,8 @@
1
+ import { view } from '@protobase/schema'
2
+ import type { leaseTenants } from './data'
3
+
4
+ export const leaseTenantsView = view<typeof leaseTenants>('lease_tenants')
5
+ .nav({ hidden: true })
6
+ .names({ singular: 'Lease tenant', plural: 'Lease tenants' })
7
+ .fields((r) => ({ role: r.role.format('badge').valueLabels({ primary: 'Primary', co_signer: 'Co-signer' }) }))
8
+ .list((r) => ({ columns: [r.lease_id, r.tenant_id, r.role] }))
@@ -0,0 +1,29 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** leasing.leases. Unique: (organization_id, number). Postgres refuses a lease that overlaps another on its unit. */
5
+ export const leases = resource('leases')
6
+ .table('leasing.leases')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ number: f.text().filterable().sortable(),
11
+ unit_id: f.relation('units').filterable().sortable(),
12
+ term: f.enum(['indefinite', 'fixed']).default('indefinite').filterable().sortable(),
13
+ start_date: f.date().filterable().sortable(),
14
+ end_date: f.date().optional().filterable().sortable(),
15
+ monthly_rent: f.decimal({ precision: 10, scale: 2 }),
16
+ service_costs: f.decimal({ precision: 10, scale: 2 }).default('0'),
17
+ notice_given_on: f.date().optional(),
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.number])
24
+ .validate((record) => {
25
+ if (record.term === 'fixed' && !record.end_date) return [{ field: 'end_date', message: 'A fixed-term lease needs an end date.' }]
26
+ if (record.end_date && record.start_date && record.end_date < record.start_date) return [{ field: 'end_date', message: 'The lease cannot end before it starts.' }]
27
+ return undefined
28
+ })
29
+ .access(access('leases'))
@@ -0,0 +1,33 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { leases } from './data'
3
+
4
+ export const leasesView = view<typeof leases>('leases')
5
+ .title((r) => r.number)
6
+ .names({ singular: 'Lease', plural: 'Leases' })
7
+ .fields((r) => ({
8
+ number: r.number.help('Assigned when the lease is signed.').format('code'),
9
+ unit_id: r.unit_id.label('Unit'),
10
+ term: r.term.help('Indefinite leases run until notice; fixed ones end on their end date.').format('badge').valueLabels({ indefinite: 'Indefinite', fixed: 'Fixed' }),
11
+ start_date: r.start_date.label('Start'),
12
+ end_date: r.end_date.label('End').help('The last day of the tenancy. Empty while an indefinite lease runs.'),
13
+ monthly_rent: r.monthly_rent.help('Current rent; it rises every 1 July.').prefix('€').decimals(2),
14
+ service_costs: r.service_costs.help('Monthly advance for shared services, billed with the rent.').prefix('€').decimals(2),
15
+ notice_given_on: r.notice_given_on.label('Notice given'),
16
+ }))
17
+ .list((r) => ({ columns: [r.number, r.unit_id, r.term, r.start_date, r.end_date, r.monthly_rent], sort: [[r.start_date, 'desc']], search: [r.number] }))
18
+ .filters((r, w) => [w.facets(r.term), w.dateRange(r.start_date, { presets: ['90d', 'year'] }), w.dateRange(r.end_date, { presets: ['90d', 'year'] })])
19
+ .layout((r) => [
20
+ l.section('Lease', [r.unit_id, r.term, r.start_date, r.end_date, r.notice_given_on]),
21
+ l.section('Rent', [r.monthly_rent, r.service_costs]),
22
+ l.related('Rent charges', {
23
+ resource: 'rent_charges',
24
+ field: 'lease_id',
25
+ sort: 'period desc',
26
+ columns: ['period', 'due_on', 'description', 'amount', 'paid_amount', 'outstanding'],
27
+ // a year of monthly charges
28
+ limit: 12,
29
+ empty: 'No rent charged yet.',
30
+ }),
31
+ l.sidebar([r.term, r.monthly_rent]),
32
+ ])
33
+ .saveFeedback('button')
@@ -0,0 +1,17 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } 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
+ city: f.text(),
12
+ kvk_number: f.text(),
13
+ created_at: f.timestamp().readOnly().dbDefault(),
14
+ updated_at: f.timestamp().readOnly().dbDefault(),
15
+ })
16
+ .primaryKey((r) => r.id)
17
+ .access(access('organizations'))
@@ -0,0 +1,7 @@
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
+ .fields((r) => ({ kvk_number: r.kvk_number.label('KvK number').help('Chamber of Commerce registration.').format('code') }))
7
+ .list((r) => ({ columns: [r.name, r.slug, r.city, r.kvk_number] }))
@@ -0,0 +1,57 @@
1
+ /** @jsxImportSource @protobase/layout */
2
+ import { Action, Card, Field, Grid, Page, RecordCard, Show, Stat, Table, page } from '@protobase/layout'
3
+
4
+ /** The property manager's start page: occupancy, rent outstanding and the maintenance that is still open. */
5
+ export const portfolioOverview = page(
6
+ 'overview',
7
+ <Page title="Portfolio overview" description="Occupancy, arrears and open maintenance across the portfolio.">
8
+ <Show when="units.count = 0">
9
+ <Card title="No units yet">Add a property and its units; this page fills up as leases start.</Card>
10
+ </Show>
11
+ <Grid columns={4}>
12
+ <Stat label="Units" resource="units" />
13
+ <Stat label="Vacant" resource="units" filter="status = 'vacant'" description="Ready to let" />
14
+ <Stat label="Leases in arrears" resource="arrears" />
15
+ <Stat label="Open tickets" resource="tickets" filter="status = 'reported' OR status = 'scheduled'" />
16
+ </Grid>
17
+ <Grid columns={2}>
18
+ <RecordCard
19
+ title="Largest arrears"
20
+ resource="arrears"
21
+ sort="balance desc"
22
+ empty="Every tenant is paid up."
23
+ actions={<Action name="remind" label="Send reminder" variant="primary" />}
24
+ >
25
+ <Grid columns={2}>
26
+ <Field name="lease_id" label="Lease" />
27
+ <Field name="balance" label="Outstanding" />
28
+ <Field name="open_charges" label="Open months" />
29
+ <Field name="oldest_due_on" label="Oldest due" />
30
+ </Grid>
31
+ <Field name="lease_id.unit_id" label="Unit" />
32
+ </RecordCard>
33
+ <RecordCard
34
+ title="Oldest urgent ticket"
35
+ resource="tickets"
36
+ filter="priority = 'urgent' AND (status = 'reported' OR status = 'scheduled')"
37
+ sort="reported_at"
38
+ empty="No urgent tickets are open."
39
+ actions={<Action name="complete" variant="primary" />}
40
+ >
41
+ <Grid columns={2}>
42
+ <Field name="number" />
43
+ <Field name="status" />
44
+ <Field name="property_id" label="Property" />
45
+ <Field name="reported_at" label="Reported" />
46
+ </Grid>
47
+ <Field name="title" />
48
+ </RecordCard>
49
+ </Grid>
50
+ <Grid columns={2}>
51
+ <Table title="Arrears" resource="arrears" sort="balance desc" columns={['lease_id', 'balance', 'open_charges', 'oldest_due_on']} limit={5} empty="Every tenant is paid up." />
52
+ <Table title="Open tickets" resource="tickets" filter="status = 'reported' OR status = 'scheduled'" sort="reported_at desc" columns={['number', 'title', 'priority', 'status']} limit={5} empty="Nothing is open." />
53
+ </Grid>
54
+ <Table title="Vacant units" resource="units" filter="status = 'vacant'" columns={['label', 'property_id', 'bedrooms', 'base_rent']} limit={5} empty="Every unit is let." />
55
+ </Page>,
56
+ { nav: { group: 'Portfolio', order: 0 }, icon: 'layout-dashboard' },
57
+ )
@@ -0,0 +1,22 @@
1
+ import { f, resource } from '@protobase/schema'
2
+ import { access } from '../roles'
3
+
4
+ /** portfolio.owners: the landlords whose properties the firm manages. */
5
+ export const owners = resource('owners')
6
+ .table('portfolio.owners')
7
+ .fields({
8
+ id: f.bigint().readOnly().filterable().sortable().dbDefault(),
9
+ organization_id: f.relation('organizations').filterable().sortable(),
10
+ name: f.text(),
11
+ kind: f.enum(['private', 'company', 'pension_fund', 'housing_association']).filterable().sortable(),
12
+ email: f.text().optional(),
13
+ phone: f.text().optional(),
14
+ iban: f.text().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/, 'An IBAN without spaces, such as NL91ABNA0417164300'),
15
+ management_fee_percent: f.decimal({ precision: 4, scale: 2 }).default('6'),
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
+ .search((r) => [r.name])
22
+ .access(access('owners'))
@@ -0,0 +1,17 @@
1
+ import { l, view } from '@protobase/schema'
2
+ import type { owners } from './data'
3
+
4
+ export const ownersView = view<typeof owners>('owners')
5
+ .title((r) => r.name)
6
+ .names({ singular: 'Owner', plural: 'Owners' })
7
+ .fields((r) => ({
8
+ kind: r.kind.format('badge').valueLabels({ private: 'Private', company: 'Company', pension_fund: 'Pension fund', housing_association: 'Housing association' }),
9
+ iban: r.iban.label('IBAN').help('Where the rent, less the management fee, is paid out.').format('code'),
10
+ management_fee_percent: r.management_fee_percent.label('Management fee').help('Share of the collected rent the firm keeps.').format('percent'),
11
+ }))
12
+ .list((r) => ({ columns: [r.name, r.kind, r.email, r.management_fee_percent], search: [r.name] }))
13
+ .filters((r, w) => [w.facets(r.kind)])
14
+ .layout((r) => [
15
+ l.section('Owner', [r.name, r.kind, r.email, r.phone]),
16
+ l.section('Payout', [r.iban, r.management_fee_percent]),
17
+ ])