diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 1c56291e29..90f94c400f 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -20,8 +20,8 @@ import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; -import { ChartGalleryDashboard, OpsDashboard } from './src/ui/dashboards/index.js'; -import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/ui/datasets/index.js'; +import { ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard } from './src/ui/dashboards/index.js'; +import { ShowcaseTaskDataset, ShowcaseProjectDataset, ShowcaseInvoiceDataset, ShowcaseAccountDataset } from './src/ui/datasets/index.js'; import { allReports } from './src/ui/reports/index.js'; import { allActions } from './src/ui/actions/index.js'; import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage } from './src/ui/pages/index.js'; @@ -183,9 +183,9 @@ export default defineStack({ portals: allPortals, views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews], pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage], - dashboards: [ChartGalleryDashboard, OpsDashboard], + dashboards: [ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard], books: allBooks, - datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], + datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset, ShowcaseInvoiceDataset, ShowcaseAccountDataset], reports: allReports, actions: allActions, themes: allThemes, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index ce6aecce98..e3bc4cef01 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -84,7 +84,12 @@ export const KIND_COVERAGE: Record = { // ── ui ── view: { status: 'demonstrated', files: ['src/ui/views/task.view.ts', 'src/ui/views/project.view.ts'] }, page: { status: 'demonstrated', files: ['src/ui/pages/index.ts'] }, - dashboard: { status: 'demonstrated', files: ['src/ui/dashboards/chart-gallery.dashboard.ts'] }, + dashboard: { + status: 'demonstrated', + files: ['src/ui/dashboards/chart-gallery.dashboard.ts', 'src/ui/dashboards/revenue-pulse.dashboard.ts'], + notes: + 'revenue-pulse also demonstrates dashboard-level filters (framework#2501): dateRange + globalFilters driving two objects via per-widget filterBindings.', + }, app: { status: 'demonstrated', files: ['src/ui/apps/index.ts'] }, action: { status: 'demonstrated', files: ['src/ui/actions/index.ts'] }, report: { status: 'demonstrated', files: ['src/ui/reports/index.ts'] }, diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts index e0e131d9bc..aaf8e01864 100644 --- a/examples/app-showcase/src/data/objects/account.object.ts +++ b/examples/app-showcase/src/data/objects/account.object.ts @@ -74,6 +74,20 @@ export const Account = ObjectSchema.create({ { label: 'Churned', value: 'churned', color: '#EF4444' }, ], }), + // Region + signed date power the Revenue Pulse dashboard's dashboard-level + // filters (framework#2501): the shared "region" filter maps to THIS + // object's `sales_region` (invoices carry their own `region`), and the + // dashboard date range maps to `signed_on` (invoices use `issued_on`) — + // the cross-object per-widget `filterBindings` demo. + sales_region: Field.select({ + label: 'Sales Region', + options: [ + { label: 'AMER', value: 'amer', default: true }, + { label: 'EMEA', value: 'emea' }, + { label: 'APAC', value: 'apac' }, + ], + }), + signed_on: Field.date({ label: 'Customer Since' }), // EIN-style tax id — the `format` rule below enforces the NN-NNNNNNN shape // with a regex (a deliberately non-trivial pattern, unlike the built-in // email/url field validators). diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index ffb6dc96c5..c472c34471 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -87,6 +87,17 @@ export const Invoice = ObjectSchema.create({ // invoices; because `showcase_invoice_line` is `controlled_by_parent`, the // lines follow automatically (ADR-0055). Mirror of `project.owner`. owner: Field.text({ label: 'Owner', maxLength: 200 }), + // Region the sale was booked in — the invoice-side target of the Revenue + // Pulse dashboard's shared "region" filter (framework#2501): accounts map + // the same filter to their own `sales_region` via `filterBindings`. + region: Field.select({ + label: 'Region', + options: [ + { label: 'AMER', value: 'amer', default: true }, + { label: 'EMEA', value: 'emea' }, + { label: 'APAC', value: 'apac' }, + ], + }), status: Field.select({ label: 'Status', required: true, diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index a80841b7f5..bd86a0b596 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -39,25 +39,30 @@ const accounts = defineSeed(Account, { // `status` is required and no default is applied at seed-insert time, so it // must be set explicitly or the row is rejected (this is why Accounts was // empty). `hq` also exercises the location field. - { name: 'Northwind', industry: 'retail', annual_revenue: 8_000_000, website: 'https://northwind.example', status: 'active', hq: { lat: 47.6062, lng: -122.3321 }, tax_id: '91-1144442', billing_email: 'ap@northwind.example' }, - { name: 'Contoso', industry: 'technology', annual_revenue: 25_000_000, website: 'https://contoso.example', status: 'active', hq: { lat: 37.7749, lng: -122.4194 }, tax_id: '20-3399881', billing_email: 'billing@contoso.example' }, - { name: 'Fabrikam', industry: 'healthcare', annual_revenue: 12_000_000, website: 'https://fabrikam.example', status: 'prospect', hq: { lat: 40.7128, lng: -74.0060 }, tax_id: '46-7782013', billing_email: 'accounts@fabrikam.example' }, + // `sales_region` + `signed_on` feed the Revenue Pulse filtered dashboard + // (framework#2501): its region filter maps here via filterBindings + // (`region → sales_region`) and its date range via `dateRange → signed_on`. + // Prospects have no `signed_on` yet — date-scoped account charts exclude + // them by design. + { name: 'Northwind', industry: 'retail', annual_revenue: 8_000_000, website: 'https://northwind.example', status: 'active', sales_region: 'amer', signed_on: cel`daysAgo(400)`, hq: { lat: 47.6062, lng: -122.3321 }, tax_id: '91-1144442', billing_email: 'ap@northwind.example' }, + { name: 'Contoso', industry: 'technology', annual_revenue: 25_000_000, website: 'https://contoso.example', status: 'active', sales_region: 'amer', signed_on: cel`daysAgo(700)`, hq: { lat: 37.7749, lng: -122.4194 }, tax_id: '20-3399881', billing_email: 'billing@contoso.example' }, + { name: 'Fabrikam', industry: 'healthcare', annual_revenue: 12_000_000, website: 'https://fabrikam.example', status: 'prospect', sales_region: 'emea', hq: { lat: 40.7128, lng: -74.0060 }, tax_id: '46-7782013', billing_email: 'accounts@fabrikam.example' }, // Extra accounts so the record picker has enough volume to exercise search, // sorting, pagination and the (non-churned) scoping filter on invoices. - { name: 'Initech', industry: 'finance', annual_revenue: 5_400_000, website: 'https://initech.example', status: 'active', hq: { lat: 30.2672, lng: -97.7431 }, tax_id: '74-2233110', billing_email: 'ap@initech.example' }, - { name: 'Globex', industry: 'technology', annual_revenue: 42_000_000, website: 'https://globex.example', status: 'active', hq: { lat: 34.0522, lng: -118.2437 }, tax_id: '95-8841200', billing_email: 'billing@globex.example' }, - { name: 'Stark Industries', industry: 'technology', annual_revenue: 180_000_000, website: 'https://stark.example', status: 'active', hq: { lat: 40.7580, lng: -73.9855 }, tax_id: '13-5567421', billing_email: 'ap@stark.example' }, + { name: 'Initech', industry: 'finance', annual_revenue: 5_400_000, website: 'https://initech.example', status: 'active', sales_region: 'amer', signed_on: cel`daysAgo(300)`, hq: { lat: 30.2672, lng: -97.7431 }, tax_id: '74-2233110', billing_email: 'ap@initech.example' }, + { name: 'Globex', industry: 'technology', annual_revenue: 42_000_000, website: 'https://globex.example', status: 'active', sales_region: 'amer', signed_on: cel`daysAgo(500)`, hq: { lat: 34.0522, lng: -118.2437 }, tax_id: '95-8841200', billing_email: 'billing@globex.example' }, + { name: 'Stark Industries', industry: 'technology', annual_revenue: 180_000_000, website: 'https://stark.example', status: 'active', sales_region: 'amer', signed_on: cel`daysAgo(800)`, hq: { lat: 40.7580, lng: -73.9855 }, tax_id: '13-5567421', billing_email: 'ap@stark.example' }, // CJK-named account so pinyin search recall (#2486) is demonstrable out of // the box: with the zh-CN locale configured, `$search=huaning` / `hnkj` // must find 华宁科技 in the record picker and list quick-search. - { name: '华宁科技', industry: 'technology', annual_revenue: 36_000_000, website: 'https://huaning.example', status: 'active', hq: { lat: 31.2304, lng: 121.4737 }, tax_id: '91-3100001', billing_email: 'billing@huaning.example' }, - { name: 'Wayne Enterprises', industry: 'finance', annual_revenue: 210_000_000, website: 'https://wayne.example', status: 'active', hq: { lat: 40.7128, lng: -74.0060 }, tax_id: '22-9087733', billing_email: 'billing@wayne.example' }, - { name: 'Acme Retail', industry: 'retail', annual_revenue: 3_200_000, website: 'https://acme.example', status: 'prospect', hq: { lat: 41.8781, lng: -87.6298 }, tax_id: '36-4471209', billing_email: 'accounts@acme.example' }, - { name: 'Soylent Foods', industry: 'healthcare', annual_revenue: 9_900_000, website: 'https://soylent.example', status: 'prospect', hq: { lat: 37.3382, lng: -121.8863 }, tax_id: '77-1029384', billing_email: 'ap@soylent.example' }, - { name: 'Hooli', industry: 'technology', annual_revenue: 96_000_000, website: 'https://hooli.example', status: 'active', hq: { lat: 37.3861, lng: -122.0839 }, tax_id: '45-7781230', billing_email: 'billing@hooli.example' }, - { name: 'Vandelay Industries', industry: 'finance', annual_revenue: 6_700_000, website: 'https://vandelay.example', status: 'active', hq: { lat: 40.6782, lng: -73.9442 }, tax_id: '11-3344556', billing_email: 'ap@vandelay.example' }, - { name: 'Umbrella Health', industry: 'healthcare', annual_revenue: 33_000_000, website: 'https://umbrella.example', status: 'churned', hq: { lat: 39.9526, lng: -75.1652 }, tax_id: '88-2200117', churn_reason: 'Switched to in-house platform', billing_email: 'accounts@umbrella.example' }, - { name: 'Wonka Brands', industry: 'retail', annual_revenue: 14_500_000, website: 'https://wonka.example', status: 'churned', hq: { lat: 41.4993, lng: -81.6944 }, tax_id: '52-7741093', churn_reason: 'Budget cuts', billing_email: 'ap@wonka.example' }, + { name: '华宁科技', industry: 'technology', annual_revenue: 36_000_000, website: 'https://huaning.example', status: 'active', sales_region: 'apac', signed_on: cel`daysAgo(200)`, hq: { lat: 31.2304, lng: 121.4737 }, tax_id: '91-3100001', billing_email: 'billing@huaning.example' }, + { name: 'Wayne Enterprises', industry: 'finance', annual_revenue: 210_000_000, website: 'https://wayne.example', status: 'active', sales_region: 'amer', signed_on: cel`daysAgo(900)`, hq: { lat: 40.7128, lng: -74.0060 }, tax_id: '22-9087733', billing_email: 'billing@wayne.example' }, + { name: 'Acme Retail', industry: 'retail', annual_revenue: 3_200_000, website: 'https://acme.example', status: 'prospect', sales_region: 'amer', hq: { lat: 41.8781, lng: -87.6298 }, tax_id: '36-4471209', billing_email: 'accounts@acme.example' }, + { name: 'Soylent Foods', industry: 'healthcare', annual_revenue: 9_900_000, website: 'https://soylent.example', status: 'prospect', sales_region: 'emea', hq: { lat: 37.3382, lng: -121.8863 }, tax_id: '77-1029384', billing_email: 'ap@soylent.example' }, + { name: 'Hooli', industry: 'technology', annual_revenue: 96_000_000, website: 'https://hooli.example', status: 'active', sales_region: 'amer', signed_on: cel`daysAgo(150)`, hq: { lat: 37.3861, lng: -122.0839 }, tax_id: '45-7781230', billing_email: 'billing@hooli.example' }, + { name: 'Vandelay Industries', industry: 'finance', annual_revenue: 6_700_000, website: 'https://vandelay.example', status: 'active', sales_region: 'emea', signed_on: cel`daysAgo(450)`, hq: { lat: 40.6782, lng: -73.9442 }, tax_id: '11-3344556', billing_email: 'ap@vandelay.example' }, + { name: 'Umbrella Health', industry: 'healthcare', annual_revenue: 33_000_000, website: 'https://umbrella.example', status: 'churned', sales_region: 'amer', signed_on: cel`daysAgo(600)`, hq: { lat: 39.9526, lng: -75.1652 }, tax_id: '88-2200117', churn_reason: 'Switched to in-house platform', billing_email: 'accounts@umbrella.example' }, + { name: 'Wonka Brands', industry: 'retail', annual_revenue: 14_500_000, website: 'https://wonka.example', status: 'churned', sales_region: 'emea', signed_on: cel`daysAgo(350)`, hq: { lat: 41.4993, lng: -81.6944 }, tax_id: '52-7741093', churn_reason: 'Budget cuts', billing_email: 'ap@wonka.example' }, ], }); @@ -261,10 +266,22 @@ const invoices = defineSeed(Invoice, { mode: 'upsert', externalId: 'name', records: [ - { name: 'INV-1001', account: 'Northwind', owner: 'ada@example.com', status: 'sent', issued_on: cel`daysAgo(10)`, tax_rate: 8 }, - { name: 'INV-1002', account: 'Contoso', owner: 'ada@example.com', status: 'draft', tax_rate: 0 }, - { name: 'INV-1003', account: 'Contoso', owner: 'linus@example.com', status: 'paid', issued_on: cel`daysAgo(30)`, paid_on: cel`daysAgo(2)`, tax_rate: 8 }, - { name: 'INV-1004', account: 'Fabrikam', owner: 'grace@example.com', status: 'draft', tax_rate: 0 }, + { name: 'INV-1001', account: 'Northwind', owner: 'ada@example.com', status: 'sent', issued_on: cel`daysAgo(10)`, tax_rate: 8, region: 'amer' }, + { name: 'INV-1002', account: 'Contoso', owner: 'ada@example.com', status: 'draft', tax_rate: 0, region: 'amer' }, + { name: 'INV-1003', account: 'Contoso', owner: 'linus@example.com', status: 'paid', issued_on: cel`daysAgo(30)`, paid_on: cel`daysAgo(2)`, tax_rate: 8, region: 'amer' }, + { name: 'INV-1004', account: 'Fabrikam', owner: 'grace@example.com', status: 'draft', tax_rate: 0, region: 'emea' }, + // Volume spread across months + regions so the Revenue Pulse filtered + // dashboard (framework#2501) shows visible movement when the date range + // or region filter changes — not just one bar. `issued_on` spans ~6 + // months; regions cover AMER / EMEA / APAC. + { name: 'INV-1005', account: 'Contoso', owner: 'ada@example.com', status: 'paid', issued_on: cel`daysAgo(45)`, paid_on: cel`daysAgo(40)`, tax_rate: 8, region: 'amer' }, + { name: 'INV-1006', account: 'Globex', owner: 'linus@example.com', status: 'sent', issued_on: cel`daysAgo(15)`, tax_rate: 8, region: 'amer' }, + { name: 'INV-1007', account: '华宁科技', owner: 'grace@example.com', status: 'paid', issued_on: cel`daysAgo(60)`, paid_on: cel`daysAgo(50)`, tax_rate: 6, region: 'apac' }, + { name: 'INV-1008', account: '华宁科技', owner: 'ada@example.com', status: 'sent', issued_on: cel`daysAgo(5)`, tax_rate: 6, region: 'apac' }, + { name: 'INV-1009', account: 'Vandelay Industries', owner: 'linus@example.com', status: 'paid', issued_on: cel`daysAgo(100)`, paid_on: cel`daysAgo(90)`, tax_rate: 20, region: 'emea' }, + { name: 'INV-1010', account: 'Fabrikam', owner: 'grace@example.com', status: 'sent', issued_on: cel`daysAgo(75)`, tax_rate: 20, region: 'emea' }, + { name: 'INV-1011', account: 'Stark Industries', owner: 'ada@example.com', status: 'paid', issued_on: cel`daysAgo(130)`, paid_on: cel`daysAgo(120)`, tax_rate: 8, region: 'amer' }, + { name: 'INV-1012', account: 'Hooli', owner: 'linus@example.com', status: 'sent', issued_on: cel`daysAgo(170)`, tax_rate: 8, region: 'amer' }, ], }); diff --git a/examples/app-showcase/src/ui/apps/index.ts b/examples/app-showcase/src/ui/apps/index.ts index 721f67d61a..1669d5ab76 100644 --- a/examples/app-showcase/src/ui/apps/index.ts +++ b/examples/app-showcase/src/ui/apps/index.ts @@ -88,6 +88,7 @@ export const ShowcaseApp = App.create({ children: [ { id: 'nav_command_center', type: 'page', pageName: 'showcase_command_center', label: 'Command Center (大屏)', icon: 'monitor-dot' }, { id: 'nav_ops', type: 'dashboard', dashboardName: 'showcase_ops_dashboard', label: 'Delivery Operations', icon: 'gauge' }, + { id: 'nav_revenue_pulse', type: 'dashboard', dashboardName: 'showcase_revenue_pulse', label: 'Revenue Pulse (filtered)', icon: 'sliders-horizontal' }, { id: 'nav_charts', type: 'dashboard', dashboardName: 'showcase_chart_gallery', label: 'Chart Gallery', icon: 'layout-dashboard' }, { id: 'nav_report_tabular', type: 'object', objectName: 'showcase_task', viewName: 'tabular', label: 'Task List', icon: 'table' }, { id: 'nav_report_summary', type: 'report', reportName: 'showcase_hours_by_status', label: 'Hours by Status', icon: 'sigma' }, diff --git a/examples/app-showcase/src/ui/dashboards/index.ts b/examples/app-showcase/src/ui/dashboards/index.ts index 94283d55a3..3e26e2e6ff 100644 --- a/examples/app-showcase/src/ui/dashboards/index.ts +++ b/examples/app-showcase/src/ui/dashboards/index.ts @@ -2,3 +2,4 @@ export { ChartGalleryDashboard } from './chart-gallery.dashboard.js'; export { OpsDashboard } from './ops-dashboard.dashboard.js'; +export { RevenuePulseDashboard } from './revenue-pulse.dashboard.js'; diff --git a/examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts b/examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts new file mode 100644 index 0000000000..87a40d8014 --- /dev/null +++ b/examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ChartConfig, ChartType, Dashboard } from '@objectstack/spec/ui'; + +const invoiceDs = 'showcase_invoice_metrics'; +const accountDs = 'showcase_account_metrics'; + +const cfg = (type: ChartType, dimension: string, measure: string): ChartConfig => ({ + type, + xAxis: { field: dimension, showGridLines: true, logarithmic: false }, + yAxis: [{ field: measure, showGridLines: true, logarithmic: false }], + showLegend: true, + showDataLabels: false, +}); + +/** + * Revenue Pulse — the dashboard-level filters demo (framework#2501, + * objectui#2578): one date range + one region filter driving charts over + * TWO different objects, each widget mapping the filter to ITS OWN field + * via `filterBindings`: + * + * • the date range's default field is the invoice `issued_on`; account + * widgets re-map it to `signed_on` (`filterBindings: { dateRange: + * 'signed_on' }`) — the field simply doesn't exist on accounts; + * • the shared "region" filter's default field is the invoice `region`; + * account widgets re-map it to `sales_region`; + * • one KPI opts out of both (`false`) as the fixed all-time reference. + * + * Changing either filter live re-scopes every bound widget — the acceptance + * scenario of framework#2501 on a real backend + seed data. + */ +export const RevenuePulseDashboard: Dashboard = { + name: 'showcase_revenue_pulse', + label: 'Revenue Pulse', + description: 'Dashboard-level date + region filters driving invoice and account charts — each widget binds the filters to its own fields.', + columns: 12, + dateRange: { field: 'issued_on', defaultRange: 'last_90_days', allowCustomRange: true }, + globalFilters: [ + { + name: 'region', + field: 'region', + label: 'Region', + type: 'select', + options: [ + { value: 'amer', label: 'AMER' }, + { value: 'emea', label: 'EMEA' }, + { value: 'apac', label: 'APAC' }, + ], + scope: 'dashboard', + }, + ], + widgets: [ + // ── KPI row ────────────────────────────────────────────────────────── + // Default bindings: dateRange → issued_on, region → region (invoice side). + { id: 'kpi_invoices', type: 'metric', title: 'Invoices', dataset: invoiceDs, values: ['invoice_count'], colorVariant: 'blue', layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'kpi_invoice_subtotal', type: 'metric', title: 'Invoiced Subtotal', dataset: invoiceDs, values: ['subtotal_sum'], colorVariant: 'success', layout: { x: 3, y: 0, w: 3, h: 2 } }, + // Account side: both filters re-mapped to this object's own fields. + { id: 'kpi_new_accounts', type: 'metric', title: 'Accounts Signed', dataset: accountDs, values: ['account_count'], colorVariant: 'teal', filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 0, w: 3, h: 2 } }, + // Fixed reference: opted out of BOTH dashboard filters. + { id: 'kpi_accounts_alltime', type: 'metric', title: 'Accounts (all time)', dataset: accountDs, values: ['account_count'], colorVariant: 'purple', filterBindings: { dateRange: false, region: false }, layout: { x: 9, y: 0, w: 3, h: 2 } }, + + // ── Trends + distribution ──────────────────────────────────────────── + { id: 'line_invoices_by_month', type: 'line', title: 'Invoices by Month', dataset: invoiceDs, dimensions: ['issued_on'], values: ['invoice_count'], chartConfig: cfg('line', 'issued_on', 'invoice_count'), layout: { x: 0, y: 2, w: 6, h: 4 } }, + { id: 'col_accounts_by_month', type: 'column', title: 'Accounts Signed by Month', dataset: accountDs, dimensions: ['signed_on'], values: ['account_count'], chartConfig: cfg('column', 'signed_on', 'account_count'), filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 2, w: 6, h: 4 } }, + { id: 'donut_invoices_by_status', type: 'donut', title: 'Invoices by Status', dataset: invoiceDs, dimensions: ['status'], values: ['invoice_count'], chartConfig: cfg('donut', 'status', 'invoice_count'), layout: { x: 0, y: 6, w: 6, h: 4 } }, + { id: 'bar_accounts_by_industry', type: 'bar', title: 'Accounts by Industry', dataset: accountDs, dimensions: ['industry'], values: ['account_count'], chartConfig: cfg('bar', 'industry', 'account_count'), filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 6, w: 6, h: 4 } }, + ], +}; diff --git a/examples/app-showcase/src/ui/datasets/index.ts b/examples/app-showcase/src/ui/datasets/index.ts index b8eb9c6572..81a8a75263 100644 --- a/examples/app-showcase/src/ui/datasets/index.ts +++ b/examples/app-showcase/src/ui/datasets/index.ts @@ -1,3 +1,4 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { ShowcaseTaskDataset, ShowcaseProjectDataset } from './chart-gallery.dataset.js'; +export { ShowcaseInvoiceDataset, ShowcaseAccountDataset } from './revenue-pulse.dataset.js'; diff --git a/examples/app-showcase/src/ui/datasets/revenue-pulse.dataset.ts b/examples/app-showcase/src/ui/datasets/revenue-pulse.dataset.ts new file mode 100644 index 0000000000..7a4ca552c9 --- /dev/null +++ b/examples/app-showcase/src/ui/datasets/revenue-pulse.dataset.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +/** + * Datasets backing the Revenue Pulse dashboard (framework#2501) — two + * DIFFERENT base objects on one dashboard, so the dashboard-level filters + * demonstrably re-scope cross-object: the date range hits `issued_on` on + * invoices vs `signed_on` on accounts, and the shared "region" filter hits + * `region` vs `sales_region`, each mapped per widget via `filterBindings`. + */ + +/** Invoice analytics — counts by status / month / region over showcase_invoice. */ +export const ShowcaseInvoiceDataset = defineDataset({ + name: 'showcase_invoice_metrics', + label: 'Invoice Metrics', + object: 'showcase_invoice', + dimensions: [ + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'region', label: 'Region', field: 'region', type: 'string' }, + { name: 'issued_on', label: 'Issued', field: 'issued_on', type: 'date', dateGranularity: 'month' }, + { name: 'account', label: 'Account', field: 'account', type: 'lookup' }, + ], + measures: [ + { name: 'invoice_count', label: 'Invoices', aggregate: 'count' }, + { name: 'subtotal_sum', label: 'Subtotal', aggregate: 'sum', field: 'total', format: '0,0' }, + ], +}); + +/** Account analytics — counts / revenue by industry / region over showcase_account. */ +export const ShowcaseAccountDataset = defineDataset({ + name: 'showcase_account_metrics', + label: 'Account Metrics', + object: 'showcase_account', + dimensions: [ + { name: 'industry', label: 'Industry', field: 'industry', type: 'string' }, + { name: 'status', label: 'Lifecycle', field: 'status', type: 'string' }, + { name: 'sales_region', label: 'Sales Region', field: 'sales_region', type: 'string' }, + { name: 'signed_on', label: 'Customer Since', field: 'signed_on', type: 'date', dateGranularity: 'month' }, + ], + measures: [ + { name: 'account_count', label: 'Accounts', aggregate: 'count' }, + { name: 'revenue_sum', label: 'Annual Revenue', aggregate: 'sum', field: 'annual_revenue', format: '0,0' }, + ], +});