add-page
Step-by-step workflow for adding to the PlusV1 metadata suite — a page, a Base/Detail query (with the PLS.QV view), a filter panel, a permissioned operation, a Group By config, or KPI cards. Includes
SKILL.md.claude/skills/add-page/SKILL.md
GitHubAdding to the PlusV1 metadata suite
A "page" is a row in [PLS].[V1PagesAndGroups]. Groups (IsGroup = 1) are
sidebar sections; pages (IsGroup = 0) open in a tab. Everything below is
configuration through the admin screens (⚙️ next to the admin name) or db.sh
— the src/ repo is only touched for a bespoke page component.
Core rule (see the query-engine skill): the frontend never executes or defines
a page's SQL. SQL View → Query Master → Page↔Query mapping → Generic Operation (GetGridData) → frontend.
A. Data-grid page (no code)
- Receive the
SELECT. Never run it from the frontend. - Create the view — schema
PLS, name startsQV(PLS.QVCustomerBalance). RawSELECTonly — no{FILTER}, noTOP, noORDER BY. Deploy via.claude/skills/live-db/db.sh(approval each time) or SSMS. - Create the page — Group & Page Master → New Group (if needed) → New Page
(Label, Icon, Parent Group, Sort Order). Note the generated
PageGroupID. - Register in Query Master — New:
QuerySQL=SELECT * FROM PLS.QV<Name> WHERE 1 = 1 {FILTER}(+ optionalORDER BYin the Query OrderBy field, not the SQL).- Type =
Base. Mapped Page = step 3. - Save → columns auto-discover into
[PLS].[V1QueryFields].
- Tune columns — Query Master → Fields tab. Per column:
Label,Format,Width,SortOrder,Hidden,ColorRules. Hand edits survive re-save. Use the suggestion ruleset below. - Recommend KPIs. Every new page: propose 2–4 summary cards for its Base query and, once confirmed, configure them (see "KPI config" below). Do this without being asked.
- Grant access — User Permissions → pick user → check the page (+ its
group). Optional per-user row filter: the Query tab under the page adds a
SQLFilterrow in[PLS].[V1UserQueryPermissions]. - Done. The page renders through
GridPage.jsx, callingGetGridDatawith itsPageGroupID.
B. Bespoke page (custom React screen)
For the Order Board design (PlusV1's Customer Order style: Table + grouped views, checklist filters, entity / party drawers, print + Preview) load the order-board skill.
For drawers, multi-panel, custom actions.
- Create the page row (A steps 2–3). Note the
PageGroupID. src/pages/<Name>.jsx— default-export a component taking{ user, def }(def.id=PageGroupID). ReuseGridPagefor the list part where possible.- Register in
src/App.jsx→PAGE_COMPONENTS, keyed by thePageGroupID. - Every search lookup on the form (Vendor, Item, Warehouse, …) → register a
V1QueryFilter, link it to this page, served viaGetFilterOptions(see "Search lookups" below). No lookup goes straight to a query from the form. npx esbuild src/pages/<Name>.jsx --loader:.jsx=jsx --outfile=/dev/nullto verify, thennpm run dev/npm run build.- Recommend KPIs (A step 6) — propose summary cards for the page's Base query.
- Grant access (A step 7) — page grant and the Filters-tab grants for its lookups.
Page business operations get their own SP. Anything a bespoke page does
beyond generic read (create/update/delete a business record, approve, post,
release, cancel, recalculate, generate a document, run a workflow) must be a
dedicated custom SP (e.g. Pls.APIPurchaseOrderV1) — never a new branch in
Pls.APIPlusOperationV1. Name it per domain, CREATE OR ALTER (deploys via
db.sh), enforce page/operation permission checks + @User server-side, and
wire it through the operations layer (V1OperationMaster.Config names the
SP + operation). See code-standards → The router SP vs page-business SPs.
Any dataset a custom SP returns still comes from a V1QueryMaster-registered
query with V1UserPagePermissions + V1UserQueryPermissions checks inside it —
no returned rows without a registered query and a permission gate.
Admin maintenance screens (User Master, Group & Page Master, Query Master,
Query Filter, Operation Master, Group By Master, User Permissions) are not
pages — they live in SETTINGS_VIEWS in App.jsx.
Detail query (extra dataset for a page)
- Query Master → New → Type =
Detail, paste the fullSELECT(no need to create aQVview —SaveQueryMasterbuilds[PLS].[V1QD<name>]and storesQuerySQL = SELECT * FROMit). - Map it to the page.
Query OrderBy/Query Filtermust use the view's column names (no source aliases). - Fetch it with
GetGridData { PageGroupID, QueryID }(generic grid) orView { PageGroupID, QueryID, KeyField, KeyValue }(one record, e.g. a double-click drawer).
Filter panel
- Query Filter (Query Filter Master) → New:
FilterName,SourceSQL(aSELECT id, name FROM …),ValueField,LabelField, optionalDisplayFields(comma list — first = bold line) andOrderBy. Save builds[PLS].[QF<Name>]. - Group & Page Master → the page → Filters tab → add a link: Type first
(
RANGE/MIN/LIKE/EQUALS/LIST/DATE_RANGE), then Filter, Query Field (Base-query column), Filter Field (QF-view column), Panel = interactive. The Filters tab is grid-only (panel widgets + always-applied fixed rules,UsePanel = 1or Fixed); form/page pickers live on the Form Filter tab (see below). - Optional per-user restriction: User Permissions → the page → Filters tab.
Search lookups (form/page pickers)
Every Vendor / Item / Warehouse / Buyer / Currency / … picker on a form is a
V1QueryFilter linked to that form/page — not a frontend query. Same steps
as the filter panel:
- Query Filter Master → New: the lookup
SELECT,ValueField/LabelField(+DisplayFieldsfor a two-line result). Builds[PLS].[QF<Name>]. - Group & Page Master → the form/page → Form Filter tab → link it
(Lookup + Field name + Value Field). Stored as a
V1QueryFilterLinkwithUsePanel = 0and no fixed value. AQFregistered for another form is not usable here until it is also linked. (Grid panel filters go on the Filters tab instead.) - User Permissions → the page → Filters tab → grant / restrict per user.
- Frontend:
SearchableSelect/FieldAutocompleteInputcallGetFilterOptions { LinkID }— the server reads theQFview with the caller'sV1UserFilterPermissionsAND-ed in. No page link ⇒ no data.
Never wire a lookup to a bespoke fetch or a raw query. Form access ≠ lookup access.
Permissioned operation (e.g. a "View Details" action)
- Operation Master → New:
OperationKey(e.g.PO_VIEW_DETAILS), Label,BackendOperation(View, or the operation name inside a custom SP for business actions),ConfigJSON (e.g.{"KeyField":"OrderNumber"}; for a business action also{"SP":"Pls.APIPurchaseOrderV1","Operation":"ApprovePO"}). - Group & Page Master → the page → Operations tab → link it, pick the page's Detail query, optional config override.
- User Permissions → the page → Operations tab → tick
CanPerformper user. - Frontend: login fetches
GetUserAllowedOperations; the bespoke page checks the key, then callsBackendOperationwith the merged config +QueryID.
Printed report (Form or Grid)
Full spec: query-engine/reference/printed-reports.md. Steps:
- Register in Report Master (
V1ReportMaster):ReportName,ReportCode,ReportType(React— the default for new work — orFast),IsActive. - Design the React Report in-system (header/footer, fields, table columns, formats, totals, grouping, page setup) — stored as metadata.
- Point it at an approved data source — a Query-Master query following the normal page/permission rules. No client SQL.
- Declare parameters (from the current record / selected grid row(s) / an approved filter / page context / validated input) — all re-validated server-side.
- Link it to the Form or Grid (Page,
IsActive,DisplayOrder,DisplayName). For a Grid report, set the record scope explicitly (selected / filtered / page / all). A Form/Grid can have several reports. - Grant per user; the server validates report + data-source + record + field access before generating. Test Preview / Print / PDF, and that an unauthorised user cannot run it.
Group By config
- Group By Master (or Group & Page Master → the page → Group By tab):
name, Active, sort, group-field chips, aggregate rows
(
field / SUM|COUNT|AVG|MIN|MAX / display name). Fields must exist in the query'sV1QueryFields. - Runtime: the Group By selector appears in the page's filter panel; the
frontend sends
GetGridData { …, GroupByIDs:[ids] }— the server builds theGROUP BY. Never a new view.
KPI config — recommend on every new page
Whenever you add a page (grid or bespoke), propose KPIs without being asked.
KPIs are server-calculated summary cards above the grid, synced to its active
filter/search. Config lives in Group & Page Master → the page → KPIs tab
(V1KPIMaster); values come from GetKPIData — never hardcode, never a view.
- Look at the Base query's
V1QueryFieldsand suggest 2–4 cards, e.g.:- a count of the main record (
COUNTof the key, formatinteger) — "Total <records>" SUMof each principal money column (*Amount,Total*,*Paid,*Costed) — formatcurrency, orcompact(1.2K / 3.4M / 5.6B) when the figures run large- a count of a meaningful subset the user cares about — either a second
COUNTKPI or, if it needs aWHERE, note it (a filtered KPI is done by the user narrowing the grid; there's no per-KPI predicate) - occasionally
AVG/MIN/MAXof a qty or lead-time column
- a count of the main record (
- Each KPI: KPIName (UPPER_SNAKE), DisplayName, Field (a
V1QueryFieldscolumn; omit forCOUNT(*)), Operation (SUM|COUNT|AVG|MIN|MAX), Format (number|integer|currency|percent|compact), DisplayOrder, Active. Always name a Format — pick it from the field's meaning (money →currencyorcompact, a plain tally →integer, a ratio →percent). - Present the list, get a yes, then
SaveKPIMasterper card (or via the KPIs tab). They then render for every user; each can Hide/Show the strip.
Column-suggestion ruleset (Fields tab seeding)
On query save [PLS].[V1QueryFields] is seeded; hand edits survive re-save.
Hide by default:
- FK id columns with a name/description sibling (
ShipToIDwhenShipToNameexists; raw*Stateint vs*StateDescription) - audit fields:
*CreatedBy,*CreatedDate,*LastMaintBy,*LastMaintDate,*ClosedBy,ReleasedBy - internal:
*InUse,*InUseBy,AttachmentFileID, category id columns
Format:
| name / type matches | Format |
|---|---|
*Amount, Total*, *Freight, *Insurance, *Discount, *Paid, *Costed |
number:2 |
| datetime | date |
*Number, *Code, *ID, PINumber |
mono |
bit |
yesno |
| else | (blank) |
ColorRules — JSON array, first match wins:
[{ "op": "eq|ne|lt|le|gt|ge|contains|default", "value": <any>,
"color": "green|amber|red|blue|gray", "badge": true }]
| column pattern | rule |
|---|---|
*StateDescription / *State text |
contains "Close"→red badge, contains "Releas"→green badge, default→blue badge |
*TypeDescription |
value-based badge |
| signed amounts | lt 0→red |
After SP changes
Any edit to SQLScript/APIPlusOperationV1.sql must be deployed by a human in
SSMS (the router SP cannot ALTER itself via ExecuteScript). Table DDL and
QV/QF views can go through db.sh. Mirror new tables in
SQLScript/V1SetupTables.sql.