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,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { WriteEvent } from '@protobase/server'
|
|
3
|
+
import { overdueCheck } from './jobs'
|
|
4
|
+
|
|
5
|
+
const event = (operation: WriteEvent['operation'], after?: Record<string, unknown>, resource = 'invoices') =>
|
|
6
|
+
({ operation, resource: { name: resource }, user: { id: 1, roles: [] }, ...(after && { after }) }) as unknown as WriteEvent
|
|
7
|
+
|
|
8
|
+
describe('overdueCheck', () => {
|
|
9
|
+
it('checks a sent invoice the day after it is due, once per invoice', () => {
|
|
10
|
+
expect(overdueCheck(event('update', { id: '42', status: 'sent', due_at: '2026-11-01' }))).toEqual({
|
|
11
|
+
invoiceId: '42',
|
|
12
|
+
runAt: new Date('2026-11-02T00:00:00Z'),
|
|
13
|
+
key: 'invoice:42',
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('leaves drafts, paid invoices, deletes and other resources alone', () => {
|
|
18
|
+
expect(overdueCheck(event('create', { id: '1', status: 'draft', due_at: '2026-11-01' }))).toBeUndefined()
|
|
19
|
+
expect(overdueCheck(event('update', { id: '1', status: 'paid', due_at: '2026-11-01' }))).toBeUndefined()
|
|
20
|
+
expect(overdueCheck(event('delete'))).toBeUndefined()
|
|
21
|
+
expect(overdueCheck(event('update', { id: '1', status: 'sent' }, 'orders'))).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { sql } from 'kysely'
|
|
2
|
+
import { defineJob, type WriteEvent } from '@protobase/server'
|
|
3
|
+
|
|
4
|
+
/** An invoice still sent and unpaid once its due date has passed becomes overdue. */
|
|
5
|
+
export const markInvoiceOverdue = defineJob<{ invoiceId: string }>({
|
|
6
|
+
name: 'mark-invoice-overdue',
|
|
7
|
+
run: async ({ invoiceId }, { db }) => {
|
|
8
|
+
await sql`update sales.invoices set status = 'overdue' where id = ${invoiceId} and status = 'sent' and due_at < current_date`.execute(db)
|
|
9
|
+
},
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
/** Every night, the sent invoices that passed their due date, such as imported ones that never had a check enqueued. */
|
|
13
|
+
export const markOverdueInvoices = defineJob({
|
|
14
|
+
name: 'mark-overdue-invoices',
|
|
15
|
+
schedule: '0 2 * * *',
|
|
16
|
+
timezone: 'Europe/Amsterdam',
|
|
17
|
+
run: async (_, { db }) => {
|
|
18
|
+
await sql`update sales.invoices set status = 'overdue' where status = 'sent' and due_at < current_date`.execute(db)
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const day = 86_400_000
|
|
23
|
+
|
|
24
|
+
/** For a sent invoice, the check that it is paid: the day after it is due, one waiting check per invoice. */
|
|
25
|
+
export const overdueCheck = (event: WriteEvent) => {
|
|
26
|
+
if (event.resource.name !== 'invoices' || event.operation === 'delete' || event.after?.status !== 'sent') return undefined
|
|
27
|
+
const invoiceId = String(event.after.id)
|
|
28
|
+
return { invoiceId, runAt: new Date(Date.parse(String(event.after.due_at)) + day), key: `invoice:${invoiceId}` }
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** inventory.locations. Unique: (warehouse_id, path) */
|
|
5
|
+
export const locations = resource('locations')
|
|
6
|
+
.table('inventory.locations')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.integer().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
warehouse_id: f.relation('warehouses').filterable().sortable(),
|
|
11
|
+
path: f.text().readOnly().filterable(),
|
|
12
|
+
name: f.text(),
|
|
13
|
+
created_at: f.timestamp().readOnly().dbDefault(),
|
|
14
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
15
|
+
})
|
|
16
|
+
.primaryKey((r) => r.id)
|
|
17
|
+
.tenant((r) => r.organization_id)
|
|
18
|
+
.access(erpAccess('locations'))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { locations } from './data'
|
|
3
|
+
|
|
4
|
+
export const locationsView = view<typeof locations>('locations')
|
|
5
|
+
.names({ singular: 'Location', plural: 'Locations' })
|
|
6
|
+
.list((r) => ({ columns: [r.path, r.name, r.warehouse_id] }))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** sales.order_lines */
|
|
5
|
+
export const orderLines = resource('order_lines')
|
|
6
|
+
.table('sales.order_lines')
|
|
7
|
+
.fields({
|
|
8
|
+
order_id: f.relation('orders').filterable().sortable(),
|
|
9
|
+
line_no: f.integer().sortable(),
|
|
10
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
11
|
+
product_id: f.relation('products').filterable().sortable(),
|
|
12
|
+
description: f.text(),
|
|
13
|
+
quantity: f.integer(),
|
|
14
|
+
unit_price: f.decimal({ precision: 12, scale: 2 }),
|
|
15
|
+
created_at: f.timestamp().readOnly().dbDefault(),
|
|
16
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
17
|
+
})
|
|
18
|
+
.primaryKey((r) => [r.order_id, r.line_no])
|
|
19
|
+
.tenant((r) => r.organization_id)
|
|
20
|
+
.access(erpAccess('order_lines'))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { orderLines } from './data'
|
|
3
|
+
|
|
4
|
+
export const orderLinesView = view<typeof orderLines>('order_lines')
|
|
5
|
+
.nav({ hidden: true })
|
|
6
|
+
.names({ singular: 'Order line', plural: 'Order lines' })
|
|
7
|
+
.list((r) => ({ columns: [r.quantity, r.unit_price, r.product_id] }))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** sales.orders. Unique: (organization_id, number) */
|
|
5
|
+
export const orders = resource('orders')
|
|
6
|
+
.table('sales.orders')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.uuid().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
number: f.text().filterable().sortable(),
|
|
11
|
+
company_id: f.relation('companies').filterable().sortable(),
|
|
12
|
+
person_id: f.relation('people').optional().filterable().sortable(),
|
|
13
|
+
owner_id: f.relation('users').filterable().sortable(),
|
|
14
|
+
status: f.enum(['draft', 'confirmed', 'picking', 'shipped', 'delivered', 'cancelled']).filterable().sortable(),
|
|
15
|
+
currency_code: f.relation('currencies'),
|
|
16
|
+
discount_percent: f.decimal({ precision: 5, scale: 2 }).default('0'),
|
|
17
|
+
total: f.decimal({ precision: 14, scale: 2 }).readOnly().default('0').filterable().sortable(),
|
|
18
|
+
paid: f.boolean().default(false).filterable().sortable(),
|
|
19
|
+
notes: f.text().optional(),
|
|
20
|
+
created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
|
|
21
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
22
|
+
})
|
|
23
|
+
.primaryKey((r) => r.id)
|
|
24
|
+
.tenant((r) => r.organization_id)
|
|
25
|
+
.owner((r) => r.owner_id)
|
|
26
|
+
.search((r) => [r.number])
|
|
27
|
+
.access(erpAccess('orders'))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { orders } from './data'
|
|
3
|
+
|
|
4
|
+
export const ordersView = view<typeof orders>('orders')
|
|
5
|
+
.title((r) => r.number)
|
|
6
|
+
.names({ singular: 'Order', plural: 'Orders' })
|
|
7
|
+
.nav({
|
|
8
|
+
recent: {
|
|
9
|
+
status: 'status',
|
|
10
|
+
tones: { draft: 'neutral', confirmed: 'info', picking: 'warning', shipped: 'success' },
|
|
11
|
+
pulse: ['picking'],
|
|
12
|
+
filter: 'status != "delivered" AND status != "cancelled"',
|
|
13
|
+
orderBy: 'created_at desc',
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
.fields((r) => ({
|
|
17
|
+
status: r.status.help('Where the order is in its life. Moving it forward can notify the customer.').format('badge')
|
|
18
|
+
.valueLabels({ draft: 'Draft', confirmed: 'Confirmed', picking: 'Picking', shipped: 'Shipped', delivered: 'Delivered', cancelled: 'Cancelled' }),
|
|
19
|
+
total: r.total.help('Order total including VAT.').prefix('€').decimals(2),
|
|
20
|
+
discount_percent: r.discount_percent.label('Discount').help('Percentage taken off the order total.').format('percent'),
|
|
21
|
+
paid: r.paid.help('Set when the payment has been received.'),
|
|
22
|
+
created_at: r.created_at.label('Created').format('absolute'),
|
|
23
|
+
}))
|
|
24
|
+
.list((r) => ({
|
|
25
|
+
columns: [r.number, r.company_id, r.status, r.total, r.discount_percent, r.paid, r.owner_id, r.created_at],
|
|
26
|
+
sort: [[r.created_at, 'desc']],
|
|
27
|
+
search: [r.number],
|
|
28
|
+
}))
|
|
29
|
+
.filters((r, w) => [
|
|
30
|
+
w.facets(r.status),
|
|
31
|
+
w.facets(r.owner_id),
|
|
32
|
+
w.dateRange(r.created_at, { presets: ['7d', '30d', '90d', 'quarter', 'year'] }),
|
|
33
|
+
w.range(r.total, { histogram: true }),
|
|
34
|
+
w.toggle(r.paid),
|
|
35
|
+
])
|
|
36
|
+
.chart((r) => ({ field: r.created_at, range: '30d', granularity: 'day' }))
|
|
37
|
+
.saveFeedback('toast')
|
|
38
|
+
.actions((a) => [
|
|
39
|
+
a.update('markPaid', { label: 'Mark as paid', icon: 'wallet', set: { paid: true }, confirm: 'Record that the customer paid this order?' }),
|
|
40
|
+
a.action('createInvoice', { label: 'Create invoice', icon: 'file-text' }),
|
|
41
|
+
])
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** core.organizations. Unique: (slug) */
|
|
5
|
+
export const organizations = resource('organizations')
|
|
6
|
+
.table('core.organizations')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.integer().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
name: f.text(),
|
|
10
|
+
slug: f.text().filterable().sortable(),
|
|
11
|
+
country_code: f.relation('countries').filterable().sortable(),
|
|
12
|
+
currency_code: f.relation('currencies').filterable().sortable(),
|
|
13
|
+
created_at: f.timestamp().readOnly().dbDefault(),
|
|
14
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
15
|
+
})
|
|
16
|
+
.primaryKey((r) => r.id)
|
|
17
|
+
.access(erpAccess('organizations'))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { organizations } from './data'
|
|
3
|
+
|
|
4
|
+
export const organizationsView = view<typeof organizations>('organizations')
|
|
5
|
+
.names({ singular: 'Organization', plural: 'Organizations' })
|
|
6
|
+
.list((r) => ({ columns: [r.name, r.slug, r.country_code, r.currency_code] }))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** @jsxImportSource @protobase/layout */
|
|
2
|
+
import { Action, Card, CardRow, Chart, Field, Grid, ModalForm, Page, RecordCard, Show, Stat, Table, component, page } from '@protobase/layout'
|
|
3
|
+
|
|
4
|
+
/** Orders per day for the last `days` days, drawn by protobase.ui.tsx. */
|
|
5
|
+
export const OrdersPerDay = component<{ days: 7 | 30 | 90 }>('OrdersPerDay')
|
|
6
|
+
|
|
7
|
+
/** The sales team's start page: what needs attention today, and the leads to follow up. */
|
|
8
|
+
export const salesOverview = page(
|
|
9
|
+
'overview',
|
|
10
|
+
<Page title="Sales overview" description="What needs attention today: unpaid orders, overdue invoices and new leads.">
|
|
11
|
+
<Show when="orders.count = 0">
|
|
12
|
+
<Card title="No orders yet">Create the first order from Orders; this page fills up as the team sells.</Card>
|
|
13
|
+
</Show>
|
|
14
|
+
<Grid columns={4}>
|
|
15
|
+
<Stat label="Draft orders" resource="orders" filter="status = 'draft'" />
|
|
16
|
+
<Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" />
|
|
17
|
+
<Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" />
|
|
18
|
+
<Stat label="Open leads" resource="companies" filter="status = 'lead'" />
|
|
19
|
+
</Grid>
|
|
20
|
+
<Grid columns={3}>
|
|
21
|
+
<RecordCard
|
|
22
|
+
title="Biggest unpaid delivery"
|
|
23
|
+
resource="orders"
|
|
24
|
+
filter="status = 'delivered' AND paid = false"
|
|
25
|
+
sort="total desc"
|
|
26
|
+
empty="Every delivered order is paid."
|
|
27
|
+
actions={<Action name="markPaid" variant="primary" />}
|
|
28
|
+
>
|
|
29
|
+
<Grid columns={2}>
|
|
30
|
+
<Field name="number" />
|
|
31
|
+
<Field name="total" />
|
|
32
|
+
<Field name="company_id" label="Customer" />
|
|
33
|
+
<Field name="company_id.city" label="City" />
|
|
34
|
+
</Grid>
|
|
35
|
+
<Show when="company_id.status = 'dormant'">The customer has gone quiet: call before sending a reminder.</Show>
|
|
36
|
+
</RecordCard>
|
|
37
|
+
<RecordCard
|
|
38
|
+
title="Oldest overdue invoice"
|
|
39
|
+
resource="invoices"
|
|
40
|
+
filter="status = 'overdue'"
|
|
41
|
+
sort="issued_at"
|
|
42
|
+
empty="Nothing is overdue."
|
|
43
|
+
actions={[<Action name="send" label="Send reminder" />, <Action name="recordPayment" variant="primary" />]}
|
|
44
|
+
>
|
|
45
|
+
<Grid columns={2}>
|
|
46
|
+
<Field name="number" />
|
|
47
|
+
<Field name="total" />
|
|
48
|
+
<Field name="company_id" label="Customer" />
|
|
49
|
+
<Field name="due_at" label="Due" />
|
|
50
|
+
</Grid>
|
|
51
|
+
</RecordCard>
|
|
52
|
+
<Card title="Orders per day" description="The last 30 days.">
|
|
53
|
+
<OrdersPerDay days={30} />
|
|
54
|
+
</Card>
|
|
55
|
+
</Grid>
|
|
56
|
+
<Grid columns={2}>
|
|
57
|
+
<Chart title="Orders by status" resource="orders" groupBy="status" type="donut" />
|
|
58
|
+
<Chart title="Revenue by customer" description="Orders not cancelled." resource="orders" groupBy="company_id" sum="total" filter="status != 'cancelled'" limit={5} />
|
|
59
|
+
</Grid>
|
|
60
|
+
<Grid columns={2}>
|
|
61
|
+
<Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issued_at" columns={['number', 'company_id', 'due_at', 'total']} limit={5} empty="Nothing is overdue." />
|
|
62
|
+
<Table title="Latest orders" resource="orders" sort="created_at desc" columns={['number', 'company_id', 'status', 'total']} limit={5} />
|
|
63
|
+
</Grid>
|
|
64
|
+
<CardRow
|
|
65
|
+
title="New leads"
|
|
66
|
+
description="Companies that have not ordered yet."
|
|
67
|
+
resource="companies"
|
|
68
|
+
filter="status = 'lead'"
|
|
69
|
+
sort="created_at desc"
|
|
70
|
+
limit={8}
|
|
71
|
+
actions={<ModalForm mode="create" label="Add lead" resource="companies" fields={['name', 'city', 'country_code', 'email']} values={{ status: 'lead' }} />}
|
|
72
|
+
>
|
|
73
|
+
<Field name="name" label={false} />
|
|
74
|
+
<Field name="city" />
|
|
75
|
+
<Field name="email" />
|
|
76
|
+
<ModalForm mode="edit" label="Edit" fields={['name', 'city', 'email', 'phone', 'status']} />
|
|
77
|
+
</CardRow>
|
|
78
|
+
</Page>,
|
|
79
|
+
{ nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },
|
|
80
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { checkPages } from '@protobase/layout'
|
|
3
|
+
import { configExports } from '@protobase/server'
|
|
4
|
+
import * as config from './index'
|
|
5
|
+
|
|
6
|
+
describe('ERP pages', () => {
|
|
7
|
+
it('name only resources, fields, filters and actions the ERP has', () => {
|
|
8
|
+
const { resources, views, pages } = configExports(config)
|
|
9
|
+
const models = Object.fromEntries(resources.map((source) => [source.toModel().name, source.toModel()]))
|
|
10
|
+
const actions = (resource: string) => new Set(views.map((view) => view.toModel()).filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
|
|
11
|
+
expect(pages.map((entry) => entry.name)).toEqual(['accounts', 'overview'])
|
|
12
|
+
expect(() => checkPages(pages.map((entry) => entry.toModel()), { models, actions })).not.toThrow()
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** crm.people */
|
|
5
|
+
export const people = resource('people')
|
|
6
|
+
.table('crm.people')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.bigint().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
company_id: f.relation('companies').filterable().sortable(),
|
|
11
|
+
first_name: f.text(),
|
|
12
|
+
last_name: f.text(),
|
|
13
|
+
email: f.text().optional(),
|
|
14
|
+
phone: f.text().optional(),
|
|
15
|
+
job_title: f.text().optional(),
|
|
16
|
+
created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
|
|
17
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
18
|
+
})
|
|
19
|
+
.primaryKey((r) => r.id)
|
|
20
|
+
.tenant((r) => r.organization_id)
|
|
21
|
+
.access(erpAccess('people'))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { people } from './data'
|
|
3
|
+
|
|
4
|
+
export const peopleView = view<typeof people>('people')
|
|
5
|
+
.names({ singular: 'People', plural: 'People' })
|
|
6
|
+
.list((r) => ({ columns: [r.first_name, r.last_name, r.email, r.phone, r.job_title] }))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** catalog.products. Unique: (organization_id, sku) */
|
|
5
|
+
export const products = resource('products')
|
|
6
|
+
.table('catalog.products')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.bigint().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
category_id: f.relation('categories').filterable().sortable(),
|
|
11
|
+
sku: f.text().filterable().sortable(),
|
|
12
|
+
name: f.text().filterable(),
|
|
13
|
+
description: f.text().optional(),
|
|
14
|
+
price: f.decimal({ precision: 12, scale: 2 }),
|
|
15
|
+
currency_code: f.relation('currencies'),
|
|
16
|
+
attributes: f.json().dbDefault(),
|
|
17
|
+
active: f.boolean().default(true),
|
|
18
|
+
deleted_at: f.timestamp().optional(),
|
|
19
|
+
created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
|
|
20
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
21
|
+
})
|
|
22
|
+
.primaryKey((r) => r.id)
|
|
23
|
+
.softDelete((r) => r.deleted_at)
|
|
24
|
+
.tenant((r) => r.organization_id)
|
|
25
|
+
.access(erpAccess('products'))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { resolveAccess } from '@protobase/schema'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { empMaster, orders, stockMoves } from './index'
|
|
4
|
+
|
|
5
|
+
const as = (role: string) => ({ user: { id: 3, roles: [role] } })
|
|
6
|
+
|
|
7
|
+
describe('ERP roles on orders', () => {
|
|
8
|
+
const rows = [
|
|
9
|
+
// role, read, create, update, delete, read filter, update filter
|
|
10
|
+
['admin', true, true, true, true, undefined, undefined],
|
|
11
|
+
['auditor', true, false, false, false, undefined, undefined],
|
|
12
|
+
['manager', true, true, true, false, undefined, undefined],
|
|
13
|
+
['sales', true, true, true, false, 'compare', 'compare'],
|
|
14
|
+
['accountant', true, false, false, false, undefined, undefined],
|
|
15
|
+
['warehouse', true, false, true, false, undefined, undefined],
|
|
16
|
+
['editor', false, false, false, false, undefined, undefined],
|
|
17
|
+
['integration', true, true, false, false, undefined, undefined],
|
|
18
|
+
] as const
|
|
19
|
+
|
|
20
|
+
it.each(rows)('%s', async (role, read, create, update, remove, readFilter, updateFilter) => {
|
|
21
|
+
const access = await resolveAccess(orders, as(role))
|
|
22
|
+
expect(access.operations).toEqual({ list: read, read, create, update, delete: remove })
|
|
23
|
+
expect(access.rowFilter.read?.kind).toBe(readFilter)
|
|
24
|
+
expect(access.rowFilter.update?.kind).toBe(updateFilter)
|
|
25
|
+
if (readFilter) expect(access.rowFilter.read).toMatchObject({ field: { name: 'owner_id' }, value: { value: 3 } })
|
|
26
|
+
expect(access.readableFields.length > 0).toBe(read)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('hidden columns', () => {
|
|
31
|
+
// accountant holds costs.read but has no access to stock_moves at all
|
|
32
|
+
const unitCost = [
|
|
33
|
+
['admin', true], ['auditor', true], ['manager', true], ['accountant', false],
|
|
34
|
+
['sales', false], ['warehouse', false], ['editor', false], ['integration', false],
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
it.each(unitCost)('stock_moves.unit_cost for %s: %s', async (role, visible) => {
|
|
38
|
+
const access = await resolveAccess(stockMoves, as(role))
|
|
39
|
+
expect(access.readableFields.includes('unit_cost')).toBe(visible)
|
|
40
|
+
if (role === 'warehouse') expect(access.readableFields).toContain('quantity')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it.each(['admin', 'auditor', 'manager', 'accountant', 'sales', 'warehouse', 'editor', 'integration'])(
|
|
44
|
+
'emp_master.sal_amt for %s is admin only',
|
|
45
|
+
async (role) => {
|
|
46
|
+
const access = await resolveAccess(empMaster, as(role))
|
|
47
|
+
expect(access.readableFields.includes('sal_amt')).toBe(role === 'admin')
|
|
48
|
+
if (role === 'manager') expect(access.readableFields).toContain('emp_name')
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
it('a field with only a read rule follows its resource for writes', async () => {
|
|
53
|
+
const access = await resolveAccess(stockMoves, as('warehouse'))
|
|
54
|
+
expect(access.writableFields.create).toContain('unit_cost')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { defineRoles } from '@protobase/schema'
|
|
2
|
+
|
|
3
|
+
/** Resources plus the area that gates hidden columns. */
|
|
4
|
+
export type ErpName =
|
|
5
|
+
| 'categories' | 'products' | 'countries' | 'currencies' | 'organizations' | 'users'
|
|
6
|
+
| 'companies' | 'company_tags' | 'people' | 'tags' | 'emp_master' | 'locations'
|
|
7
|
+
| 'stock_levels' | 'stock_moves' | 'warehouses' | 'invoice_lines' | 'invoices'
|
|
8
|
+
| 'order_lines' | 'orders'
|
|
9
|
+
| 'costs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Capabilities are `<resource|area>.<action>[.<scope>]`. `.own` rows are those whose `.owner()`
|
|
13
|
+
* field is the user's id. The `costs` area gates cost columns; salaries are admin-only via `roles.is('admin')`.
|
|
14
|
+
*/
|
|
15
|
+
export const roles = defineRoles<ErpName>({
|
|
16
|
+
admin: ['*'],
|
|
17
|
+
auditor: ['*.read'],
|
|
18
|
+
manager: ['*.read', '*.create', '*.update', 'costs.read'],
|
|
19
|
+
sales: [
|
|
20
|
+
'orders.read.own', 'orders.create', 'orders.update.own',
|
|
21
|
+
'order_lines.read', 'order_lines.create', 'order_lines.update', 'order_lines.delete',
|
|
22
|
+
'companies.read', 'companies.create', 'companies.update',
|
|
23
|
+
'people.read', 'people.create', 'people.update',
|
|
24
|
+
'company_tags.read', 'company_tags.create', 'company_tags.delete', 'tags.read',
|
|
25
|
+
'products.read', 'categories.read', 'countries.read', 'currencies.read', 'stock_levels.read',
|
|
26
|
+
],
|
|
27
|
+
accountant: [
|
|
28
|
+
'invoices.*', 'invoice_lines.*',
|
|
29
|
+
'orders.read', 'order_lines.read', 'companies.read',
|
|
30
|
+
'products.read', 'categories.read', 'countries.read', 'currencies.read',
|
|
31
|
+
'costs.read',
|
|
32
|
+
],
|
|
33
|
+
warehouse: [
|
|
34
|
+
'stock_levels.*', 'stock_moves.*', 'locations.*', 'warehouses.read',
|
|
35
|
+
'orders.read', 'orders.update', 'order_lines.read',
|
|
36
|
+
'products.read', 'categories.read',
|
|
37
|
+
],
|
|
38
|
+
editor: [
|
|
39
|
+
'products.*', 'categories.*', 'tags.*', 'company_tags.*',
|
|
40
|
+
'countries.read', 'currencies.read',
|
|
41
|
+
],
|
|
42
|
+
integration: [
|
|
43
|
+
'orders.read', 'orders.create', 'order_lines.read', 'order_lines.create',
|
|
44
|
+
'products.read', 'companies.read', 'stock_levels.read',
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** The standard rule set for a resource: every operation needs the matching capability. */
|
|
49
|
+
export const erpAccess = (name: ErpName) => ({
|
|
50
|
+
read: roles.can(`${name}.read`),
|
|
51
|
+
create: roles.can(`${name}.create`),
|
|
52
|
+
update: roles.can(`${name}.update`),
|
|
53
|
+
delete: roles.can(`${name}.delete`),
|
|
54
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createAdmin, configExports } from '@protobase/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import * as config from './index'
|
|
4
|
+
import { roles } from './roles'
|
|
5
|
+
|
|
6
|
+
describe('ERP sidebar, user menu and bottom bar', () => {
|
|
7
|
+
const { resources, views, pages, userMenu, bottomBars } = configExports(config)
|
|
8
|
+
|
|
9
|
+
it('has recent groups that fit their resources', () => {
|
|
10
|
+
const grouped = views.map((view) => view.toModel()).filter((view) => view.nav?.recent)
|
|
11
|
+
expect(grouped.map((view) => view.resource).sort()).toEqual(['invoices', 'orders'])
|
|
12
|
+
// createAdmin refuses a group or a user menu entry that does not fit, before any request.
|
|
13
|
+
expect(() => createAdmin({ resources, views, pages, userMenu, db: {} as never, authenticate: () => { throw new Error('No requests in this test') } })).not.toThrow()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('moves the account pages into the user menu', () => {
|
|
17
|
+
expect(userMenu?.toUserMenuModel().items.map((item) => (item.kind === 'resource' ? item.resource : item.kind === 'page' ? item.page : item.label))).toEqual(['organizations', 'accounts', 'Documentation', 'Support'])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('gives the warehouse role a bottom bar that fits the resources and roles', () => {
|
|
21
|
+
expect(bottomBars.map((bar) => bar.toBottomBarModel())).toEqual([expect.objectContaining({ label: 'Warehouse', roles: ['warehouse'] })])
|
|
22
|
+
expect(() => createAdmin({ resources, views, bottomBars, db: {} as never, authenticate: () => { throw new Error('No requests in this test') }, options: { roles } })).not.toThrow()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** inventory.stock_levels */
|
|
5
|
+
export const stockLevels = resource('stock_levels')
|
|
6
|
+
.table('inventory.stock_levels')
|
|
7
|
+
.fields({
|
|
8
|
+
product_id: f.relation('products').filterable().sortable(),
|
|
9
|
+
location_id: f.relation('locations').filterable().sortable(),
|
|
10
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
11
|
+
quantity: f.integer(),
|
|
12
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
13
|
+
})
|
|
14
|
+
.primaryKey((r) => [r.product_id, r.location_id])
|
|
15
|
+
.tenant((r) => r.organization_id)
|
|
16
|
+
.access(erpAccess('stock_levels'))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess, roles } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** inventory.stock_moves */
|
|
5
|
+
export const stockMoves = resource('stock_moves')
|
|
6
|
+
.table('inventory.stock_moves')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.bigint().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
product_id: f.relation('products').filterable().sortable(),
|
|
11
|
+
warehouse_id: f.relation('warehouses').filterable().sortable(),
|
|
12
|
+
location_id: f.relation('locations').filterable().sortable(),
|
|
13
|
+
kind: f.enum(['receipt', 'issue', 'transfer', 'adjustment']).filterable().sortable(),
|
|
14
|
+
quantity: f.integer().filterable().sortable(),
|
|
15
|
+
unit_cost: f.decimal({ precision: 12, scale: 2 }).access({ read: roles.can('costs.read') }),
|
|
16
|
+
reference: f.text().optional(),
|
|
17
|
+
moved_at: f.timestamp().filterable().sortable(),
|
|
18
|
+
user_id: f.relation('users').optional().filterable().sortable(),
|
|
19
|
+
})
|
|
20
|
+
.primaryKey((r) => r.id)
|
|
21
|
+
.tenant((r) => r.organization_id)
|
|
22
|
+
.access(erpAccess('stock_moves'))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { stockMoves } from './data'
|
|
3
|
+
|
|
4
|
+
export const stockMovesView = view<typeof stockMoves>('stock_moves')
|
|
5
|
+
.names({ singular: 'Stock move', plural: 'Stock moves' })
|
|
6
|
+
.fields((r) => ({
|
|
7
|
+
moved_at: r.moved_at.label('Moved at').format('absolute'),
|
|
8
|
+
product_id: r.product_id.label('Product'),
|
|
9
|
+
kind: r.kind.label('Type').help('Receipts add stock, issues remove it, transfers move it between locations.').format('badge').valueLabels({ receipt: 'Receipt', issue: 'Issue', transfer: 'Transfer', adjustment: 'Adjustment' }),
|
|
10
|
+
quantity: r.quantity.help('Negative for issues.'),
|
|
11
|
+
warehouse_id: r.warehouse_id.label('Warehouse'),
|
|
12
|
+
unit_cost: r.unit_cost.label('Unit cost').prefix('€').decimals(2),
|
|
13
|
+
user_id: r.user_id.label('By'),
|
|
14
|
+
}))
|
|
15
|
+
.list((r) => ({
|
|
16
|
+
columns: [r.moved_at, r.product_id, r.kind, r.quantity, r.warehouse_id, r.unit_cost, r.reference, r.user_id],
|
|
17
|
+
sort: [[r.moved_at, 'desc']],
|
|
18
|
+
}))
|
|
19
|
+
.filters((r, w) => [
|
|
20
|
+
w.facets(r.kind),
|
|
21
|
+
w.facets(r.warehouse_id),
|
|
22
|
+
w.dateRange(r.moved_at, { presets: ['7d', '30d', '90d', 'quarter', 'year'] }),
|
|
23
|
+
w.range(r.quantity, { histogram: true }),
|
|
24
|
+
])
|
|
25
|
+
.chart((r) => ({ field: r.moved_at, range: '30d', granularity: 'day' }))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** crm.tags. Unique: (organization_id, name) */
|
|
5
|
+
export const tags = resource('tags')
|
|
6
|
+
.table('crm.tags')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.integer().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
name: f.text().filterable().sortable(),
|
|
11
|
+
color: f.text(),
|
|
12
|
+
created_at: f.timestamp().readOnly().dbDefault(),
|
|
13
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
14
|
+
})
|
|
15
|
+
.primaryKey((r) => r.id)
|
|
16
|
+
.tenant((r) => r.organization_id)
|
|
17
|
+
.access(erpAccess('tags'))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { userMenu } from '@protobase/schema'
|
|
2
|
+
|
|
3
|
+
/** Account pages under the signed-in user instead of in the sidebar, then help. */
|
|
4
|
+
export const accountMenu = userMenu((m) => [
|
|
5
|
+
m.resource('organizations', { label: 'Organization', icon: 'building' }),
|
|
6
|
+
m.page('accounts', { label: 'Users', icon: 'users' }),
|
|
7
|
+
m.link('Documentation', 'https://docs.protobase.net', { icon: 'book-open' }),
|
|
8
|
+
m.link('Support', 'mailto:support@example.com', { icon: 'life-buoy' }),
|
|
9
|
+
])
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** core.users. Unique: (organization_id, email) */
|
|
5
|
+
export const users = resource('users')
|
|
6
|
+
.table('core.users')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.integer().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
email: f.text().filterable().sortable(),
|
|
11
|
+
name: f.text(),
|
|
12
|
+
role: f.enum(['admin', 'sales', 'finance', 'warehouse']).optional().filterable().sortable(),
|
|
13
|
+
active: f.boolean().default(true),
|
|
14
|
+
created_at: f.timestamp().readOnly().dbDefault(),
|
|
15
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
16
|
+
})
|
|
17
|
+
.primaryKey((r) => r.id)
|
|
18
|
+
.tenant((r) => r.organization_id)
|
|
19
|
+
.access(erpAccess('users'))
|