Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { ClmPositions, ClmSharingRules } from './src/sharing/index.js';
import { registerClmPositionBindings, type BindHostContext } from './src/security/index.js';
import { clmSeeds } from './src/data/index.js';
import * as apps from './src/apps/index.js';
import * as datasets from './src/datasets/index.js';
import * as dashboards from './src/dashboards/index.js';
import * as pages from './src/pages/index.js';
import * as views from './src/views/index.js';

Expand Down Expand Up @@ -107,15 +109,42 @@ export default defineStack({
// with three such issues). `approvals` additionally pulls `job` + `queue`
// in ahead of itself, so its SLA escalation has durable scheduling.
//
// The rest arrive with the cards that need them (`analytics` for §09).
// Capability expansion stays tight — a card names the token it adds
// (AGENTS.md).
requires: ['ui', 'auth', 'sharing', 'hierarchy-security', 'automation', 'triggers', 'approvals', 'messaging'],
// `analytics` is the ADR-0021 semantic layer DESIGN.md §09's four datasets
// and three dashboards run on. MEASURED on 17.3.0 before it was declared,
// because the question the card asked first was whether it is
// enterprise-gated the way `hierarchy-security` is. It is not, on either
// count:
//
// - `PLATFORM_CAPABILITY_PROVIDERS.analytics` is
// `{ package: '@objectstack/service-analytics', edition: 'open' }` — the
// open edition, where `hierarchy-security` reads
// `{ package: '@objectstack/security-enterprise', edition: 'enterprise' }`.
// - It needs no declaration to load. `analytics` is a member of
// `PLATFORM_ALWAYS_ON_CAPABILITIES`, so `serve` appends it for every
// non-`minimal` preset: `AnalyticsServicePlugin` was already in the boot
// roster (36 plugins) on `main` with this list unchanged, and
// `POST /api/v1/analytics/dataset/query` already answered.
//
// So this token has no gate behind it, exactly like `messaging` above, and
// it is declared for the same reason: the app must state what it MEANS. A
// runtime that does not carry the always-on slate serves no dataset at all,
// and every widget on all three dashboards would render its error state.
//
// The rest arrive with the cards that need them. Capability expansion stays
// tight — a card names the token it adds (AGENTS.md).
requires: ['ui', 'auth', 'sharing', 'hierarchy-security', 'automation', 'triggers', 'approvals', 'messaging', 'analytics'],

objects: Object.values(objects),
apps: Object.values(apps),
views: Object.values(views),
pages: Object.values(pages),

// Analytics (DESIGN.md §09, card 10). `datasets` is the ONE semantic layer
// (ADR-0021): a dashboard widget names a dataset and picks its dimensions
// and measures by name, so "contract value" is defined once and means the
// same number on every surface. There are no hand-written cubes.
datasets: Object.values(datasets),
dashboards: Object.values(dashboards),
// Lifecycle hooks (numbering, type-derived stamps, the state machines and
// the display_name mirrors). A metadata `Hook` is only registered from
// here — `hooks` is a top-level stack key, not an object key.
Expand Down
94 changes: 93 additions & 1 deletion src/apps/clm.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import { App } from '@objectstack/spec/ui';
* to mean here, and the only place they are edited is the Setup app's field
* editor. Reported rather than invented: giving them an object is a data-model
* decision, not a view.
* - **报表** (管理) is card 10. No `reports:` metadata exists yet, and a
* - **报表** (管理) is still absent. Card 10 landed §09's four datasets and
* three DASHBOARDS, which the 分析 group below points at with
* `type: 'dashboard'` items; no `reports:` metadata exists yet, and a
* `type: 'report'` item naming a report that does not exist is a dead row.
*/
export const ClmApp = App.create({
Expand Down Expand Up @@ -180,6 +182,96 @@ export const ClmApp = App.create({
],
},

{
/**
* 分析 — DESIGN.md §09's three dashboards, one row each.
*
* ## The gate is `clm_requester.access`, and it is the ONLY spelling that
* ## reaches every audience these three boards are written for
*
* `requiredPermissions` is **AND, not OR**. Measured, not assumed — the
* authoritative server-side filter is `filterAppForUserWithReason`
* (`@objectstack/rest` 17.3.0), and its nav clause reads:
*
* const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : [];
* if (req.length > 0 && !req.every((p) => sysPerms.has(p))) continue;
*
* `req.every(...)`. So the intuitive "one row per audience, gated by that
* audience" — `['clm_legal.access', 'clm_finance.access', ...]` — would
* require a user to hold ALL of them and would hide the group from
* EVERYONE. No account in this app holds two `*.access` capabilities.
* That is this card's own trap in a third costume: a union that reads as
* generous and gates to nothing.
*
* What the five sets actually grant (card 04, `src/profiles/`): each one
* carries exactly ONE `clm_*.access` and never another's — `clm_admin`'s
* `systemPermissions` are `clm_admin.access` plus six action gates, so an
* administrator does NOT hold `clm_legal.access`. The one capability every
* CLM audience shares is `clm_requester.access`, because
* `bind-position-sets.ts` binds `RequesterSet` to *every* position:
*
* ...Object.values(CLM_POSITION).map((position) => [position, RequesterSet.name])
*
* PR #25's audience matrix is the measurement: 我的合同, gated on exactly
* this capability, is served to legal, finance, records, clm_admin and an
* employee with no position — and withheld from the platform admin, which
* holds no CLM set at all. That last one is the control that proves the
* gate is a gate and not a no-op.
*
* ⚠️ This gate reaches every CLM user, a plain business requester
* included, so it is only correct if a DATASET QUERY is row-level scoped
* to the person asking. Scoped and unscoped look identical from outside —
* a board renders numbers either way — so it was measured, with a
* POSITIVE CONTROL rather than inferred from a zero (a broken query
* returns zero too).
*
* An account holding `clm_requester` and NO position (card 07's "employee
* with no position", which §04 lets read 0 contracts), against `clm_admin`,
* on the same two measures, before and after that employee created one
* contract of their own worth 4,242:
*
* contract_count / total_amount instalments / overdue
* before employee 0 / 0 0 / 0
* clm_admin 120 / 61,041,000 300 / 2,176,250
* after employee 1 / 4,242 0 / 0
* clm_admin 121 / 61,045,242 300 / 2,176,250
*
* SCOPED. The employee's total moved to exactly their own contract and no
* further; the administrator's moved to the company total INCLUDING it.
* Same instant, same tiles, 4,242 against 61,045,242. The move is also
* what rules out a false negative: that account's query demonstrably
* works, because it answered a correct non-zero the moment there was
* something it was allowed to see.
*
* So the same board is a personal view for a requester and a company view
* for the CFO — the analytics runtime scopes per joined object (ADR-0021
* D-C), visible in the generated SQL as a
* `WHERE "clm_payment_plan"."contract" IN (...)` clause.
*
* Narrowing the ROWS is what this app can express; narrowing the NAV per
* audience needs an OR the platform does not have, and inventing a
* per-board capability would be a §04 change this card does not own.
*
* `type: 'dashboard'` + `dashboardName`, the only shape
* `DashboardNavItemSchema` accepts. Each name matches a dashboard
* registered in `objectstack.config.ts`; a name that matched nothing
* would be the same dead row the 报表 note above refuses — and that is
* enforced, not hoped: `validate` exits 1 with "App 'clm' navigation
* references dashboard '…' which is not defined in dashboards."
*/
id: 'group_analytics',
type: 'group',
label: 'Analytics',
icon: 'chart-line',
expanded: true,
requiredPermissions: ['clm_requester.access'],
children: [
{ id: 'nav_legal_workbench', type: 'dashboard', dashboardName: 'legal_workbench', label: 'Legal Workbench', icon: 'scale' },
{ id: 'nav_executive_overview', type: 'dashboard', dashboardName: 'executive_overview', label: 'Executive Overview', icon: 'trending-up' },
{ id: 'nav_finance_overview', type: 'dashboard', dashboardName: 'finance_overview', label: 'Finance Overview', icon: 'banknote' },
],
},

{
// 管理 — §05 row 5. Two of the four items §05 lists have no destination
// in this repo yet; see the file header for which and why.
Expand Down
208 changes: 208 additions & 0 deletions src/dashboards/executive.dashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import type { Dashboard } from '@objectstack/spec/ui';

/**
* 管理层 — DESIGN.md §09's second dashboard: 生效合同额 · 90 天内到期 ·
* 高风险合同 · 审批瓶颈(各台阶平均停留)· 按方向的合同额趋势.
*
* Every widget binds `contract_metrics`, so the two global filters below reach
* a column that exists on every one of them. See `legal.dashboard.ts` for the
* measured list of widget keys the renderer reads and for why no dashboard
* `dateRange` is declared.
*/
export const ExecutiveDashboard: Dashboard = {
name: 'executive_overview',
label: 'Executive Overview',
description: 'Contract value in force, expiries, risk concentration and approval load for management.',

columns: 12,
gap: 4,

globalFilters: [
{
name: 'direction',
field: 'direction',
object: 'clm_contract',
label: 'Direction',
type: 'select',
scope: 'dashboard',
options: [
{ value: 'sales', label: 'Sales' },
{ value: 'purchase', label: 'Purchase' },
{ value: 'other', label: 'Other' },
],
},
{
name: 'risk_level',
field: 'risk_level',
object: 'clm_contract',
label: 'Risk Level',
type: 'select',
scope: 'dashboard',
options: [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
],
},
],

widgets: [
// ─── Row 1 ───────────────────────────────────────────────────────────
{
/**
* §09's 生效合同额, rendered SPLIT BY CURRENCY rather than as one KPI
* number, and the split is the honest form rather than a decoration.
*
* The fixture holds three currencies (USD 42 / EUR 41 / GBP 37 across 120
* contracts) and this app carries no FX rate — DESIGN.md §01 leaves
* commercial terms with HotCRM. A single "active contract value" tile
* would therefore add dollars to euro to sterling and print the result as
* though it meant something. Three bars each say what they are.
*
* This is the same rule that keeps `format: '0,0'` free of a baked `$`
* (the HotCRM changelog lesson); a cross-currency SUM is the same mistake
* one level up from the symbol.
*/
// `chart-config-missing` suppressed — MEASURED, not waved away. The rule's
// message asserts "the renderer cannot determine which measure to plot,
// so the series renders empty"; on a dataset-bound widget that is not
// what happens. objectui's `buildChartSeries(rows, dimensions, values,
// fields)` DERIVES the bindings from the selection, and `chartConfig` is
// merged onto that derivation as PRESENTATION only
// (`mergeAuthoredPresentation`). Observed in Chromium against this branch:
// this widget draws its marks with no `chartConfig` at all. The rule's own
// hint prescribes this suppression for exactly that case.
id: 'active_contract_value',
title: 'Active Contract Value by Currency',
description: 'Contracts in force. Each bar is its own currency — no FX conversion exists in this app.',
type: 'bar',
dataset: 'contract_metrics',
dimensions: ['currency_code'],
values: ['total_amount'],
filter: { status: 'active' },
suppressWarnings: ['chart-config-missing'],
layout: { x: 0, y: 0, w: 6, h: 4 },
},
{
id: 'expiring_90_days',
title: 'Expiring Within 90 Days',
description: 'Active contracts whose end date falls in the next 90 days',
type: 'metric',
dataset: 'contract_metrics',
values: ['contract_count'],
// `end_date` is a persisted `Field.date`; the bounds are date macros the
// renderer resolves before the query leaves the browser. This widget
// carries no `compareTo`, so the bounded window it states is simply a
// filter.
filter: { status: 'active', end_date: { $gte: '{today}', $lte: '{90_days_from_now}' } },
colorVariant: 'warning',
layout: { x: 6, y: 0, w: 3, h: 2 },
},
{
id: 'high_risk_contracts',
title: 'High-Risk Contracts',
description: 'Assessed high by legal during review',
type: 'metric',
dataset: 'contract_metrics',
values: ['contract_count'],
filter: { risk_level: 'high' },
colorVariant: 'danger',
layout: { x: 9, y: 0, w: 3, h: 2 },
},
// ─── §09's 审批瓶颈, as the four rungs of DESIGN.md §04's ladder ───────
//
// §09 asks for 各台阶平均停留 — average DWELL per approval step. Two
// independent facts rule the dwell out, both measured rather than assumed:
//
// 1. There is no dwell to read. `sys_approval_request` holds 0 rows on a
// stock `pnpm demo` — the seed stamps `approval_status` directly and
// the ladder (F5) never ran — so no rung has a start or an end.
// 2. Even with rows, a duration is not expressible in the semantic layer:
// see the measurement in `cycle-time.dataset.ts`.
//
// What IS persisted is the routing: F2 stamps four booleans on the contract,
// one per rung. These four tiles read them — how much traffic each rung
// carries. Each title names its rung and the group title says "Routing
// Load", never "bottleneck", because volume is not dwell.
//
// FOUR TILES, not one four-measure chart, and the shape was forced by a
// measurement. The rungs are four COLUMNS, not four values of one column, so
// a single widget would have to select four measures with no dimension —
// and `DatasetWidget.tsx:423` reads `METRIC_TYPES.has(widgetType) ||
// dimensions.length === 0`, so ANY zero-dimension widget renders as a KPI
// card whatever its `type`. Authored as one `bar`, and then as one `table`,
// this tile printed "11 · Routes: Head of Legal" and nothing else: three of
// the four rungs silently absent from a card that looked finished. One tile
// per rung is the shape that shows all four numbers.
{
id: 'route_legal_head',
title: 'Routes: Head of Legal',
description: 'Contracts whose matrix row reaches the head of legal',
type: 'metric',
dataset: 'contract_metrics',
values: ['route_legal_head_count'],
layout: { x: 0, y: 4, w: 3, h: 2 },
},
{
id: 'route_finance',
title: 'Routes: Finance Controller',
description: 'Contracts whose matrix row reaches finance',
type: 'metric',
dataset: 'contract_metrics',
values: ['route_finance_count'],
layout: { x: 3, y: 4, w: 3, h: 2 },
},
{
id: 'route_executive',
title: 'Routes: Executive',
description: 'Contracts whose matrix row reaches the executive sponsor',
type: 'metric',
dataset: 'contract_metrics',
values: ['route_executive_count'],
layout: { x: 6, y: 4, w: 3, h: 2 },
},
{
id: 'route_gm',
title: 'Routes: General Manager',
description: 'Contracts whose matrix row reaches the general manager',
type: 'metric',
dataset: 'contract_metrics',
values: ['route_gm_count'],
layout: { x: 9, y: 4, w: 3, h: 2 },
},

// ─── Row 2 ───────────────────────────────────────────────────────────
{
/**
* 按方向的合同额趋势, on the signature month — the point a contract's
* value becomes real.
*
* `other` is excluded: NDAs and DPAs carry no `amount` by construction
* (`clm_contract_type.amountBand` is null for both), so the series would
* be a flat zero line claiming a trend that has no values in it. The
* filter names the two directions that carry money, both persisted select
* values.
*/
// `chart-config-missing` suppressed — MEASURED, not waved away. The rule's
// message asserts "the renderer cannot determine which measure to plot,
// so the series renders empty"; on a dataset-bound widget that is not
// what happens. objectui's `buildChartSeries(rows, dimensions, values,
// fields)` DERIVES the bindings from the selection, and `chartConfig` is
// merged onto that derivation as PRESENTATION only
// (`mergeAuthoredPresentation`). Observed in Chromium against this branch:
// this widget draws its marks with no `chartConfig` at all. The rule's own
// hint prescribes this suppression for exactly that case.
id: 'value_trend_by_direction',
title: 'Contract Value Signed, by Direction',
description: 'Signed value over the last 12 months. Sales and purchase only — NDA/DPA types carry no amount.',
type: 'line',
dataset: 'contract_metrics',
dimensions: ['signed_month', 'direction'],
values: ['total_amount'],
filter: { direction: { $in: ['sales', 'purchase'] }, signed_at: { $gte: '{12_months_ago}' } },
options: { sortBy: 'signed_month', sortOrder: 'asc' },
suppressWarnings: ['chart-config-missing'],
layout: { x: 0, y: 6, w: 12, h: 4 },
},
],
};
Loading