# Building a Functional-Area Dashboard Suite: The Recipe

## Why this doc exists

Finance was the first functional area built out on top of the Portlet/Page/Dashboard framework —
Profitability, Cash Flow, Receivables & Payables, Revenue & Expense, Budgeting & Forecasting,
KPIs & Ratios, and Bank Reconciliation. Inventory, Sales, and Purchase are next. This doc extracts
the repeatable steps from that build so the next functional area doesn't have to rediscover the
pattern, the pitfalls, or the shared tooling. It assumes you've read
`PortletDashboardFramework_TechnicalGuide.md` (the framework itself) and
`Analytics_DeveloperPlaybook.md` (the BI/dataset side) — this doc is the missing "how do these two
things come together for one functional area" layer between them.

## The shape of one functional-area dashboard suite

Every module in this recipe produces the same five things:

1. **A handful of report/analytics endpoints already exist, or get built.** Most functional areas
   already have real reporting endpoints (`BaseReportEndpoint`-family) from earlier work — a
   dashboard almost never needs new business logic, just a dashboard-shaped read over data that's
   already being computed correctly elsewhere. Check for this first; Finance found 6 of 9 modules
   already had a real backend and only needed presentation work (Phase 1), with Bank Reconciliation
   (Phase 2) as the one case needing a genuinely new query.
2. **A small library of portlet definitions** (KPI-card + chart portlets bound to those endpoints,
   plus one Tab-Group portlet per module grouping its chart set) — never a single fixed layout.
3. **One default Dashboard+Page per relevant role**, built from that portlet library, editable
   afterward through the existing admin screens exactly like any other dashboard.
4. **A filter tier wired to OU-Group / BizDimension** where the underlying report already supports
   multi-OU aggregation.
5. **Live-refresh, where it's operationally meaningful** — not applied uniformly.

## Step-by-step

### 1. Inventory what already exists before writing anything

For each mockup/wireframe module in the new functional area, find the real backend behind it:

- Search the module's existing `*Reports`/`*Dashboard` endpoints
  (`GB5Solution/<Module>/<Module>SL/EndPoints/**/*Report*.cs`,
  `**/*Dashboard*.cs`) for something that already computes the shape you need.
- Check for dead/unwired DTOs — Finance's Bank Reconciliation
  (`BRSSummaryReportDTO`/`BRSDetailReportDTO`) existed as real, well-shaped DTOs with **no query
  behind them** for a long time. Grep for a DTO name with zero references outside its own file
  before assuming a report needs to be built from scratch — it may already be half-designed.
- Build a table like Finance's gap table (module → what's real vs what's a gap) before touching
  code. This single table is what turns "build a dashboard suite" into a scoped, phased plan
  instead of an open-ended one.

### 2. If a report is genuinely missing, follow the existing report family's conventions exactly

Don't invent a new pattern. Find the closest sibling report in the same module and copy its shape:

- Same `GET_ACCESSIBLE_OU_IDS`-style access-control query, duplicated locally per report (this
  codebase's established convention — see `feedback_gb5_migration_verification` /
  `RatioAnalysisReportQB.cs` precedent — don't try to factor this into a shared helper; every
  sibling report in the family repeats it).
- Same `BaseReportEndpoint<TRequest, TData>` shape: `ExecuteReportAsync` streams the row-level
  detail (used for export/NDJSON), `ExecutePagedAsync` is overridden to return the full
  dashboard-shaped result via `CreateSuccessResponse` — don't leave the default weak
  `ExecutePagedAsync` in place once real paging/shaping exists.
- Reuse proven table/column references from a working sibling query rather than reverse-engineering
  schema from DTO property names — DTO properties are aliases, not column names (see
  `feedback_ids_are_integer_not_string` in memory: check the real SQL column type before "fixing"
  the caller).
- If the new report needs a criteria field no other report in the family needs (e.g. Bank
  Reconciliation's `BankAccountIds`), parse it locally in the endpoint rather than extending the
  shared `AccountReportCriteriaParser` — that parser is for fields shared across the report family,
  not one-off filters.

### 3. Decide bridgeable vs bespoke per report, before building portlets

The BI/Analytics `ApiService` bridge (`BICatalogBLL.ResolveApiResourcePath`) can wrap **any**
endpoint whose response is a flat array or `{Body:[...]}` shape — each element becomes one row for
a generic chart/table portlet. A response that's a **composite object** (multiple named sections,
e.g. Finance P&L's `{Items, Totals}` or BRS's `{Summary, Detail}`) collapses to a single row through
that bridge and needs a **bespoke** portlet component instead (a purpose-built Angular component
registered in `WidgetComponentMap`, not a generic `ChartAdapter` config).

Check `MWEBSERVICE.SECONDURITEMPLATE` is populated for any report you want to bridge — this is the
modern GB5 route; a report only wired via the legacy `URITEMPLATE` won't be picked up by
`CreateBICatalogFromReportView` (this was a real, silently-wrong bug found and fixed during Finance:
`GET_REPORTVIEW_FOR_CATALOG_CREATION` originally only selected the legacy column).

**A data-pivot report view can never bridge, no matter how the webservice is wired.**
`BICatalogBLL.CreateFromReportView` rejects any `MREPORTVIEW.ISDATAPIVOT = 1` view outright ("its
output columns are computed at run time and cannot back a BI catalog") — this check runs *before*
URI-template resolution, so it's a separate, harder blocker than the `SECONDURITEMPLATE` gap above.
Query `MREPORTVIEW.ISDATAPIVOT` for every candidate report view *before* assuming it's bridgeable —
during Finance, both Ratio Analysis and Budget vs Actual turned out to have every one of their
report views pivot-shaped, contradicting an earlier assumption that they were "bridgeable." Both
still shipped fine — just as bespoke `MPORTLET` "Report"-type rows pointing at their existing
report endpoint via the `MENUID → MREPORT → MWEBSERVICE` chain (see the next section), exactly like
P&L/Receivable/Cash Flow/BRS. **A pivot-view rejection is not a dead end — it just means "register
this one as a Report-type portlet instead of a BI Catalog," not "this report can't have a
dashboard tile."**

**The BI Catalog ApiService bridge is same-host-only — verify before betting a module's portlets
on it.** `ApiDatasetDAL.FetchRowsAsync` resolves its base URL from a single global
`MDATASOURCE` row (`DATASOURCECODE = 'GB5_SELF_HOST'`), whose `HOSTNAME` is hardcoded to
`BusinessHost`'s own address (`http://<host>:5102` on this box). A flat, non-pivot report view
living on ANY other host (PlatformHost, HRFinanceHost, FrameworkSL, EngagementHost) will 404 at
query time, no matter how correctly its `SECONDURITEMPLATE` is wired — this is a same-process
self-call mechanism, not a real cross-service resolver. Found this building Compliance & Audit
(served by PlatformHost): `CreateBICatalogFromReportView` succeeds (catalog creation doesn't call
the endpoint), but `RunBIQuery` 404s at read time. Compounding it, `BICatalogBLL.ResolveApiResourcePath`
stores `SECONDURITEMPLATE` verbatim into `MBICATALOG.APIRESOURCEPATH` — including the literal
`{BaseURI}` placeholder text — since nothing ever substitutes or strips it; `ApiDatasetDAL` just
string-concatenates it onto the self-host base, producing a malformed URL even for a same-host
report. The pilot test catalogs that worked earlier this session (`BITEST_FROM_RV`,
`PILOT_APISVC_TEST`) only worked because their webservice row's `SECONDURITEMPLATE` was
deliberately stored as a clean relative path (`/BI/PilotApiServiceTest`) with no `{BaseURI}` token
at all — not because the bridge actually resolves the placeholder. **Practical rule: before
spending time on `MREPORTVIEW`/`MBICATALOG` wiring for a report, confirm (a) it's genuinely served
by BusinessHost, and (b) you're prepared to hand-author a clean, placeholder-free relative path in
its webservice row — never assume `SECONDURITEMPLATE`'s normal `http://{BaseURI}/as/...` shape
will work through this bridge.** If either condition fails, skip straight to the "Report"-type
portlet path below — it doesn't touch this bridge at all and works reliably regardless of which
host serves the report (proven now for 8 dashboard tiles across Finance and Compliance).

### 4. Build portlet definitions and default dashboards using Quick-Build

Use the Portlet Quick-Build wizard (`features/gb5widget/quickbuild/`,
`PortletQuickBuildService`) for the common case — "wrap an existing report/BI dataset in a
chart or KPI card" — instead of hand-touching `MPORTLETTYPE`/`MPORTLETTYPEVERSION`/`MPORTLET` rows
and the FE component-map registration separately. This is the entire point of Phase 0b's investment;
skipping it and hand-wiring a new module's portlets defeats the purpose of having built it.

For bespoke (non-bridgeable) reports, the portlet component still gets registered normally in
`WidgetComponentMap`/`gbdynamicwidget.component.ts`'s render path — Quick-Build only covers the
generic chart/KPI-card case, not custom layouts.

Group a module's related charts under one **Tab-Group portlet**
(`features/gb5widget/portlet/tab-group-portlet.component.ts`) rather than placing many
separate chart portlets side by side — this is what makes a dashboard page navigable instead of a
long scroll.

**A "Report"-type portlet's menu needs an `MROLEVSMENU` grant, or it silently renders nothing.**
`MenuBLL.GetReportCriteriaForMenu` (the SQL behind `Framework.ReportMenu.Get` — the exact call every
"Report"-type portlet makes to resolve its data, and the one every drilldown navigation makes too)
filters on `rolevsmenu.Allow like '%0%'` for the requesting role, unless `IsFromScreen=1` is passed
(the dashboard/drilldown paths never pass that). A menu with zero `MROLEVSMENU` rows for the
viewing user's role returns an **empty array** — not an error, not a 4xx, just nothing — so the
portlet appears blank on the actual dashboard even though a direct `curl` against its report
endpoint returns real data. **Verifying a report endpoint directly is not the same as verifying the
portlet renders** — this was found only after re-checking a live dashboard portlet through the
exact FE call chain (`GetMenuReport`/`picklist` → `Framework.ReportMenu.Get`), not just the report
endpoint itself. Copy an `MROLEVSMENU` row from an existing working menu (same `ALLOW` bitmask,
same `RoleId` the demo/target user actually has) for every new `MMENU` row a portlet or drilldown
target depends on — this is a required step for every portlet in this recipe, not an edge case.

### 5. Wire the filter tier — reuse the report-filter/criteria-attribute flow, don't build a parallel one

When a new segment/grouping filter is needed (Finance needed "OU Group" and, for Receivable, a
BizDimension filter), extend the **existing** `MCRITERIAATTRIBUTE`/`MWEBSERVICECRITERIA` /
report-filter component flow — the same mechanism `gbfilter.component.ts` already drives for every
other report filter — rather than inventing a dashboard-specific filter concept. This was an
explicit correction during Finance's build (the user rejected a first draft that would have added a
separate ad-hoc OU-Group concept instead of reusing the report-filter/settings-based flow).

BizDimension is optional/licensed and often unpopulated — always fall back to OU/Department grouping
when it isn't available; never assume it's populated for a given client.

### 6. Live-refresh: scope it honestly per report, not uniformly

Not every dashboard needs SignalR push. The working rule from Finance: push is worth it for data
that changes intraday and is operationally meaningful to see live (pending instruments, open alerts,
anything with a queue/status that changes minute-to-minute). It is **not** worth it for
period-close/month-end aggregates whose only real "event" is a nightly posting job — an interval
poll off the already-existing (previously dead) `MPORTLET.RefreshIntervalMinutes` field, or a plain
"last refreshed" timestamp with manual refresh, is enough there.

Where push is warranted, reuse `DashboardHub`/`IDashboardHubNotifier`
(`GB5Framework/FrameworkSL/Hubs/Dashboard/`) — it already exists and pushes a
"this dataset/report changed for OU/period X" event (never the payload itself) so the client
refetches through its normal query path. Trigger it from the module's own BLL at the point a
real, meaningful state change already happens (a posting run completing, a status flip) via
`IHubContext<DashboardHub, IDashboardClient>` if co-hosted, or the cross-host
`POST /Dashboard/NotifyDataChanged` endpoint if the triggering BLL lives on a different host
from the FrameworkSL that maps the Hub.

### 7. Drilldown — reuse `MDRILLDOWN`/`MDRILLDOWNDETAIL`, don't build a new registry

GB5 already has a real, admin-configurable drilldown registry — `MDRILLDOWN` (source `FROMTYPE`
Menu/Portlet/Report + `FROMMENUID`/`FROMPORTLETID`/`FROMREPORTID` → target `TOTYPE` Menu/Portlet/
Report/Form/BizTransactionType + `TOMENUID`/etc.) and `MDRILLDOWNDETAIL` (per-field source→target
criteria mapping). It's already wired end to end: `MenuBLL.GetReportCriteriaForMenu` resolves it
server-side into a `DrillDownArray` returned on every `Framework.ReportMenu.Get` response, and the
FE's `navigateDrillDown` in `gbslickgrid.component.ts` reads `target.ToMenuId`/`target.ToMenuType`
generically (no per-report switch statement) to either open the target as a Report (re-entering the
exact same menu→report pipeline used to render dashboard portlets) or a Form. **Do not design a new
drilldown config table for a dashboard — this one already exists and any new detail-view target
should register into it.**

Two real wire-shape gotchas found wiring this (Finance's BRS pending-instrument → full instrument
list):
- **The target endpoint must accept the `ReportCallingDTO{CriteriaDTO, MenuId}` envelope.** The
  drill-navigate path's outbound call (`reportviewer.dbservice.ts`'s `reportData()`, GB5 branch)
  POSTs `{"CriteriaDTO": {"SectionCriteriaList": [...]}}` to the target's resolved URL — a plain
  `[FromBody] CriteriaDTO` endpoint (an older style, e.g. `POST /Voucher/BRSLoad`) would silently
  bind to a default/empty instance against that shape. Build a thin `ReportCallingDTO`-accepting
  wrapper first, exactly like the Compliance dashboard's own wrapper endpoints (see above) — same
  fix, same reason.
- **The target's `MMENU` row needs the same `MROLEVSMENU` grant as any other portlet menu** (see the
  callout above) — a drilldown target is just another menu resolved through
  `GetReportCriteriaForMenu`, so it silently returns nothing without one, exactly like a portlet.

For a first cut with no real per-row filter to carry over, an `MDRILLDOWNDETAIL` row with a blank
`SOURCEFIELD` is a legitimate, already-used pattern (several existing production drilldowns —
`PEnqView→PEnqScreen` and similar — do exactly this) — it's navigation-only, not every drilldown
needs a criteria mapping. Verify the wiring by calling `Framework.ReportMenu.Get`
(`GET /Menu/ReportMenuDetailsForMenu?MenuId=<source>&UserId=<real-user>&IsFromScreen=0`) directly and
confirming the returned `Reportdetails[0].DrillDownArray` contains your new link with the expected
`ToMenuId` — this proves the metadata resolves correctly even before a real browser click-through.

### 8. Print/Export/Share — mostly already free, one real gap to know about

Every "Report"-type portlet embeds the full `GbreportviewerComponent`, including its complete
PDF/Excel/CSV/Print/Mail/Schedule-report toolbar — **this needs no new code and no `MPORTLET`
config**. `MPORTLET.ISPRINT`/`ISEXPORT` are dead columns, unread anywhere in the FE — don't bother
setting them. The toolbar's format list comes from `MREPORTCONFIG.AllowedExportFormats`, keyed by
`REPORTID` (not `MENUID`) — and when no `MREPORTCONFIG` row exists for a report (the common case,
confirmed true for every Finance dashboard report including the pre-existing Receivable Dashboard),
the FE explicitly fails open and shows every format, by design. So a new dashboard portlet gets a
working Export/Print/Share-a-rendered-result button for free, with zero extra wiring, the moment its
report endpoint exists.

The one real gap: there is no admin screen to assign a *dashboard* to a role/user today — only
self-service "My Dashboards" pinning and a drift-reconciliation tool exist in the FE. The backend
(`MROLEVSDASHBOARD`/`MUSERVSDASHBOARD`) is real and functional, though — grant it the same way
portlets are granted (`MROLEVSMENU`-style direct insert) rather than waiting on a new FE screen,
unless building that screen is explicitly in scope. Don't confuse this ("assign this dashboard to a
role") with the much bigger "package dashboards + reports into a shareable, schedulable bundle for
non-users" concept some teams call BI-style sharing — that's a distinct, larger initiative spanning
Widget/Analysis/ReportViewer, not something to fold into a single module's rollout.

### 9. Verify before calling a module done

- `dotnet build` clean on every touched project.
- Live HTTP verification of every new/changed endpoint against GB5DEMO — a `dotnet build` pass on
  one project does not prove the endpoint is reachable through the host that actually serves it in
  production. See "Isolated worktree verification" below for why this matters on this repo
  specifically.
- Confirm the resulting dashboard is still editable afterward: add/remove/reorder portlets through
  the existing admin screens, on the actual dashboard you just built — this is the concrete proof
  that composability wasn't accidentally lost by shipping one hardcoded layout instead of a portlet
  library plus a default arrangement.
- A cropped, careful look at any chart screenshot before reporting it as broken or correct — a
  small full-page screenshot of a waterfall chart was nearly misread as "just independent bars"
  during Finance's verification. Zoom in before concluding a chart is wrong.

## Known pitfalls specific to this repo (not generic advice)

- **`GB5Shared.dll` HintPath.** Several DAL/BLL projects reference `GB5Shared.dll` via a hardcoded
  `bin/Debug` path rather than a ProjectReference. A fresh checkout (a new clone, or a git worktree)
  will fail to build every downstream project with a wall of `CS0246` errors for basic types
  (`LoginDTO`, `CriteriaDTO`, `IQueryExecutor`, `Result<>`) that look like a real break but are
  actually just "GB5Shared hasn't been built into `bin/Debug` yet in this checkout." Fix: build
  `GB5Shared/GB5Shared.csproj -c Debug` first, always, before building anything that depends on it
  in a new checkout.
- **Isolated worktree verification.** This repo's shared dev working tree
  (`/Users/venkatv/gbBE/gb5`) routinely has *other concurrent sessions'* uncommitted, in-progress
  work sitting in it — which can and does break builds of hosts/modules you never touched. Before
  concluding your own new code has a build problem, check `git status --porcelain` /
  `git diff origin/dev -- <file>` on any failing file to confirm whether the break is actually yours
  or someone else's uncommitted clutter. If it's not yours: build in an isolated `git worktree`
  checked out at `origin/dev` HEAD (`git worktree add <scratch-path> <commit>`), copy just your new
  files into it, build `GB5Shared` there first, then build the real host. This gives a clean
  verification untouched by whatever anyone else has mid-flight in the shared tree — do not "fix"
  the other session's uncommitted files yourself.
- **Which host actually serves the endpoint.** `dotnet build` on the module's own `*SL.csproj` only
  proves the module compiles in isolation — it does not prove the multi-host deployment actually
  serves the route. Find the real serving host
  (`GB5Solution/Hosts/<Area>/<Area>Host/<Area>Host.csproj`) and build/run *that* for live
  verification, not just the module project.
- **Shared-tree git hygiene.** Never `git add -A`/`git add .` in this working tree — always stage
  the explicit list of files you created/touched, verified via `git status --porcelain` to have zero
  overlap with any other session's in-progress files, before committing. If `git push` is rejected
  because `origin/dev` moved (very common — multiple sessions push to `dev` concurrently), `git
  fetch` + `git merge origin/dev` (not `rebase`) tolerates the other sessions' unrelated uncommitted
  local files better than rebase does, since merge only requires a clean tree for files that would
  actually change.
- **KPI/portlet row FK values.** `MPORTLET` has several NOT NULL FK columns
  (`REPORTFORMATID`, `REPORTID`, `PROGRAMID`, `DEFAULTCRITERIACONFIGID`) that are easy to get wrong
  by guessing a "NONE" sentinel. Copy every field value from an existing real portlet row of the
  same type rather than guessing — the working sentinel values aren't always `-1`
  (`REPORTFORMATID`'s real "none" value on this schema is `-1499999990`, not `-1`).
- **PortletCode length.** `MPORTLET.PORTLETCODE` truncates silently past a fairly short length —
  keep new codes to 8 characters or fewer, or you'll get an opaque "String or binary data would be
  truncated" failure with no indication of which column caused it.

## Reference: Finance's own gap table, as a worked example

| Mockup module | Backend today | What shipped |
|---|---|---|
| Profitability (P&L) | Already period-bucketed waterfall-shaped report | Waterfall chart portlet + segment breakdown |
| Cash Flow | Existing CashFlow/FundFlow report | Dashboard wrapper + trend cards |
| Receivables & Payables | Existing dashboard report (Kpi+InchargeWise+AgeingWise) | Presentation only, BizDimension filter added |
| Revenue & Expense | Overlaps P&L/FFINANCE | Presentation only |
| Budgeting & Forecasting | Existing comparison report | Presentation only |
| KPIs & Ratios | MKPI engine + Ratio Analysis report | Generalized trend-card support |
| Bank Reconciliation | Dead DTOs, no query | New query + endpoint (Phase 2) |
| Compliance & Audit | 7-phase GST Compliance module already complete | Thin dashboard wrapper (Phase 3) |
| Consolidation | OU-Group + SUM real; currency conversion scaffolding-only | Deferred — full elimination engine out of scope |

Use this as the template shape for Inventory/Sales/Purchase's own gap table — most of the real
report logic for those modules likely already exists too; the job is almost always presentation,
filter-tier wiring, and finding the one or two genuinely missing reports, not building a module's
worth of new business logic from scratch.
