|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Expense Report + Expense Line — the canonical demonstration of **filtered** |
| 7 | + * roll-up `summary` fields (framework#1868). |
| 8 | + * |
| 9 | + * A plain `summary` rolls up EVERY child row (see `showcase_invoice.total`). |
| 10 | + * The power added by `summaryOperations.filter` is that ONE child object can |
| 11 | + * feed SEVERAL different parent totals, each aggregating only the child rows |
| 12 | + * that match a predicate — something that was impossible to express before and |
| 13 | + * had to be hand-maintained (and drifted the moment a real child row was added). |
| 14 | + * |
| 15 | + * Here a single `showcase_expense_line` child feeds six rollups on the report: |
| 16 | + * |
| 17 | + * • total_amount SUM(amount) — every line |
| 18 | + * • approved_amount SUM(amount) WHERE status=approved — filtered sum (equality) |
| 19 | + * • reimbursable_amount SUM(amount) WHERE billable=true — filtered sum (boolean) |
| 20 | + * • line_count COUNT — every line |
| 21 | + * • rejected_count COUNT WHERE status=rejected — filtered count (equality) |
| 22 | + * • over_limit_count COUNT WHERE amount>=500 — filtered count (operator) |
| 23 | + * |
| 24 | + * The engine keeps all six current server-side as lines are inserted / updated |
| 25 | + * / deleted. Because each recompute re-runs the whole filtered aggregate, a |
| 26 | + * line moving in or out of a predicate — e.g. a manager flipping one line's |
| 27 | + * `status` from `submitted` to `approved` in the inline grid — updates the |
| 28 | + * relevant totals on that write with no extra wiring. That is the interactive |
| 29 | + * story to drive in the running app: edit line statuses and watch |
| 30 | + * approved/rejected/reimbursable diverge from the unfiltered total. |
| 31 | + */ |
| 32 | +export const ExpenseReport = ObjectSchema.create({ |
| 33 | + name: 'showcase_expense_report', |
| 34 | + // [ADR-0090 D1] grandfather stamp: world-writable demo object so any seeded |
| 35 | + // persona (and the browser e2e) can create/edit reports without a bespoke |
| 36 | + // permission set. Belonging to no permission set, it is intentionally absent |
| 37 | + // from the access-matrix snapshot (cf. showcase_cascade). |
| 38 | + sharingModel: 'public_read_write', |
| 39 | + label: 'Expense Report', |
| 40 | + pluralLabel: 'Expense Reports', |
| 41 | + icon: 'wallet', |
| 42 | + description: 'An expense report whose parent totals are filtered roll-ups of its line items.', |
| 43 | + |
| 44 | + fields: { |
| 45 | + name: Field.text({ label: 'Report Title', required: true, searchable: true, maxLength: 120 }), |
| 46 | + employee: Field.text({ label: 'Employee', searchable: true, maxLength: 120 }), |
| 47 | + status: Field.select({ |
| 48 | + label: 'Status', |
| 49 | + required: true, |
| 50 | + options: [ |
| 51 | + { label: 'Draft', value: 'draft', default: true, color: '#94A3B8' }, |
| 52 | + { label: 'Submitted', value: 'submitted', color: '#3B82F6' }, |
| 53 | + { label: 'Approved', value: 'approved', color: '#10B981' }, |
| 54 | + { label: 'Reimbursed', value: 'reimbursed', color: '#6366F1' }, |
| 55 | + ], |
| 56 | + }), |
| 57 | + submitted_on: Field.date({ label: 'Submitted On' }), |
| 58 | + |
| 59 | + // ── Filtered roll-ups (the point of this object) ───────────────────────── |
| 60 | + // Child FK is auto-detected (showcase_expense_line.expense_report), so none |
| 61 | + // of these need `relationshipField`. |
| 62 | + |
| 63 | + /** Baseline: unfiltered — the grand total of every line. */ |
| 64 | + total_amount: Field.summary({ |
| 65 | + label: 'Total', |
| 66 | + summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: 'sum' }, |
| 67 | + }), |
| 68 | + /** Filtered SUM (equality) — only lines a manager has approved. */ |
| 69 | + approved_amount: Field.summary({ |
| 70 | + label: 'Approved', |
| 71 | + summaryOperations: { |
| 72 | + object: 'showcase_expense_line', |
| 73 | + field: 'amount', |
| 74 | + function: 'sum', |
| 75 | + filter: { status: 'approved' }, |
| 76 | + }, |
| 77 | + }), |
| 78 | + /** Filtered SUM (boolean) — only lines billable back to a client. */ |
| 79 | + reimbursable_amount: Field.summary({ |
| 80 | + label: 'Reimbursable', |
| 81 | + summaryOperations: { |
| 82 | + object: 'showcase_expense_line', |
| 83 | + field: 'amount', |
| 84 | + function: 'sum', |
| 85 | + filter: { billable: true }, |
| 86 | + }, |
| 87 | + }), |
| 88 | + /** Baseline: unfiltered line count. */ |
| 89 | + line_count: Field.summary({ |
| 90 | + label: 'Lines', |
| 91 | + summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: 'count' }, |
| 92 | + }), |
| 93 | + /** Filtered COUNT (equality) — lines a manager rejected. */ |
| 94 | + rejected_count: Field.summary({ |
| 95 | + label: 'Rejected', |
| 96 | + summaryOperations: { |
| 97 | + object: 'showcase_expense_line', |
| 98 | + field: 'amount', |
| 99 | + function: 'count', |
| 100 | + filter: { status: 'rejected' }, |
| 101 | + }, |
| 102 | + }), |
| 103 | + /** Filtered COUNT (operator) — lines at or above the $500 receipt-required |
| 104 | + * threshold. Shows the FilterCondition operator form, not just equality. */ |
| 105 | + over_limit_count: Field.summary({ |
| 106 | + label: 'Over $500', |
| 107 | + summaryOperations: { |
| 108 | + object: 'showcase_expense_line', |
| 109 | + field: 'amount', |
| 110 | + function: 'count', |
| 111 | + filter: { amount: { $gte: 500 } }, |
| 112 | + }, |
| 113 | + }), |
| 114 | + }, |
| 115 | +}); |
| 116 | + |
| 117 | +/** Expense line item — owned by its report, entered inline in the grid. */ |
| 118 | +export const ExpenseLine = ObjectSchema.create({ |
| 119 | + name: 'showcase_expense_line', |
| 120 | + label: 'Expense Line', |
| 121 | + pluralLabel: 'Expense Lines', |
| 122 | + icon: 'receipt', |
| 123 | + description: 'A single expense on a report; the parent report rolls these up with filters.', |
| 124 | + |
| 125 | + // ADR-0055: a line's access is CONTROLLED BY ITS PARENT report — the same |
| 126 | + // master-detail pattern as showcase_invoice_line. No RLS authored here; the |
| 127 | + // security layer derives it from the required master_detail relationship. |
| 128 | + sharingModel: 'controlled_by_parent', |
| 129 | + |
| 130 | + fields: { |
| 131 | + expense_report: Field.masterDetail('showcase_expense_report', { |
| 132 | + label: 'Report', |
| 133 | + required: true, |
| 134 | + deleteBehavior: 'cascade', |
| 135 | + // Thin, high-volume line items → the editable grid form factor. The |
| 136 | + // report's filtered summaries recompute as rows are saved in this grid. |
| 137 | + inlineEdit: 'grid', |
| 138 | + inlineTitle: 'Expense Lines', |
| 139 | + }), |
| 140 | + merchant: Field.text({ label: 'Merchant', required: true, maxLength: 120 }), |
| 141 | + category: Field.select({ |
| 142 | + label: 'Category', |
| 143 | + options: [ |
| 144 | + { label: 'Travel', value: 'travel' }, |
| 145 | + { label: 'Meals', value: 'meals' }, |
| 146 | + { label: 'Lodging', value: 'lodging' }, |
| 147 | + { label: 'Supplies', value: 'supplies' }, |
| 148 | + { label: 'Software', value: 'software' }, |
| 149 | + { label: 'Other', value: 'other', default: true }, |
| 150 | + ], |
| 151 | + }), |
| 152 | + amount: Field.currency({ label: 'Amount', required: true, scale: 2, min: 0 }), |
| 153 | + // Whether the expense is billable back to a client — the `billable: true` |
| 154 | + // filter on the report's `reimbursable_amount` rollup reads this. |
| 155 | + billable: Field.boolean({ label: 'Billable to client', defaultValue: false }), |
| 156 | + // Per-line approval status — a manager approves/rejects individual lines, |
| 157 | + // which is what the report's `approved_amount` / `rejected_count` filter on. |
| 158 | + status: Field.select({ |
| 159 | + label: 'Line Status', |
| 160 | + required: true, |
| 161 | + options: [ |
| 162 | + { label: 'Submitted', value: 'submitted', default: true, color: '#3B82F6' }, |
| 163 | + { label: 'Approved', value: 'approved', color: '#10B981' }, |
| 164 | + { label: 'Rejected', value: 'rejected', color: '#EF4444' }, |
| 165 | + ], |
| 166 | + }), |
| 167 | + incurred_on: Field.date({ label: 'Incurred On' }), |
| 168 | + }, |
| 169 | +}); |
0 commit comments