Skip to content

Commit 51c372f

Browse files
committed
feat(showcase): expense-report example demonstrating filtered roll-up summaries (#1868)
Adds `showcase_expense_report` + `showcase_expense_line` — the headline demo of `summaryOperations.filter`: one expense-line child feeds SIX parent roll-ups, each aggregating only the lines a filter matches: total_amount SUM(amount) every line approved_amount SUM(amount) WHERE status=approved filtered sum (equality) reimbursable_amount SUM(amount) WHERE billable=true filtered sum (boolean) line_count COUNT every line rejected_count COUNT WHERE status=rejected filtered count (equality) over_limit_count COUNT WHERE amount>=500 filtered count (operator) Master-detail with an inline line-item grid (like showcase_invoice), so the interactive story — flip a line's status and watch approved/rejected/ reimbursable diverge from the unfiltered total — is drivable in the app. Seed data is chosen so all six show distinct non-zero values on first boot. Wired into the object registry, seed set, and the Data Model nav group. Verified end-to-end in the running showcase: the six rollups compute the expected values from seed, and recompute when a child line's status flips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbK4FqcHwp9jSwdhTtxYuC
1 parent 1ec693c commit 51c372f

4 files changed

Lines changed: 210 additions & 1 deletion

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
});

examples/app-showcase/src/data/objects/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { Category } from './category.object.js';
77
export { BusinessUnit } from './business-unit.object.js';
88
export { Team, ProjectMembership } from './team.object.js';
99
export { Product, Invoice, InvoiceLine } from './invoice.object.js';
10+
export { ExpenseReport, ExpenseLine } from './expense-report.object.js';
1011
export { FieldZoo } from './field-zoo.object.js';
1112
export { CascadingSelect } from './cascading-select.object.js';
1213
export { Preference } from './preference.object.js';

examples/app-showcase/src/data/seed/index.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Category } from '../objects/category.object.js';
1010
import { BusinessUnit } from '../objects/business-unit.object.js';
1111
import { Team, ProjectMembership } from '../objects/team.object.js';
1212
import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js';
13+
import { ExpenseReport, ExpenseLine } from '../objects/expense-report.object.js';
1314
import { Contact } from '../objects/contact.object.js';
1415
import { Inquiry } from '../objects/inquiry.object.js';
1516
import { FieldZoo } from '../objects/field-zoo.object.js';
@@ -300,6 +301,41 @@ const invoiceLines = defineSeed(InvoiceLine, {
300301
],
301302
});
302303

304+
// Expense reports + lines — the filtered-rollup demo (framework#1868). Line
305+
// amounts, `billable`, and per-line `status` are chosen so the report's SIX
306+
// summaries show visibly DIFFERENT values on first boot: the unfiltered total
307+
// vs the approved-only sum vs the billable-only sum, etc. Drive the interactive
308+
// story in the app by flipping a line's status in the inline grid and watching
309+
// approved_amount / rejected_count move. Merchants are unique → externalId.
310+
const expenseReports = defineSeed(ExpenseReport, {
311+
mode: 'upsert',
312+
externalId: 'name',
313+
records: [
314+
{ name: 'EXP-2001', employee: 'Ada Lovelace', status: 'submitted', submitted_on: cel`daysAgo(5)` },
315+
{ name: 'EXP-2002', employee: 'Linus Torvalds', status: 'approved', submitted_on: cel`daysAgo(12)` },
316+
{ name: 'EXP-2003', employee: 'Grace Hopper', status: 'draft' },
317+
],
318+
});
319+
320+
const expenseLines = defineSeed(ExpenseLine, {
321+
mode: 'upsert',
322+
externalId: 'merchant',
323+
records: [
324+
// EXP-2001 → total 1500.50 · approved 960 · reimbursable 512 · rejected 0 · over$500 2
325+
{ merchant: 'United Airlines', expense_report: 'EXP-2001', category: 'travel', amount: 620, billable: false, status: 'approved', incurred_on: cel`daysAgo(9)` },
326+
{ merchant: 'Marriott Downtown', expense_report: 'EXP-2001', category: 'lodging', amount: 340, billable: false, status: 'approved', incurred_on: cel`daysAgo(8)` },
327+
{ merchant: 'Chipotle', expense_report: 'EXP-2001', category: 'meals', amount: 28.5, billable: false, status: 'submitted', incurred_on: cel`daysAgo(8)` },
328+
{ merchant: 'AWS', expense_report: 'EXP-2001', category: 'software', amount: 512, billable: true, status: 'submitted', incurred_on: cel`daysAgo(7)` },
329+
// EXP-2002 → total 917 · approved 825 · reimbursable 825 · rejected 1 · over$500 1
330+
{ merchant: 'Delta Air Lines', expense_report: 'EXP-2002', category: 'travel', amount: 780, billable: true, status: 'approved', incurred_on: cel`daysAgo(15)` },
331+
{ merchant: 'Uber', expense_report: 'EXP-2002', category: 'travel', amount: 45, billable: true, status: 'approved', incurred_on: cel`daysAgo(14)` },
332+
{ merchant: 'Staples', expense_report: 'EXP-2002', category: 'supplies', amount: 92, billable: false, status: 'rejected', incurred_on: cel`daysAgo(14)` },
333+
// EXP-2003 (draft) → total 225.75 · approved 0 · reimbursable 0 · rejected 0 · over$500 0
334+
{ merchant: 'Hilton Garden Inn', expense_report: 'EXP-2003', category: 'lodging', amount: 210, billable: false, status: 'submitted', incurred_on: cel`daysAgo(3)` },
335+
{ merchant: 'Starbucks', expense_report: 'EXP-2003', category: 'meals', amount: 15.75, billable: false, status: 'submitted', incurred_on: cel`daysAgo(2)` },
336+
],
337+
});
338+
303339
// Inquiries so the staff triage list (inquiry views + Contact Form page)
304340
// renders on first boot — the "every view renders real data" principle. One
305341
// per status; the `closed` row doubles as live prey for InquiryPurgeFlow.
@@ -337,4 +373,4 @@ const announcements = defineSeed(Announcement, {
337373
],
338374
});
339375

340-
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences, announcements];
376+
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, expenseReports, expenseLines, preferences, announcements];

examples/app-showcase/src/ui/apps/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export const ShowcaseApp = App.create({
5252
{ id: 'nav_accounts', type: 'object', objectName: 'showcase_account', label: 'Accounts', icon: 'building' },
5353
{ id: 'nav_contacts', type: 'object', objectName: 'showcase_contact', label: 'Contacts', icon: 'user' },
5454
{ id: 'nav_invoices', type: 'object', objectName: 'showcase_invoice', label: 'Invoices', icon: 'receipt' },
55+
// Filtered roll-up summary demo (framework#1868): one expense-line child
56+
// feeds six parent totals, each aggregating only the lines a filter matches.
57+
{ id: 'nav_expense_reports', type: 'object', objectName: 'showcase_expense_report', label: 'Expense Reports', icon: 'wallet' },
5558
{ id: 'nav_products', type: 'object', objectName: 'showcase_product', label: 'Products', icon: 'package' },
5659
{ id: 'nav_teams', type: 'object', objectName: 'showcase_team', label: 'Teams', icon: 'users' },
5760
{ id: 'nav_categories', type: 'object', objectName: 'showcase_category', label: 'Categories', icon: 'list-tree' },

0 commit comments

Comments
 (0)