# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage ## Goal Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris", "Tankrengøring") only appear when the user is already on the matching view tab. On the "Alle" tab the chips never show, even when a customer actually belongs to several categories. We want every chip to render on every subpage whenever the customer belongs to that category — independent of which view tab is active. --- ## 1. Root cause (already confirmed by investigation) ### Front-end rendering path * `Right.vue` (line ~300+) declares view tabs and fetches `/superuser/invoicing/period` with the corresponding `periodView` query param (`all`, `invoice_per_order`, …). * `InvoicingBillingPeriodViewAll.vue` is rendered for every active view (including `all`). It reads the active bucket via `view.variables.sharedVariables.value.types[componentName]`. * For each customer card it mounts `InvoicingBillingPeriodCustomerAttributes.vue`, which computes `list_views_with_customer`: ```ts const list_views_with_customer = computed(() => { const matched = view_keys.value.filter((view_key) => { if (view_key === 'all') return false; const view_type = sharedTypes.value[view_key]; return view_type && view_type.some( (v: any) => v.customer_number === props.customer.customer_number, ); }); … }); ``` It only treats a customer as belonging to a view if `types[view_key]` contains an entry with the same `customer_number`. ### Back-end paging path * `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning, special_arrangements, invoice_per_order, possible_duplicates, self_wash, all) holds full customer cards. * `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then truncates the response so that ONLY the bucket matching `$periodView` carries the full card data; every other bucket becomes `[]`. ```php $pagedTypes = array_fill_keys(array_keys($types), []); if ($isAllLimit) { $pagedTypes[$periodView] = array_values($types[$periodView] ?? []); } else { $offset = ($page - 1) * $perPage; $pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage); } ``` * The frontend then iterates over the (empty) non-active buckets and finds no customer entries → no chip is rendered → the bug. ### Why the existing e2e test missed it `tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line ~864) returns FULL customer data for every type in the mock payload. Because the mock already mimics the "pre-fix" backend behaviour (every type populated), the chip-rendering path is exercised even when the real backend strips the data. Updating the mock to mirror the new, real backend shape gives us an end-to-end safety net. --- ## 2. Fix strategy We want one round trip, no N+1 calls, and a payload that stays bounded. **Approach: lightweight membership entries** Extend `applyPeriodPagination` so that, after pagination, every non-active view bucket is populated with "membership only" entries — each entry is just `{ customer_number }` so the frontend can resolve membership via the existing `view_type.some(v => v.customer_number === …)` check. * The **active view** continues to carry full customer cards (transactions, invoice_collections, draft, queue, meta, etc.) — no behaviour change for it. * **Every other view** carries a `{customer_number: N}` array (one per matching customer after all filters / search / sort / pagination). No transactions or auxiliary fields — keeping the payload small. * `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged. `type_counts` (already computed before pagination) keeps the totals per view, so tab counters remain correct. * The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`) stores the full `periodResult` verbatim, so cached responses naturally retain the new lightweight entries. ### Why this option wins | Approach | Network | Payload | Schema change | UX consistency | |---|---|---|---|---| | **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ | | N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow | | Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination | --- ## 3. Concrete code changes ### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php` In `applyPeriodPagination(...)` (around line 730-742), after the active bucket is sliced, populate every non-active bucket with lightweight memberships derived from the already-filtered/searched/sorted `$types` arrays: ```php // Existing pagination of the active bucket $pagedTypes = array_fill_keys(array_keys($types), []); if ($isAllLimit) { $pagedTypes[$periodView] = array_values($types[$periodView] ?? []); } else { $offset = ($page - 1) * $perPage; $pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage); } // NEW: lightweight memberships for every non-active view so the front-end // can render category chips regardless of which tab is active. foreach ($types as $typeName => $customers) { if ($typeName === $periodView) { continue; } $pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships( is_array($customers) ? $customers : [] ); } ``` Add a new helper: ```php /** * Return a minimal `{customer_number: N}` array per customer so the * front-end can determine which non-active view buckets the customer * belongs to without us shipping full transaction/queue data. * * Filters, searches, sort and visibility rules have already been applied * to `$customers` by the time we run, so we just de-duplicate and emit. * * @param array> $customers * @return array */ private static function summarizePeriodCustomerMemberships(array $customers): array { $memberships = []; $seen = []; foreach ($customers as $customer) { if (!is_array($customer)) { continue; } $customerNumber = (int) ($customer['customer_number'] ?? 0); if ($customerNumber < 1 || isset($seen[$customerNumber])) { continue; } $seen[$customerNumber] = true; $memberships[] = [ 'customer_number' => $customerNumber, 'membership_only' => true, ]; } return $memberships; } ``` Notes: * We deduplicate on `customer_number` so a customer appearing twice in a bucket (rare but possible — multiple PO transactions for the same customer in `invoice_per_order`) still only emits one membership. * We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees so consumers that iterate `Object.keys(types)` still see every view even when the filtered list ends up empty. * The active bucket's structure is **unchanged** — the front-end `customersInCurrentView` and `list_views_with_customer` paths continue to work as before. * `type_counts` and `type_totals` are computed before pagination (see `summarizePeriodTypes`) and remain authoritative for tab counters. ### 3.2 OpenAPI specs Both repositories carry a copy of the schema and must stay in lock-step. **`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`** The current envelope for `InvoicingPeriod` (`types[view]`) is typed via `InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`, `transactions`, `invoice_collections`. Membership entries don't carry those fields, so we need to relax the `required` constraint on non-active buckets and document the new shape. Add a new sibling component: ```yaml InvoicingPeriodCustomerMembership: type: object description: >- Lightweight customer marker returned for every non-active view bucket. Used only by the front-end to render category chips (e.g. "Faktura pr. ordre") regardless of which tab is active. Full transaction / queue data is intentionally omitted; see InvoicingPeriodCustomer for the shape returned for the active bucket. additionalProperties: false required: [customer_number, membership_only] properties: customer_number: type: integer minimum: 1 membership_only: type: true enum: [true] ``` In the `InvoicingPeriod` schema, switch the `types` property from `additionalProperties: $ref(InvoicingPeriodCustomer)` to: ```yaml types: type: object additionalProperties: type: array items: oneOf: - $ref: '#/components/schemas/InvoicingPeriodCustomer' - $ref: '#/components/schemas/InvoicingPeriodCustomerMembership' discriminator: propertyName: membership_only ``` Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`, `invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no longer `required` (they remain documented in `properties`). The active bucket still emits them, but the union makes the membership shape valid. ### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue` After the backend fix, the chip rendering logic in `list_views_with_customer` will start working on every subpage. To keep performance bounded when buckets grow large, we also turn the membership arrays into `Set` lookups via a small `computed`: ```ts const membershipIndexes = computed(() => { const result: Record> = {}; for (const view_key of view_keys.value) { if (view_key === 'all') continue; const view_type = sharedTypes.value[view_key]; if (!Array.isArray(view_type)) { result[view_key] = new Set(); continue; } result[view_key] = new Set( view_type .map((entry) => Number(entry?.customer_number ?? 0)) .filter((n) => Number.isInteger(n) && n > 0), ); } return result; }); const list_views_with_customer = computed(() => { const matched = view_keys.value.filter((view_key) => { if (view_key === 'all') return false; return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true; }); … }); ``` Behavioural impact: * Same chip set as today, now visible on every subpage including `Alle`. * Lookup is O(1) per (view × customer) instead of O(bucket size). * Defensive against the lightweight entries (no `customer_name`, `transactions`, etc. fields) — the chip only needs the view's friendly name, which already comes from `view.computed.getViewFriendlyName(...)`. ### 3.4 Front-end — e2e mock `tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints` (line ~864) currently mocks every bucket as fully populated. Update the mock so that: * The **active** bucket (whichever the page requested) carries full customer cards (unchanged). * Every **other** bucket carries membership-only entries (`{customer_number, membership_only: true}`). This mirrors the real backend so the existing chip-stacking test (`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually guards the membership path. --- ## 4. Tests to add / update ### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php` Existing assertion at line 292: ```php expect($result['period']['types']['fixed_pricing'])->toBe([]); ``` …becomes: ```php expect($result['period']['types']['fixed_pricing']) ->toBe(array_map( static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true], [1001], // the test fixture's other-bucket membership )); ``` Add a new test that, given a period with two customers in `all` and one in `invoice_per_order`, paging `periodView=all` yields: * `types.all` — full customer cards (existing behaviour preserved) * `types.invoice_per_order` — one lightweight membership entry * `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no matching customers, so nothing to emit) Add a search-aware test: searching for "Beta" while paging `periodView=all` must surface the lightweight membership only for customers that pass the filter, mirroring the active bucket. Add a flag-tab-aware test: the `red` flag filter must propagate to the membership arrays just as it does to `type_counts`. ### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec) Add a focused Vitest spec `tests/unit/invoicing-period-customer-attributes.spec.js` that mounts `InvoicingBillingPeriodCustomerAttributes` with a stubbed `sharedVariables.value.types` containing: ```ts { all: [...full cards], invoice_per_order: [{customer_number: 1001, membership_only: true}, …], fixed_pricing: [], … } ``` …and asserts that the rendered chips include "Faktura pr. ordre" (and any other categories the stubbed customer is a member of), independent of which view tab is "active" in the stub. ### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js` * Update `setupPeriodEndpoints` (line ~864) so the mock returns membership-only entries for non-active buckets — matching the real backend contract. * Extend the existing chip-stacking test (lines ~2360-2393) to assert that on the `Alle` tab the rendered customer cards include the "Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc. * Add a new spec scenario: `Given: Alle tab with mixed customers across categories. When: page loads. Then: every customer card shows chips for every category it belongs to.` Guarded with `@smoke` so it runs in the PR pipeline. ### 4.4 OpenAPI consistency Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent script in `scripts/sync-ai-workflow.mjs` to verify that the two `openapi.yaml` files remain aligned. If a drift check is not wired up, add it so future schema edits surface in CI. --- ## 5. Verification steps (manual + automated) ### 5.1 Manual smoke test (in dev) 1. `bash scripts/setup.sh` (or the appropriate docker compose command) to bring up the API stack. 2. `cd /workspace/pleno-vue && npm run dev`. 3. Sign in as a superuser that owns customers spanning multiple categories (fixed_pricing + invoice_per_order, for instance). 4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range. 5. On the **Alle** tab confirm every customer card shows every chip it qualifies for. 6. Click into the **Faktura pr. ordre** tab and confirm the same chips render (sans the active tab's own chip). 7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**. 8. Apply the search box; chips should update with the filter. 9. Toggle the **Kræver handling** flag tab; chips should narrow to the flagged subset. 10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain consistent across pages. 11. Reload the page — chips must persist from the cache layer (`setCachedPeriodPage`) and not flash empty. ### 5.2 Automated * Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally inside `php1` container per `scripts/setup.sh`). * Backend static analysis: `composer analyse` (phpstan). * Backend rector dry-run: `composer rector:dry-run`. * Front-end unit: `npm run test:unit`. * Front-end e2e (smoke): `npm run test:e2e:smoke`. * Front-end e2e (PR slice): `npm run test:e2e:pr`. * Front-end lint: `npm run lint:strict`. * AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`. ### 5.3 CI checks to watch * `.github/workflows/tests.yml` (api) — PHP matrix (`unit`/`integration`/`api`/`legacy`) and Edge Agent job. * `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix. * `.github/workflows/code_quality.yml` — Qodana scan. --- ## 6. Roll-out plan 1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in `api` and from `pr-296` (current dev branch) in `pleno-vue`. 2. Backend change (3.1) + new helper + updated/new unit tests (4.1). 3. OpenAPI updates (3.2) in both repos. 4. Frontend attribute component (3.3) — add the `Set` index, keep the array `.some()` fallback for back-compat. 5. E2E mock update (3.4) + extended chip-stacking test (4.3). 6. Run the full verification suite (5.2) locally before pushing. 7. Open the PR; CI should turn green; Qodana should not flag the new memberships (they are deliberate additive fields). 8. After merge, monitor the period page in staging for payload size and chip rendering parity. --- ## 7. Risk assessment | Risk | Likelihood | Mitigation | |---|---|---| | Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. | | Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). | | OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. | | Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. | | Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. | --- ## 8. Files touched (summary) **Backend (`/workspace/api`):** * `services/nginx/app/routes/InvoicingPeriodRoute.php` — add `summarizePeriodCustomerMemberships`, populate non-active buckets. * `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php` — relax line 292, add membership / search / flag-tab tests. * `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax `InvoicingPeriodCustomer` requireds, union-typed `types` items. **Front-end (`/workspace/pleno-vue`):** * `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue` — `Set`-based membership index. * `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec. * `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend shape, extended chip-stacking assertions. * `openapi.yaml` — mirror backend schema edits.