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.
- package/dist/ask-preset.js +25 -0
- package/dist/create-project.js +7 -6
- package/dist/index.js +19 -9
- package/dist/next-steps.js +8 -2
- package/dist/presets.js +17 -0
- package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
- package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
- package/dist/templates/erp/.env.example +14 -0
- package/dist/templates/erp/AGENTS.md +24 -0
- package/dist/templates/erp/auth/auth.ts +68 -0
- package/dist/templates/erp/components/orders-per-day.tsx +21 -0
- package/dist/templates/erp/config/bottom-bar.ts +9 -0
- package/dist/templates/erp/config/categories/data.ts +20 -0
- package/dist/templates/erp/config/categories/ui.ts +11 -0
- package/dist/templates/erp/config/companies/data.ts +23 -0
- package/dist/templates/erp/config/companies/ui.ts +22 -0
- package/dist/templates/erp/config/company_tags/data.ts +16 -0
- package/dist/templates/erp/config/company_tags/ui.ts +7 -0
- package/dist/templates/erp/config/countries/data.ts +13 -0
- package/dist/templates/erp/config/countries/ui.ts +6 -0
- package/dist/templates/erp/config/currencies/data.ts +14 -0
- package/dist/templates/erp/config/currencies/ui.ts +6 -0
- package/dist/templates/erp/config/emp_master/data.ts +19 -0
- package/dist/templates/erp/config/emp_master/ui.ts +7 -0
- package/dist/templates/erp/config/index.ts +42 -0
- package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
- package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
- package/dist/templates/erp/config/invoices/data.ts +26 -0
- package/dist/templates/erp/config/invoices/ui.ts +38 -0
- package/dist/templates/erp/config/jobs.test.ts +23 -0
- package/dist/templates/erp/config/jobs.ts +29 -0
- package/dist/templates/erp/config/locations/data.ts +18 -0
- package/dist/templates/erp/config/locations/ui.ts +6 -0
- package/dist/templates/erp/config/order_lines/data.ts +20 -0
- package/dist/templates/erp/config/order_lines/ui.ts +7 -0
- package/dist/templates/erp/config/orders/data.ts +27 -0
- package/dist/templates/erp/config/orders/ui.ts +41 -0
- package/dist/templates/erp/config/organizations/data.ts +17 -0
- package/dist/templates/erp/config/organizations/ui.ts +6 -0
- package/dist/templates/erp/config/overview/page.tsx +80 -0
- package/dist/templates/erp/config/pages.test.ts +14 -0
- package/dist/templates/erp/config/people/data.ts +21 -0
- package/dist/templates/erp/config/people/ui.ts +6 -0
- package/dist/templates/erp/config/products/data.ts +25 -0
- package/dist/templates/erp/config/products/ui.ts +6 -0
- package/dist/templates/erp/config/roles.test.ts +56 -0
- package/dist/templates/erp/config/roles.ts +54 -0
- package/dist/templates/erp/config/shell.test.ts +24 -0
- package/dist/templates/erp/config/stock_levels/data.ts +16 -0
- package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
- package/dist/templates/erp/config/stock_moves/data.ts +22 -0
- package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
- package/dist/templates/erp/config/tags/data.ts +17 -0
- package/dist/templates/erp/config/tags/ui.ts +6 -0
- package/dist/templates/erp/config/user-menu.ts +9 -0
- package/dist/templates/erp/config/users/data.ts +19 -0
- package/dist/templates/erp/config/users/ui.ts +9 -0
- package/dist/templates/erp/config/warehouses/data.ts +19 -0
- package/dist/templates/erp/config/warehouses/ui.ts +6 -0
- package/dist/templates/erp/data.ts +3 -0
- package/dist/templates/erp/db/connection.ts +7 -0
- package/dist/templates/erp/db/drop.ts +9 -0
- package/dist/templates/erp/db/kysely.ts +13 -0
- package/dist/templates/erp/db/migrations/001_core.sql +48 -0
- package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
- package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
- package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
- package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
- package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
- package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
- package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
- package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
- package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
- package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
- package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
- package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
- package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
- package/dist/templates/erp/package.json +38 -0
- package/dist/templates/erp/protobase.config.ts +22 -0
- package/dist/templates/erp/protobase.ui.tsx +14 -0
- package/dist/templates/erp/seed/auth-organizations.ts +11 -0
- package/dist/templates/erp/seed/base-organization.test.ts +17 -0
- package/dist/templates/erp/seed/base-organization.ts +10 -0
- package/dist/templates/erp/seed/base.ts +22 -0
- package/dist/templates/erp/seed/builders/categories.ts +18 -0
- package/dist/templates/erp/seed/builders/company.ts +61 -0
- package/dist/templates/erp/seed/builders/invoice.ts +31 -0
- package/dist/templates/erp/seed/builders/order.ts +66 -0
- package/dist/templates/erp/seed/builders/product.ts +46 -0
- package/dist/templates/erp/seed/calendar.ts +60 -0
- package/dist/templates/erp/seed/cli.ts +54 -0
- package/dist/templates/erp/seed/copy.ts +22 -0
- package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
- package/dist/templates/erp/seed/data/layout.ts +19 -0
- package/dist/templates/erp/seed/data/names.ts +44 -0
- package/dist/templates/erp/seed/data/reference.ts +53 -0
- package/dist/templates/erp/seed/format.ts +24 -0
- package/dist/templates/erp/seed/maintenance.ts +63 -0
- package/dist/templates/erp/seed/rng.ts +36 -0
- package/dist/templates/erp/seed/scales.ts +9 -0
- package/dist/templates/erp/seed/steps/catalog.ts +34 -0
- package/dist/templates/erp/seed/steps/crm.ts +62 -0
- package/dist/templates/erp/seed/steps/hr.ts +26 -0
- package/dist/templates/erp/seed/steps/ledger.ts +88 -0
- package/dist/templates/erp/seed/steps/reference.ts +24 -0
- package/dist/templates/erp/seed/steps/sales.ts +58 -0
- package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
- package/dist/templates/erp/seed/world.ts +86 -0
- package/dist/templates/erp/vitest.config.ts +4 -0
- package/dist/templates/real-estate/.env.example +9 -0
- package/dist/templates/real-estate/AGENTS.md +24 -0
- package/dist/templates/real-estate/_.gitignore +3 -0
- package/dist/templates/real-estate/_bunfig.toml +4 -0
- package/dist/templates/real-estate/auth/auth.ts +36 -0
- package/dist/templates/real-estate/config/amenities/data.ts +12 -0
- package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
- package/dist/templates/real-estate/config/arrears/data.ts +19 -0
- package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
- package/dist/templates/real-estate/config/deposits/data.ts +21 -0
- package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
- package/dist/templates/real-estate/config/index.ts +41 -0
- package/dist/templates/real-estate/config/inspections/data.ts +22 -0
- package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
- package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
- package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
- package/dist/templates/real-estate/config/leases/data.ts +29 -0
- package/dist/templates/real-estate/config/leases/ui.ts +33 -0
- package/dist/templates/real-estate/config/organizations/data.ts +17 -0
- package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
- package/dist/templates/real-estate/config/overview/page.tsx +57 -0
- package/dist/templates/real-estate/config/owners/data.ts +22 -0
- package/dist/templates/real-estate/config/owners/ui.ts +17 -0
- package/dist/templates/real-estate/config/pages.test.ts +49 -0
- package/dist/templates/real-estate/config/payments/data.ts +20 -0
- package/dist/templates/real-estate/config/payments/ui.ts +14 -0
- package/dist/templates/real-estate/config/properties/data.ts +26 -0
- package/dist/templates/real-estate/config/properties/ui.ts +21 -0
- package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
- package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
- package/dist/templates/real-estate/config/roles.test.ts +49 -0
- package/dist/templates/real-estate/config/roles.ts +45 -0
- package/dist/templates/real-estate/config/shell.test.ts +18 -0
- package/dist/templates/real-estate/config/tenants/data.ts +23 -0
- package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
- package/dist/templates/real-estate/config/tickets/data.ts +33 -0
- package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
- package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
- package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
- package/dist/templates/real-estate/config/units/data.ts +24 -0
- package/dist/templates/real-estate/config/units/ui.ts +41 -0
- package/dist/templates/real-estate/config/user-menu.ts +9 -0
- package/dist/templates/real-estate/config/users/data.ts +19 -0
- package/dist/templates/real-estate/config/users/ui.ts +11 -0
- package/dist/templates/real-estate/config/valuations/data.ts +19 -0
- package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
- package/dist/templates/real-estate/config/vendors/data.ts +21 -0
- package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
- package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
- package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
- package/dist/templates/real-estate/db/connection.ts +7 -0
- package/dist/templates/real-estate/db/drop.ts +9 -0
- package/dist/templates/real-estate/db/kysely.ts +13 -0
- package/dist/templates/real-estate/db/migrate.ts +30 -0
- package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
- package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
- package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
- package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
- package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
- package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
- package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
- package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
- package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
- package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
- package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
- package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
- package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
- package/dist/templates/real-estate/node-types.d.ts +1 -0
- package/dist/templates/real-estate/package.json +38 -0
- package/dist/templates/real-estate/protobase.config.ts +5 -0
- package/dist/templates/real-estate/protobase.ui.tsx +16 -0
- package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
- package/dist/templates/real-estate/seed/base-organization.ts +10 -0
- package/dist/templates/real-estate/seed/base.ts +18 -0
- package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
- package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
- package/dist/templates/real-estate/seed/builders/property.ts +98 -0
- package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
- package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
- package/dist/templates/real-estate/seed/calendar.ts +77 -0
- package/dist/templates/real-estate/seed/cli.ts +50 -0
- package/dist/templates/real-estate/seed/copy.ts +22 -0
- package/dist/templates/real-estate/seed/data/names.ts +31 -0
- package/dist/templates/real-estate/seed/data/reference.ts +63 -0
- package/dist/templates/real-estate/seed/format.ts +24 -0
- package/dist/templates/real-estate/seed/iban.ts +15 -0
- package/dist/templates/real-estate/seed/maintenance.ts +63 -0
- package/dist/templates/real-estate/seed/rng.ts +36 -0
- package/dist/templates/real-estate/seed/scales.ts +9 -0
- package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
- package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
- package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
- package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
- package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
- package/dist/templates/real-estate/seed/world.ts +92 -0
- package/dist/templates/real-estate/tsconfig.json +16 -0
- package/dist/templates/real-estate/vitest.config.ts +4 -0
- package/dist/templates/scratch/_.gitignore +3 -0
- package/dist/templates/scratch/_bunfig.toml +4 -0
- package/dist/templates/scratch/db/migrate.ts +30 -0
- package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
- package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
- package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
- package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
- package/dist/templates/scratch/node-types.d.ts +1 -0
- package/dist/{template → templates/scratch}/package.json +5 -5
- package/dist/templates/scratch/tsconfig.json +16 -0
- package/package.json +2 -2
- /package/dist/{template → templates/erp}/_.gitignore +0 -0
- /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
- /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
- /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
- /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
- /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
- /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
- /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
- /package/dist/{template → templates/erp}/tsconfig.json +0 -0
- /package/dist/{template → templates/scratch}/.env.example +0 -0
- /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
- /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
- /package/dist/{template → templates/scratch}/config/index.ts +0 -0
- /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
- /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
- /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
- /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
- /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'] })])
|