# GB5 Portlet + Page + Dashboard Framework: Developer & Implementation Guide

## Status as of 2026-08-04

Backend: the full original 6-phase roadmap, the MDASHBOARDPAGE follow-up, and **Phase 6**
(Role→Dashboard materialization reconciliation) are all **implemented and live-verified on
GB5DEMO**. Frontend admin/dev UI shipped 2026-08-02, extended 2026-08-03/04 with a
search-and-select record UX, inline filter authoring, and real-menu-navigation wiring for 4 of
the dev-route screens — **TypeScript-verified (`tsc --noEmit`, zero new errors) throughout, and
every new backend endpoint/migration live-tested on GB5DEMO with test data cleaned up
afterward.** Browser click-through of the frontend itself is still not done — the shared dev
machine used for this build is memory-constrained (8GB RAM) and the full `gbhost` native-
federation host (30+ wired remotes) runs out of V8 heap before it finishes compiling. Screens
below are described from their source (routes, form configs, component code), not from a live
screenshot. Real screenshots are a follow-up once verification runs on a better-resourced
machine or a lighter-weight serve path is set up (see "Known gaps" at the end).

All backend code lives in `GB5Framework/{FrameworkDAL,FrameworkBLL,FrameworkSL}` (module: Framework,
Dapr app, port 5000). All frontend code lives in `gb4.7mfe`, branch `GBDEV4.7`, mostly under
`projects/framework/master/portlet/` and `projects/admin/entitlement/`.

## Entity model

```
MPORTLETTYPE ──1:N── MPORTLETTYPEVERSION      (versioned JSON schema registry)
MDASHBOARD ──1:N── MDASHBOARDPAGE ──N:1── MPAGE ──1:N── MPORTLET ──N:1── MPORTLETTYPE
MDASHBOARD ──1:N── MROLEVSDASHBOARD           (role-level visibility/pin/order)
MDASHBOARD ──1:N── MUSERVSDASHBOARD           (per-user pin/reorder override)
MENTITLEMENTFEATUREPORTLETTYPEMAP  (FEATURECODE string key → MPORTLETTYPE, cross-DB, no FK)
MENTITLEMENTFEATUREDASHBOARDMAP    (FEATURECODE string key → MDASHBOARD, cross-DB, no FK)
```

- **MPORTLETTYPE** is the catalog of portlet *kinds* (Chart, KPI Card, Table, Analysis, Search,
  …). `SourceType` classifies who owns it: `1`=Framework, `2`=DevAdmin (together, "Standard" —
  eligible for entitlement gating), `3`=ImpAdmin, `4`=Admin, `5`=User (together, "Custom" —
  gating bypassed unconditionally). `TenantId` (nullable int) and `IsRegistryManaged` (byte)
  were added in Phase 1 but were dormant in the DTO/QB layer until commit `79b3167a8` — before
  that, saving either field silently did nothing.
- **MPORTLETTYPEVERSION** holds a JSON schema per version of a portlet type (mirrors the
  existing `MENTITYLAYOUT`/`EntityLayoutBLL` versioning pattern). A version is drafted
  (`SaveTypeVersion`), then explicitly activated (`ActivateTypeVersion`), which materializes a
  set of capability flags back onto the parent `MPORTLETTYPE` row.
- **MPORTLET** is a placed instance of a portlet type on a page — e.g. "Q3 Sales by Region"
  is a Chart-type portlet. Carries optional cross-portlet pub/sub metadata
  (`PortletPublishChannelName`/`PortletPublishFieldName`/`PortletSubscribeChannelName`/
  `PortletSubscribeFieldName`) and its own filter override tier (below).
- **MPAGE** groups portlets into a tab. Pre-existing entity, extended in this roadmap with its
  own filter tier fields.
- **MDASHBOARD** is new (Phase 2) — groups pages into a dashboard, with its own SourceType
  (Standard/Custom, same semantics as PortletType) and its own filter tier field.
- **MDASHBOARDPAGE** is the join table assigning pages to a dashboard with an explicit
  `SlNo` (tab order), `IsDefaultTab`, `IsVisible`.
- **MROLEVSDASHBOARD** / **MUSERVSDASHBOARD** record which dashboards a role/user sees, plus
  pin state and personal sort order — same shape, `MUSERVSDASHBOARD` is the per-user override
  layer on top of the role-level default.

## Filter/criteria tier precedence

Four tiers, resolved in this order — **Dashboard > Page > Portlet > User**:

1. `MDASHBOARD.DefaultCriteriaConfigId` — the dashboard's own default filter.
2. `MPAGE.DefaultCriteriaConfigId`, applied unless `MPAGE.OverrideDashboardCriteria = 0` (the
   field name is inverted from what it sounds like — read it as "does this page's own default
   *replace* the dashboard's?").
3. `MPORTLET.DefaultCriteriaConfigId`, applied unless `MPORTLET.OverridePageCriteria = 0` —
   same inversion pattern.
4. Whatever the user has interactively set at runtime (existing, pre-this-roadmap behavior).

Both override flags are backend `byte` (0/1) fields. **Do not bind them to an HTML
`<input type="checkbox">`** — `CheckboxControlValueAccessor` sends a literal `true`/`false`,
not `0`/`1`. Every screen in this roadmap uses a plain `<input type="number" min="0" max="1">`
instead, matching the existing convention for `IsPinned`/`IsVisible` elsewhere in this codebase.

**Deliberate scope-down, worth knowing before extending this**: the plan originally called for
resolving Page/Dashboard filter trees via two more JOINs directly inside `PortletQB`'s existing
~20-table mega-query. That was not done — those two extra 3-level `TCRITERIACONFIG` joins would
multiply row count on the busiest query in the framework. Instead, `PortletDAL.GetPortletListWithCriteria`
resolves each tier via small, separate calls to a new private helper (`GetCriteriaConfigTree`,
reusing `PortletQB.GET_CRITERIA_CONFIG_TREE_BY_ID`). Net DTO shape and precedence are identical;
the risk profile is not.

**Authoring a `CriteriaConfig`**: Page/Dashboard/Portlet can now author a brand-new named filter
inline, not just reference an existing `CriteriaConfigId` by number. `gbfilter.component.ts`'s
`SaveConfiguration()` was the obvious candidate to reuse, but it's scoped by
`MenuDetail[0].WebServiceId` via `WEBSERVICECRITERIA` (confirmed in `WebServiceCriteriaQB.cs`) —
Page/Dashboard/Portlet have no `WebServiceId`, and gbfilter itself is a ~2000-line component
tightly coupled to `MenuDetail`/dexie caching, not something to extract a fragment from.
`webservicesetting.component.ts`'s similar-looking `/cs/Criteria.svc/?ObjectCode=X` call was
checked too and is a legacy GB4 `.svc` endpoint, not part of GB5's backend — not reusable
either.

Instead: a new `MENTITYCRITERIA` table mirrors `MWEBSERVICECRITERIA`'s shape exactly, keyed by
a plain `ENTITYTYPE` string (`'Page'`/`'Dashboard'`/`'Portlet'`) instead of `WEBSERVICEID` —
additive only, zero risk to the existing `MCRITERIAATTRIBUTE`/`MWEBSERVICECRITERIA` joins (that
relationship was already confirmed many-to-many, so a same-shaped sibling table is the natural
fit). A new `GetEntityCriteria(EntityType)` endpoint mirrors `GetWebServiceCriteria`'s DTO shape
so the frontend didn't need a parallel data model. Seeded with a first small batch (Status, OU)
against already-existing generic `MCRITERIAATTRIBUTE` rows — no new attribute rows invented.

On the frontend, a small standalone dialog (`features/gbentitycriteria/`,
`GbEntityCriteriaDialogComponent`) calls `GetEntityCriteria` for the attribute list and
`Framework.CriteriaConfig.Save` directly (a "+ New Filter" button next to each screen's
existing Default Criteria Config Id field), bypassing gbfilter/`FilterService` entirely. Wired
into Dashboard (patches `gb-metaform`'s publicly-exposed `form` via `@ViewChild`), Page (patches
`GBBaseFormGroup` directly — this also required adding the `DefaultCriteriaConfigId`/
`OverrideDashboardCriteria` fields to `page.component.html` itself, since Phase 4 had added them
to `formjson/page.json` but the hand-rolled template never actually rendered them), and Portlet
(patches its own reactive form directly). `Framework.CriteriaConfig.Save` didn't exist in
gb5api's `framework.ts` before this — only `Framework.CriteriaConfig.Picklist` did (the Save
flow previously only existed on gb4api, used by gbfilter).

**Two real bugs caught via live testing of the exact save payload against GB5DEMO**, not
theoretical: `TCRITERIACONFIG.SOURCETYPE` has a CHECK constraint (`IN 1-5`) the initial payload
didn't set, defaulting to 0 and failing every save — fixed to `SourceType=4` (Admin), matching
this roadmap's established Standard/Custom `SourceType` convention. Confirmed the full
Config/Section/Attribute row chain saves with the exact values sent, then cleaned up the test
row.

## Portlet Type versioning workflow

```
SaveTypeVersion(PortletTypeId, SchemaJson, …)   → drafts a new MPORTLETTYPEVERSION row
GetTypeVersionList(PortletTypeId)               → list all versions for a type
ActivateTypeVersion(PortletTypeId, PortletTypeVersionId) → makes one version live,
                                                             materializes capability flags
                                                             onto MPORTLETTYPE
GetActiveTypeVersion(PortletTypeId)             → the currently-active version's schema
```

`ActivateTypeVersion` had a real deadlock bug found via live testing: `GetVersionById` was
running mid-transaction on an *untransacted* connection, blocking on the row lock
`ActivateVersion` had just taken on the same row. Fixed by threading the transaction through
consistently — if you add a new step to this workflow, keep every read/write inside the same
transaction scope.

## Phase 6: Role→Dashboard materialization reconciliation

`MROLEVSPORTLET`/`MUSERVSPORTLET` already had a "diff, don't auto-apply" reconciliation pair —
`GetRoleVsPortletForUpdateUserVsPortlet`/`GetUserVsPortletForDeleteUserVsPortlet` — that finds
(a) users on a role missing a materialized row for a portlet the role now grants, and (b) stale
materialized rows that no longer match any role grant. Phase 6 brings `MROLEVSDASHBOARD`/
`MUSERVSDASHBOARD` in line with the same shape:
`GetRoleVsDashboardForUpdateUserVsDashboard`/`GetUserVsDashboardForDeleteUserVsDashboard`
(`RoleVsDashboardQB`/`DAL`/`BLL`, `FrameworkSL/Endpoints/RoleVsDashboard/`), mirroring the
Portlet SQL shape exactly, substituting Dashboard/Page for Portlet/Page. **These only compute a
diff — nothing auto-applies the result**, matching the Portlet precedent; a caller (today: a
future admin screen) applies it via the existing `SaveUserVsDashboard`/`DeleteUserVsDashboard`
endpoints. This does not replace `RoleVsDashboardBLL`'s existing `AllInvalidateCache("-1")`
cache flush on write — the two are complementary, not substitutes.

**Real pre-existing bug found while mirroring, not fixed (out of scope)**: the `RoleVsPortlet`
endpoints being mirrored have a genuine 3-way cache-key collision —
`GetRoleVsPortlet`/`GetRoleVsPortletForUpdateUserVsPortlet`/`GetUserVsPortletForDeleteUserVsPortlet`
all generate the identical cache key (same `RoleId`/`OBJECTROLEVSPORTLET`/`CacheKeyLevel.USER_LEVEL`,
and `BaseEndpoint`'s cache key has no route component of its own — the same bug class fixed for
`GetDimensionsForDataset`/`GetMeasuresForDataset` in commit `c8f3c6e50`). The new Dashboard
endpoints do **not** repeat this — each uses a distinct objectId suffix (`-for-update`/
`-for-delete`) so they never collide with each other or with the pre-existing, unsuffixed
`GetRoleVsDashboard`.

## Cross-portlet pub/sub

`PortletPublishChannelName`/`PortletSubscribeChannelName` route a shared notification bus
message between portlets on the same page/dashboard; `…FieldName` identifies which data field
travels on that channel. Backend validation (`PortletBLL.SavePortlet`): each pair
(Publish channel+field, Subscribe channel+field) must be set *together* or *neither* — one half
alone is rejected.

On the frontend, this was **write-only until Phase 4**: `gbcard.component.ts`,
`gbdynamicchart.component.ts`, and `gbdynamictable.component.ts` all hardcoded a single global
`DASHBOARD_CHANGES_CHANNEL` for both publish and subscribe, ignoring the per-portlet metadata
entirely. Fixed (fallback-only, by explicit product decision): each component now reads
`this.PortletData.PortletPublishChannelName || DASHBOARD_CHANGES_CHANNEL` (and the Subscribe
equivalent) and uses that at every `notificationService.publish()`/`.subscribe()` call site.
**Field-name-based matching was explicitly NOT wired** — only channel routing. A portlet with
no configured channel behaves exactly as it did before this change.

## Entitlement gating

- `SourceType IN (1,2)` (Framework/DevAdmin) = **Standard**, eligible for entitlement gating.
  `IN (3,4,5)` (ImpAdmin/Admin/User) = **Custom**, gating bypassed unconditionally. Enforced at
  *read time* in `PortletTypeBLL.GetSelectListPortletType` / `DashboardBLL.GetDashboardListForUser`
  — not a DB constraint. (Live GB5DEMO `SourceType` data already spans 1-5, so a hard DB
  constraint was never viable here.)
- **Cross-database topology, important if you extend this**: Entitlement's own tables
  (`MENTITLEMENT*`) live in a single shared **platform** database (`EntitlementDb` /
  `USCIMPSYS`), never in a client's own OLTP database — confirmed by reading
  `EntitlementBLL/Common/EntitlementLoginFactory.cs`, which builds Entitlement's `LoginDTO`
  with a fixed `DatabaseName`, not the caller's tenant one. This is why
  `MENTITLEMENTFEATUREPORTLETTYPEMAP`/`MENTITLEMENTFEATUREDASHBOARDMAP` store `FEATURECODE` as
  a plain, unenforced string business key rather than a `FeatureId` FK — a cross-database
  foreign key is impossible in SQL Server. `PORTLETTYPEID`/`DASHBOARDID` on those same tables
  *do* keep real enforced FKs, since `MPORTLETTYPE`/`MDASHBOARD` live in the same OLTP DB as the
  mapping tables. Before assuming any other "same repo → same database" shortcut in this
  codebase, grep the referenced table's own DAL for how it builds its `LoginDTO`.
- **Fail-open by design**: `FrameworkBLL/Entitlement/EntitlementGateClient.cs` calls a
  `"entitlement-internal"` HTTP client (loopback via the YARP gateway, same pattern as the
  existing `"eip-internal"` client) and returns `true` on any HTTP/parse error. A gateway
  hiccup must never hide an entire Standard portlet-type/dashboard list from an otherwise-
  entitled client. GB5DEMO itself has no Entitlement schema and no `/lic/*` gateway route, so
  this path is permanently in fail-open there — a useful thing to know if a live check on
  GB5DEMO ever looks like it's "not enforcing anything."

## Cache invalidation

Response caching goes through `DaprClient.SaveStateAsync`/`GetStateAsync`/`DeleteStateAsync`
against Dapr's `"statestore"` component (a standard `state.redis`, `ttlInSeconds=60`,
`keyPrefix: "gb5_"`). `GB5Shared/DaprCache/KeyInvalidate.cs` provides two invalidation shapes:

- **`AllInvalidateCache("-1")`** — a real, full flush: injects an optional
  `IConnectionMultiplexer` (nullable by design — ~30 other SL hosts call `KeyInvalidate` without
  Redis registered at all, and DI must fall back to `null` for those), then does an
  `IServer.KeysAsync(pattern: "gb5_*")` SCAN + batched `KeyDeleteAsync`. **Before this
  roadmap this call literally did nothing** — it called `DeleteStateAsync` with an
  `{"action":"delete-all"}` metadata hint that the standard `state.redis` component doesn't
  recognize. Proved empirically (create → cache → delete → immediate re-GET still returned the
  deleted row) before being rewritten. Used by `DashboardBLL`/`DashboardPageBLL`/`RoleVsDashboardBLL`
  — writes to these are rare/admin-driven, and the blast radius (every user who can see a
  Shared/Role dashboard) isn't cheaply enumerable, so a nuclear flush is the pragmatic choice.
- **`InvalidateByKey(objectId, objectTypeId, cacheKeyLevel, LoginDTO, criteriaDTO?)`** —
  reproduces the exact SHA-256 cache key a `GetCacheKey()` override would have produced, by
  reusing `GB5Shared.DaprCache.CacheKeyGeneration` directly from BLL code. Used by
  `UserVsDashboardBLL` (guarded by a self-service check: only precise-invalidate when
  `LoginDTO.UserId` matches the target row's `UserId`, since both relevant cache keys embed
  the *caller's* `UserId`/`RoleId`, not necessarily the target's) — pin/reorder is a frequent
  self-service action, and nuking every user's Portlet/Report/EntityViewer cache on every
  drag-reorder would be wasteful.

**If you add a new cached, frequently-written entity**: don't assume an existing "invalidate"
helper actually invalidates anything just because its name and every other call site imply it
does — check the backing store's actual component config (`components/*.yaml`) for what
operations it supports before trusting a wrapper's method signature.

## API surface (all under Framework, Dapr port 5000)

| Entity | Endpoints |
|---|---|
| PortletType | `GetPortletType`, `SavePortletType` (pre-existing); `GetTypeVersionList`, `SaveTypeVersion`, `ActivateTypeVersion`, `GetActiveTypeVersion` (Phase 1) |
| Portlet | `GetPortlet`, `SavePortlet` (pre-existing); `DeletePortlet`, `SelectListPortlet` (Phase 2, FE-registered this roadmap — the endpoints existed, nothing called them from the frontend) |
| Page | `GetPage`, `SavePage`, `DeletePage` (pre-existing, extended with filter-tier fields) |
| Dashboard | `GetDashboard`, `SaveDashboard`, `DeleteDashboard`, `GetSelectListDashboard`, `GetDashboardListForUser` (Phase 2) |
| DashboardPage | `GetDashboardPage`, `SaveDashboardPage`, `DeleteDashboardPage` (built 2026-08-01 to close a gap — Phase 2 shipped the read-side JOIN but nothing wrote page↔dashboard assignments except raw SQL) |
| UserVsDashboard | `GetUserVsDashboard`, `SaveUserVsDashboard`, `DeleteUserVsDashboard` (Phase 5) |
| RoleVsDashboard | pre-existing, cache-invalidation gap closed 2026-08-02; `GetForUpdateUserVsDashboard`/`GetForDeleteUserVsDashboard` reconciliation pair (Phase 6, 2026-08-04) |
| FeaturePortletTypeMap / FeatureDashboardMap | `Get`/`Save`/`Delete` for both (built 2026-08-02 — Phase 5 only wired the read-side JOIN, nothing let anyone create/delete a mapping row except raw SQL) |
| EntityCriteria | `GetEntityCriteria` (2026-08-04) — the attribute catalog for Page/Dashboard/Portlet's inline filter-authoring dialog, keyed by `EntityType` instead of `WebServiceId` |
| CriteriaConfig | `SaveCriteriaConfig` (pre-existing on the backend; FE-registered for gb5api 2026-08-04 — previously only reachable via gb4api, used by gbfilter) |

## Frontend architecture

Two competing, both-live master-screen conventions in this repo — pick whichever matches
the screen you're extending, don't mix them:

- **`gbmetaform`** — JSON-config-driven (`*.meta.json` + a 3-line wrapper component +
  `metaform-registry.json` entry). Used for the new Dashboard master screen and both new
  Entitlement mapping screens. Simpler, but no support for dynamic/dependent pickers.
- **Hand-written `GBBaseFormGroup`/`formjson/*.json`** — used for `PortletComponent`,
  `PageComponent`, `PortletTypeComponent`. Needed wherever a field's options depend on another
  field's value (e.g. Portlet's Analysis-type dataset/dimension/measure cascade).

### New/changed screens (all pushed to `GBDEV4.7`)

| Screen | Path | Pattern | Route |
|---|---|---|---|
| Dashboard master + Page assignment | `projects/framework/master/portlet/dashboard/` | gbmetaform + hand-written page-assignment panel | `/dev/dashboard` **+ real menu** (`frameworkdashboardadmin`) |
| Portlet master (now full CRUD) | `projects/framework/master/portlet/portlet/portlet.component.ts` | GBBaseFormGroup | existing `/portlet-config-demo` route |
| PortletType master (extended) | `projects/framework/master/portlet/portlettype/portlettype.component.ts` | GBBaseFormGroup | existing menu-driven route (needs injected `selectedId`/`MenuRights`/`DrillDownDetails` — not reachable via a plain dev route) |
| PortletType version manager | `projects/framework/master/portlet/portlettype/portlettypeversion.component.ts` | standalone (sibling to PortletType, not an extension — see below) | `/dev/portlettype-version` **+ real menu** (`frameworkportlettypeversion`) |
| Entitlement: PortletType↔Feature map | `projects/admin/entitlement/portlettypemap/` | gbmetaform | `/dev/ent-portlettypemap` **+ real menu** (`entportlettypemap`) |
| Entitlement: Dashboard↔Feature map | `projects/admin/entitlement/dashboardmap/` | gbmetaform | `/dev/ent-dashboardmap` **+ real menu** (`entdashboardmap`) |
| Page master (extended) | `projects/framework/master/portlet/page/page.component.ts` | GBBaseFormGroup | existing menu-driven route |
| My Dashboards (end-user) | `features/gbmydashboards/` | standalone `MatDialog` | opened via a trigger icon added to `gbtabcontainer`, not a route |

`PortletTypeVersionComponent` is a **sibling** of `PortletTypeComponent`, not an extension of
it, specifically because `PortletTypeComponent` requires DI tokens (`selectedId`, `MenuRights`,
`DrillDownDetails`) that only the generic menu-driven form-viewer host supplies — it can't be
reached from a plain dev route at all. If you need to add another child-management panel to a
menu-driven master screen, expect the same constraint.

**Real menu navigation, added 2026-08-04, for the four screens marked above**: each got a
`federation.config.js` exposes entry, a `gbformviewer.registry.json` canonical entry, and a
`MWEBFORM`/`MMENU`/`MMODULEVSMENU`/`MROLEVSMENU` seed migration
(`DB/Migrations/20260803_PortletDashboardFramework_MenuSeed_SqlServer.sql`), mirroring the
already-merged `20260716_Entitlement_Phase1_6_MenuSeed` template. Dev routes are kept in place
as a fallback, not removed.

**How real-menu resolution actually works, confirmed via live tracing (corrects an earlier,
wrong pass of this same investigation)**: `GetMenuForUserModule`'s real query
(`MenuQB.GET_MENU_TREE_FOR_USER_MODULE`) is a 3-branch `UNION ALL`, not a single
`MUSERVSMENU`-keyed query. Branch A joins `MUSERVSMENU` to `MMENU` filtered by `UserId` — every
row is hardcoded to `ParentId = -5`, i.e. this branch supplies **only the user's personal "My
Favorite" bookmark list** (confirmed further by `Constant.cs`'s dedicated
`SAVEUSERVSMENUEVENTTYPEID`/`DELETEUSERVSMENUEVENTTYPEID` — a self-service favorite toggle, no
admin CRUD screen exists for it). Branch B is the synthetic "My Favorite" folder node itself.
**Branch C is the real main tree** — `MROLEVSMODULES` + `MMODULEVSMENU` + `MMENU` +
`MROLEVSMENU`, gated on `rolevsmenu.RoleId = muser.RoleId` and
`SUBSTRING(rolevsmenu.Allow,1,1) = '0'`. `MUSERVSMENU` appears in Branch C only via a harmless
`LEFT OUTER JOIN` used to flag "is this item also bookmarked" — never in a gating condition.

**`MROLEVSMENU` (via `MMODULEVSMENU`/`MROLEVSMODULES`) is the real, sole access-control gate —
exactly what the migration below writes; no `MUSERVSMENU` row is needed at all.** Live-verified
directly: `MROLEVSMODULES` already grants `GBSUPERUSER` (`-1799999978`) access to the `Admin`
module on GB5DEMO; called the live endpoint with `UserId` set to a real user holding that exact
role (a query param independent of the calling session's own identity) and confirmed all four
screens appear as top-level items in the real tree with their exact expected
`WebFormSecondURL` values — zero `MUSERVSMENU` rows involved, nothing to clean up (read-only
check). This applies equally to the already-merged 20260716 migration's 14 Entitlement screens
— any user holding a role with the right `MROLEVSMENU` grant sees them today, no external sync
process required.

Every other screen not marked above remains **dev-route-only** — matching the two most recently
added master screens in this repo before this work (`PortletComponent`, `MenuAdminComponent`).
Real MMENU-driven navigation for them is unsolved, repo-wide, not something this roadmap
attempted to fix beyond the four screens above.

Also found and fixed **five real bugs** in the menu-seed process via live testing against
GB5DEMO (none hit by the 20260716 template, because it was apparently never actually run):
`MWEBFORM`/`MMENU` both have a `SOURCETYPE` CHECK constraint (`IN 1-5`) a literal `0` violates;
`MMENU.TOVERSION` is `NUMERIC(3,1)` (max `99.9`), overflowed by a literal like `9999`;
`MMENU.MENUCODE` is `NVARCHAR(10)` — far too short for any registry-key-length code (this
roadmap's own, or the 20260716 template's own `'entfeaturegroups'`-style codes) — fixed by
giving each screen its own short internal `MenuCode` distinct from its long, registry-matching
`WebFormSecondURL`; `MMENU.DEFAULTREPORTVIEWID` is `NOT NULL` with no default and was omitted
entirely from the template's own column list.

### `proxy.conf.js`

`FWS_FRAMEWORK_CONTROLLERS` routes specific `/fws/*` paths to Framework (port 5000) instead of
the Business/5102 catch-all when targeting GB5DEMO. Extended this roadmap to include:
`/fws/Dashboard`, `/fws/UserVsDashboard`, `/fws/RoleVsDashboard`, `/fws/DashboardPage`,
`/fws/FeaturePortletTypeMap`, `/fws/FeatureDashboardMap`, `/fws/Portlet`. Add any new
Framework-hosted controller here or it'll silently route to the wrong backend host when
targeting GB5DEMO locally.

## Known gaps / tech debt

- ~~No search-and-select-existing-record UX on `dashboard.component.ts`/`portlettypeversion.component.ts`~~
  **Closed 2026-08-03**: both now use `gb-newpicklist` (this repo's standard search-and-select
  control) bound standalone via its `[PicklistConfigData]`/`(PicklistValue)` inputs — the same
  pattern `overalldashboard.component.ts` already used outside any `GBBaseFormGroup`/menu-driven
  context. No new shared component or backend endpoint was needed: `Framework.Dashboard.GetSelectList`,
  `Framework.Page.Picklist`, and `Framework.PortletType.Picklist` already existed and already
  return the `{Id, Code, Name}` shape `gb-newpicklist` expects. `portlet.component.ts`'s own
  "Load Existing Portlet" `<select>` was left as-is (a separate, smaller gap, not touched here).
- ~~Phase 6 (optional Role/Dashboard→User materialization cleanup) not started~~ **Closed
  2026-08-04** — see "Phase 6" section above.
- ~~Filter authoring UI for Page/Dashboard/Portlet is reference-by-id only~~ **Closed
  2026-08-04** — see "Authoring a `CriteriaConfig`" above.
- ~~Real menu navigation unsolved for every screen in this roadmap~~ **Partially closed
  2026-08-04**: Dashboard Admin, PortletType Version Manager, and both Entitlement mapping
  screens now have real MMENU wiring (see "Real menu navigation" above) — `portlet.component.ts`'s
  existing route, `PageComponent`/`PortletTypeComponent` (already menu-driven), and `My
  Dashboards` (not a route at all) were out of scope for this pass.
- **Browser click-through not yet done** for any screen in this roadmap. TypeScript-level
  verification is clean; the blocker turned out to be this specific dev machine's 8GB RAM
  ceiling against the full `gbhost` federation host's build memory footprint (not a code bug,
  not the Angular/esbuild version skew or parallel-TS-worker assertion it initially looked
  like — those were downstream symptoms of the same OOM). Two `postinstall`-time environment
  fixes were made regardless, since they're correct independent of this blocker:
  - `scripts/patch-angular-build.js` now patches **every** installed copy of
    `@angular/build`'s `lazy-routes-transformer.js` (top-level plus the private nested copies
    `@angular-devkit/build-angular` and `@angular-architects/native-federation` each carry) —
    previously it only patched the top-level copy, which Node's module resolution doesn't
    actually use.
  - `NG_BUILD_PARALLEL_TS=0` avoids a separate worker-thread assertion
    (`node_assert_1.default(compilation)` in `@angular/build`'s parallel TS compiler) that
    surfaces under the same memory pressure.
- **No search-and-select-existing-record UX** for `portlet.component.ts`'s own "Load Existing
  Portlet" `<select>` (a plain dropdown, not a searchable picker) — a separate, smaller gap,
  deliberately not touched by the search-and-select closure above.
