# WP8 report — Routing operations, CoA parameters and the order-issue grid get column control

**Status:** DONE_WITH_CONCERNS (all acceptance criteria met by code path + build; nothing browser-verified — see concerns)
**Branch:** `hazemdev2` · **Commit:** `571188f1a8cd56c3c607073479ea26927c844c5d`
**Build:** `npx ng build --base-href /app/` → *Application bundle generation complete. [41.085 seconds]*, zero errors.
`npx tsc --noEmit` → exit 0, no output, no new errors.
**Not pushed, not merged, not deployed to `/app`.**

---

## ⛔ The Issue Materials dialog's editability is UNCHANGED — verified, not assumed

**I verified this and state it explicitly: nothing read-only became editable, no add-line or
remove-line control appeared, and NO unit picker was added.** Evidence, in order of strength:

1. **Control census over the whole `production-orders.component.html`, HEAD vs working tree —
   IDENTICAL.** Counting every `<p-inputNumber` / `<p-select` / `<p-datePicker` / `<input` /
   `<textarea` / `<p-checkbox` / `<app-product-search` / `<p-button` / `pInputText` occurrence in
   the entire 47 KB template:
   ```
   diff <(census HEAD.html) <(census work.html)  →  IDENTICAL
   ```
   Not one control was added anywhere in the file. A unit picker would have shown up here.

2. **Control + `[disabled]` census over the Issue-dialog REGION alone — IDENTICAL.** Region =
   from `<!-- Issue Materials Dialog -->` to the over-issue dialog comment (163 lines before,
   212 after — the growth is the `@for`/`@switch` scaffolding and the explanatory comments):
   ```
   8  [disabled]="saving()"      (8 before, 8 after)
   6  <p-button                  (6 before, 6 after)
   2  <p-inputNumber             (2 before, 2 after)
   4  <p-select                  (4 before, 4 after)
   0  formControlName / [(ngModel)] / <input / <textarea / <p-checkbox / <app-product-search
   ```
   Same multiset both sides. The 2 `p-inputNumber` are the issue-qty cell and the batch-allocation
   qty (inside the expander); the 4 `p-select` are the dialog-level warehouse, the per-line
   warehouse, the consignor and the batch picker (inside the expander). Exactly as before.

3. **The `unit_id` cell is still text.** `@case ('unit_id')` holds `<td>{{ line.unit_name || '—' }}</td>`
   verbatim — no control of any kind. (The only other `line.unit_name` hit in the file is prose
   inside my explanatory comment at line 610; real bindings 1 = 1.)

4. **The five read-only cells stay read-only** — `product_id` (two `<div>`s of text),
   `unit_id`, `planned_quantity`, `consumed_quantity`, `remaining` (all plain interpolation).
   The three already-editable cells (`quantity`, `warehouse_id`, `owner_partner_id`) keep their
   exact existing bindings, including `lineShowsConsignor(line)` still gating the consignor picker.

5. **No line add/remove.** There is no add-line or remove-line control in this dialog, before or
   after. `issueLines` is populated only by `buildIssueLines(res.data.materials)` from the server,
   and that method is byte-identical (see § Payload equivalence). I did not convert this grid to
   `TransactionLineItemsComponent` precisely because that component *is* an entry grid: it owns
   line add/remove and makes every registered cell editable — the exact behaviour the brief forbids.

I did not need any cell to become editable to make column control work, so I had no reason to
invoke the "stop and report" clause.

### `owner_partner_id = 0` sentinel — what a blank actually emits, checked in the PrimeNG source

Not assumed. `node_modules/primeng/fesm2022/primeng-select.mjs:1610`:

```js
clear(event) {
    this.updateModel(null, event);
    ...
}
```

The consignor `p-select` carries `[showClear]="true"` and `optionValue="id"`, so clearing emits
**`null`**, which `setLineConsignor(i, null)` writes as `owner_partner_id: null`, which
`submitIssueMaterials()` then **skips** (`if (line.owner_partner_id != null)`). A blank can never
emit `0`. The only route to `0` would be an option whose `id === 0`; the options are
`customers()` = real partners. Nothing in this WP touches any of that — the cell moved, it was
not rewritten.

**Hiding the consignor column is also safe, and is in fact stricter:** with the column hidden the
picker never renders, so `setLineConsignor` can never fire, so the row keeps the `null` that
`buildIssueLines` seeded. Same for `warehouse_id` — hidden ⇒ stays `null` ⇒ the line inherits the
dialog-level `issueWarehouseId` (a header control above the table, not part of the grid, so it is
never hideable). That degrades exactly to the pre-ISS-2026-9166 behaviour, not to a wrong one.

---

## Mechanism

`TxColumnLayout` (`src/app/shared/util/tx-column-layout.ts`) over each grid's existing
hand-written cells — the WP5/WP6/WP7 approach, instantiated once per grid. I did **not** modify
`TransactionLineItemsComponent`, `transaction-line.types.ts` or `TxColumnLayout`; none of the
three appears in `git diff --numstat`.

| Screen | docKey | Row shape | Reference followed |
|---|---|---|---|
| `features/production/routing/` operations | `production.routing` | `signal<OperationRow[]>` + `[(ngModel)]` | WP7 (`20684e86b`) — already a `<table>`, ngModel rows |
| `features/production/coa/` parameters | `production.coa` | `FormArray` + `formControlName` | WP5 (`a8c20cad5`) — FormArray rows |
| `features/production/orders/` Issue Materials dialog | `production.order_issue` | `signal<IssueLineRow[]>` + `[ngModel]`/`(ngModelChange)` | WP6 (`433347abe`) / WP7 — ngModel signal rows |

All three were **already real `<table>` elements**, so — like WP7 and unlike WP6 — no markup-model
change was needed: only the header/body loops, `table-layout: fixed`, and the handle styles.

## Files changed (10)

| File | +/− | What |
|---|---|---|
| `src/app/features/production/routing/routing.component.ts` | +74 / −1 | `ElementRef` + `DragDropModule` imports; inject `DocConfigService`/`PermissionService`/`ElementRef`; `docKey`; `TxColumnLayout`; `requiredLineCols`/`centredLineCols` helpers; `docConfig.load()` in `ngOnInit` |
| `src/app/features/production/routing/routing.component.html` | +83 / −37 | ops `<thead>`/`<tbody>` → ordered-column loop; the 12 cells moved verbatim into `@switch`; actions `<td>` pinned outside |
| `src/app/features/production/routing/routing.component.scss` | +74 / −2 | `table-layout: fixed`, `position: relative`/`overflow: hidden` on cells, `.ops-act` pinned width, drag/resize handle styles, `min-width: 0` on the inline controls |
| `src/app/features/production/coa/coa.component.ts` | +46 / −1 | same injects + `docKey` + `TxColumnLayout` + `docConfig.load()` |
| `src/app/features/production/coa/coa.component.html` | +98 / −56 | param `<thead>`/`<tbody>` → ordered-column loop; 6 cells verbatim into `@switch`; delete `<td>` pinned; `.param-table-scroll` wrapper; empty-row colspan derived |
| `src/app/features/production/coa/coa.component.scss` | +86 / −0 | `table-layout: fixed`, scroll wrapper, `.param-act` pinned width, handle styles, `::ng-deep` control fill |
| `src/app/features/production/orders/production-orders.component.ts` | +59 / −1 | same injects + `issueDocKey` + `issueLayout` + `docConfig.load()` |
| `src/app/features/production/orders/production-orders.component.html` | +101 / −52 | issue `<thead>`/`<tbody>` → ordered-column loop; 8 cells verbatim into `@switch`; `.issue-lines-scroll` wrapper; expander colspan derived |
| `src/app/features/production/orders/production-orders.component.scss` | +100 / −0 | all scoped to the **new** `.issue-lines-table` class, never to the shared `.detail-table` |
| `src/app/core/services/doc-config.service.ts` | +14 / −1 | **comment only** — recorded that WP8 wired all three and why each stays bespoke. **No field key, label, lock flag or behaviour changed** (see the full diff quoted in § Registry). |

All paths under `/home/moonui2/public_html/moon-erp/`.

### Structural columns render outside the ordered loop (AC 3)

- **routing** — trailing delete `<th class="ops-act">` / `<td class="ctr ops-act">`, pinned last.
  There is no row-`#` column: `operation_no` **is** a registered data column (WP4's display-only key).
- **CoA** — trailing delete `<th class="param-act">` / `<td class="param-act">`, pinned last.
- **order-issue** — this grid has **no** structural column at all (8 data `<th>`, 8 data `<td>`,
  no row-# and no actions), so the whole `<tr>` is the drop list and the CDK index math maps 1:1
  onto the visible data columns.

Derived colspans, so nothing breaks when a column is hidden:
- CoA empty row: `colspan="7"` → `[attr.colspan]="layout.orderedColumns().length + 1"` (6 visible + pinned delete = 7 by default — the old literal).
- Issue batch expander: `colspan="8"` → `[attr.colspan]="issueLayout.orderedColumns().length"` (8 by default — the old literal; **no** `+1`, because this grid has no pinned column).

### `defaultWidths`

Under `table-layout: fixed` an unsized column collapses, so every registered key has one. Values
are ≥ the old static `<th>` width / the cell's inline control width, so an untouched grid renders
as before.

- **routing** (12 cols): `operation_no 70 · name 180 · work_center_id 180 · tool_id 170 ·
  setup_time_minutes 110 · run_time_per_unit 110 · cavity_count 90 · cycle_time_seconds 110 ·
  queue_time_minutes 100 · move_time_minutes 100 · inspection_required 84 · critical_operation 84`
  (sum 1388 + 44 actions). The brief warned about this grid: the old header widths (54/160/160/150/
  90/90/70/90/80/80/54/54) were AUTO-layout *hints* that the browser grew; under fixed layout they
  are absolute, so a literal copy would have produced an unusable row. The pre-existing
  `.ops-table-wrap { overflow-x: auto }` carries the overflow.
- **CoA** (6 cols): `name 200 · unit 100 · spec_min 115 · spec_max 115 · result 115 ·
  result_status 105` (sum 750 + 44 delete). The `name` column was previously unsized (it absorbed
  the slack), so it needed an explicit value.
- **order-issue** (8 cols): `product_id 200 · unit_id 90 · planned_quantity 110 ·
  consumed_quantity 110 · remaining 100 · quantity 175 · warehouse_id 190 · owner_partner_id 210`
  (sum 1185; the dialog is 760 px, hence `.issue-lines-scroll`).

### Header labels and the required `*`

Labels now render as `{{ col.labelKey | translate }}`. **Every key the registry supplies is the
byte-identical string the static header used** — verified key-by-key against both i18n files:
`PRODUCTION.ROUTING.OP_NO|OP_NAME|WORK_CENTER|TOOL|SETUP|RUN|CAVITY|CYCLE|QUEUE|INSP|CRIT|MOVE`,
`PRODUCTION.COA.PARAM_NAME|PARAM_UNIT|SPEC_MIN|SPEC_MAX|RESULT|RESULT_STATUS`,
`PRODUCTION.ORDERS.MATERIAL|UNIT|PLANNED_QTY|CONSUMED_QTY|REMAINING|ISSUE_QTY|LINE_WAREHOUSE|LINE_CONSIGNOR`,
plus `DOCS.PRODUCTION_ROUTING|PRODUCTION_COA|PRODUCTION_ORDER_ISSUE`. All 27 present in **both**
`ar.json` and `en.json`.

**No i18n key was needed and neither i18n file was touched** — not written, not opened for
writing, and no `git checkout` / `restore` / `stash` was run on any file in this WP.

Routing's static header hard-coded `OP_NAME *` and `WORK_CENTER *`. Registry `labelKey`s carry no
asterisk, so the marker is now keyed off the column key via `isRequiredLineCol()`, whose set
(`name`, `work_center_id`) mirrors `onSave()`'s `invalidOp` guard exactly — the only two fields it
requires. Routing's two `.ctr` header classes likewise became `isCentredLineCol()`
(`inspection_required`, `critical_operation`). CoA and order-issue had no asterisks and no header
alignment classes, so neither needed a helper.

---

## Registry: no correction needed on any of the three

Re-verified every key against the real row model, as the brief asked:

- **`production.routing`** → `interface OperationRow` (`routing.component.ts:35`). All 12 keys
  match property names exactly. `name_ar` is on the row but has no column and is correctly not
  registered. ✅
- **`production.coa`** → `newParameterGroup()` (`coa.component.ts`). The 5 control names match;
  `result_status` is the display-only pass/fail chip whose registry key **is** its column
  identity — used verbatim as the `@case` label, exactly as WP4 instructed. `sort_order` is
  stamped from the row index on save and correctly not registered. ✅
- **`production.order_issue`** → `interface IssueLineRow` (`production-orders.component.ts:77`).
  All 8 keys match property names. The `product_id` column *renders* `product_name`/`product_code`
  and the `unit_id` column *renders* `unit_name` — WP4's comment already documents that row
  identity is the `_id`, so no change. `product_variant_id`, `batchExpanded`, `batchAllocations`
  correctly not registered. ✅

`operation_no`, `result_status` (and, from WP7, `line_value`) were used verbatim as the brief
required — none of them became unhideable.

The only edit to `doc-config.service.ts` is comment text. Full diff shape:
`+14 / −1`, and the one "deleted" line is the CoA sentence *"…the wiring WP must use it verbatim"*
rewritten to *"…WP8 wired it using this key verbatim"*. No `LINE(...)` call was touched — the
field lists are byte-identical.

---

## Payload equivalence — how I verified it (AC 7)

**Evidence A — every save path is byte-identical, by sha256.** Script:
`scratchpad/wp8/extract8.py`. For each screen it pulls the union of the line-state declarations
and the full payload builder out of the file, brace-balanced, in a fixed order, from the `HEAD`
copy and from the working tree, then hashes.

| Screen | Blocks extracted | Lines | sha256 (HEAD **and** working tree) | diff |
|---|---|---|---|---|
| routing | `interface OperationRow`, `operations` signal decl, `addOperation`, `removeOperation`, `resequence`, **`onSave()` in full** (validity guard, `invalidOp` guard, the `CreateRoutingOperation[]` map and the `CreateRouting` builder) | 120 | `95cca2b964dda66ee57549f836f63deb525d1d6ee4209461d8d8ce6ce896e0c1` | **IDENTICAL** |
| coa | `formAllPassed`, `get parameters`, `parameterRows`, `newParameterGroup`, `addParameter`, `removeParameter`, `evalPass`, `rowPass`, **`onSave()` in full** (the `CoaParameterInput[]` map and the `CreateCoa` builder), `formatDate` | 104 | `36bd7e57039daa44ca5d4e9adb8fe8220fc765249db3a83f78df4add20e8555b` | **IDENTICAL** |
| orders | `interface IssueLineRow`, `issueLines`/`issueWarehouseId` decls, `buildIssueLines`, `batchOptions`, `setIssueQty`, `lineEffectiveWarehouseId`, `isConsignmentWarehouse`, `lineShowsConsignor`, `setLineWarehouse`, `setLineConsignor`, `toggleBatchExpander`, `addBatchAllocation`, `removeBatchAllocation`, `setBatchAllocId`, `setBatchAllocQty`, `updateAllocation`, `fefoFillLine`, `hasIssuableLines`, **`submitIssueMaterials()` in full** (the `IssueMaterialLine[]` map, the batch-ids filter, the `warehouse_id`/`owner_partner_id != null` guards and the `IssueMaterialsRequest` builder) | 269 | `32f1d63d4e22c742041e2548031e850ec8f41e6fb73bf00013516505a12f7e10` | **IDENTICAL** |

`git diff --numstat` corroborates the shape: each `.ts` is `+N / −1`, and in all three the single
deleted line is the `import { Component, … } from '@angular/core'` line, replaced by the same
import plus `ElementRef`. **Not one line of any save path was deleted or rewritten.**

**Evidence B — control-name comparison (the WP5/6/7 bar).** Model-binding surface extracted from
each template before and after and diffed:

```
routing  [(ngModel)]="op.<field>" ×12 · <p-select 2 · <p-inputNumber 6 · <p-checkbox 2 ·
         <input pInputText 2 · <p-button 4 · removeOperation($index) 1 · addOperation() 1 ·
         {{ op.operation_no }} 1 · standalone: true 12          → IDENTICAL
coa      formControlName="…" ×9 (name/unit/spec_min/spec_max/result + the 4 header controls) ·
         formArrayName="parameters" 1 · [formGroupName]="$index" 1 · <p-inputNumber 3 ·
         <input pInputText 5 · <p-button 5 · rowPass($index) 2 · removeParameter($index) 1 ·
         addParameter() 1 · pf-chip pf-pass/fail/pending 3 · parameters.controls 1 ·
         parameters.length 2                                     → IDENTICAL
orders   [ngModel]="line.<field>" ×2 · [ngModel]="lineEffectiveWarehouseId(line)" 1 ·
         setIssueQty/setLineWarehouse/setLineConsignor/toggleBatchExpander 1 each ·
         lineShowsConsignor 1 · line.planned_quantity/consumed_quantity/remaining/
         product_name/product_code 1 each · line.batchExpanded 2 · line.batchAllocations 2 ·
         addBatchAllocation/removeBatchAllocation/fefoFillLine/setBatchAllocId/
         setBatchAllocQty 1 each · hasIssuableLines/submitIssueMaterials/issueLines/
         issueWarehouseId unchanged                              → IDENTICAL except line.unit_name 1→2
```

The single counted difference (`line.unit_name` 1 → 2) is my explanatory comment at line 610
mentioning the field by name; the real binding is 1 = 1 (line 655). Verified with `grep -n`.

**Evidence C — no save path reads the DOM or the column order.**
- `RoutingComponent.onSave()` builds `CreateRoutingOperation` from `this.operations()` (the signal
  array) as an **object literal with a fixed key order**, plus `this.form.value` for the header.
- `CoaComponent.onSave()` builds `CoaParameterInput` from `raw.parameters` (i.e. `this.form.value`)
  as a fixed-key-order literal, with `sort_order: idx` from the **row** index — never the column index.
- `submitIssueMaterials()` builds `IssueMaterialLine` from `this.issueLines()`.

None of the three consults `orderedColumns()`, `widthFor()` or any element. Reordering or hiding a
column therefore cannot reach the payload at all. `JSON.stringify` emits the same bytes.

**Hidden-column safety, checked per screen — a hidden column never blanks a value:**
- *routing / order-issue* (plain objects in a signal array): the row object keeps every field
  regardless of the DOM. `onSave()` / `submitIssueMaterials()` map all fields unconditionally.
  Hiding e.g. `cavity_count` preserves the `1` that `addOperation()` seeded.
- *CoA* (FormArray): removing a `<td>` destroys the `FormControlName` **directive**, whose
  `ngOnDestroy` runs `_cleanUpControl` — that detaches the view callbacks and does **not** reset
  the value. The `FormControl` itself lives in the FormArray built by `newParameterGroup()` and
  survives; `this.form.value` still carries it (only *disabled* controls are omitted from
  `.value`, and hiding does not disable). Locked `name` and `result` cannot be hidden at all.

Conclusion: an unchanged routing, an unchanged COA and an unchanged issue submit identical JSON.

---

## Acceptance criteria

| # | Criterion | Status |
|---|---|---|
| 1 | All three grids reorder and resize, RTL-correct, persisted company-wide, surviving reload | ✅ code path — `TxColumnLayout` + `docConfig.load()` in each `ngOnInit`. RTL is inside `TxColumnLayout` (`isRtl` → `effectiveDelta = -deltaX`), untouched. All three grids live in a dialog, so resize relies on `closest('th')` — the dialog-safe resolution from `1574011b8`; our `.tx-resize-handle` is always a descendant of its own `<th>`. `table-layout: fixed` on all three so the width can actually drive the column. **Not browser-verified — see concerns.** |
| 2 | Non-locked columns hide from Settings → Documents; locked cannot | ✅ `orderedColumns()` filters on `docConfig.fieldVisible()`, which short-circuits to `true` for `locked` fields. Locked: routing `name`+`work_center_id`; CoA `name`+`result`; order-issue `product_id`+`quantity` (so the issue-qty cell — and the batch-expander toggle that lives beside it — can never disappear). |
| 3 | Header and body cells stay aligned after any reorder + any hide | ✅ by construction — both loops consume the same `layout.orderedColumns()` computed signal; structural columns render outside it. |
| 4 | **The order material grid is still entirely read-only** — no cell became editable, no line can be added or removed | ✅ **verified and stated explicitly** — see the section at the top of this report (whole-file control census identical; region control + `[disabled]` census identical; `unit_id` still text; no add/remove control exists). |
| 5 | Routing's two checkbox columns still toggle and save correctly after reordering | ✅ both `<td class="ctr"><p-checkbox [(ngModel)]="op.inspection_required\|critical_operation" [ngModelOptions]="{standalone:true}" [binary]="true" /></td>` moved verbatim into their `@case`; the bindings are on the row object, not the DOM position, so reordering is irrelevant to them. `onSave()` maps both unconditionally (they are the only two fields written without a `!= null` guard). |
| 6 | CoA's derived pass/fail cell still derives correctly after reordering | ✅ `rowPass($index)` is a template method call re-evaluated every CD cycle, and `$index` is the **row** index from `@for (row of parameters.controls; track $index)` — the outer loop, unaffected by the inner column loop. The three-branch `@if/@else if/@else` chip markup is verbatim. |
| 7 | Saving each screen produces a payload identical to before | ✅ evidence A/B/C above — three sha256 matches plus three control-surface diffs. |
| 8 | `ng build` green; `tsc --noEmit` no new errors | ✅ both. Only pre-existing warnings: CommonJS bailouts, and the same three SCSS budget warnings (`dept-worklist`, `validation-worklist`, `create-visit`) WP5–WP7 reported — **none in Production**. |

---

## Concerns

1. **Not browser-verified.** I was told not to deploy to `/app`, so AC 1–6 are verified by code
   path and by build, not by clicking. Worth one manual pass after the orchestrator deploys:
   - **routing** — 12 columns is the widest fixed-layout grid this mechanism has driven; check the
     row is usable at the default widths inside the 1100 px dialog and that `.ops-table-wrap`
     scrolls. Toggle both checkboxes after a reorder and save.
   - **CoA** — reorder, then confirm the PASS/FAIL chip still tracks the row it belongs to.
   - **order-issue** — the batch expander: its `<td>` colspan is now derived, and the expander row
     sits inside a `table-layout: fixed` table. The nested `.batch-alloc-table` is deliberately
     excluded from every new rule (all of them use **child** combinators off `.issue-lines-table`,
     and the sub-table is a grandchild), so it keeps its own auto layout — but that is the one
     visual interaction this change could plausibly disturb, so open one expander.

2. **Narrow-screen behaviour changed on CoA and order-issue.** Both grids now scroll horizontally
   inside a new wrapper (`.param-table-scroll`, `.issue-lines-scroll`) rather than letting the
   table squash to the dialog width. Neither had a `@media` reflow rule before, so this is the same
   (small) trade WP6/WP7 made: on a phone the columns keep their px widths and the user scrolls.
   Routing already had `.ops-table-wrap { overflow-x: auto }`, so it is unchanged there.

3. **The order-issue inline pickers now fill their column** (`::ng-deep` rule scoped to
   `.issue-lines-table > tbody > tr > td`, overriding the `[style]="{width:'170px'}"` /
   `'190px'` on the two `p-select`s; the quantity `p-inputNumber` flexes beside its expander button
   instead of stretching). The `[style]` attributes in the markup were left verbatim — only CSS
   overrides them, and only inside this one table. Without it a resized column would show a 170 px
   picker in a 300 px cell. This is a small, intentional visual change; it does **not** change what
   is editable. The same override pattern was applied to routing (`min-width: 0` added to the
   existing `.cell-num`/`.cell-select` rules) and CoA (`.p-inputnumber`).

4. **`.issue-lines-table` is a NEW class, added alongside the existing `.detail-table`.** Several
   other tables in `production-orders.component.html` also carry `.detail-table` (the order-detail
   Materials tab, operations, costing…), so every WP8 rule is scoped to the new class and the
   shared `.detail-table` block is untouched. **The read-only Materials tab is not touched by this
   WP at all**, as the brief requires.

5. **Global `.cdk-drag-preview` styles.** The drag-ghost rule must be `::ng-deep` without `:host`
   (the preview is portalled to `<body>`), so it leaks globally — as WP5/WP7's already do. To stop
   the four Production screens fighting over the ghost's appearance I normalised all three of mine
   to be **byte-identical to the rule WP7 shipped in `consignment.component.scss`** (verified by
   diff). Whichever component's stylesheet loads last, the result is the same.

6. **No CHANGELOG entry written.** This is user-facing, but none of the five sibling commits on
   this plan (`c03c989d1`, `1574011b8`, `a8c20cad5`, `433347abe`, `20684e86b`) wrote one — the
   convention here is one bilingual entry per finished capability, written by the orchestrator.
   Flagging rather than breaking the pattern. **This is the last FE package of the plan, so the
   entry is now due.** I committed with `SKIP_CHANGELOG=1` per the internal-commit escape hatch;
   the pre-push hook will demand it before this branch is pushed.

7. **The strategic finding from WP6/WP7 stands, and WP8 widens it.** Three more grids that could
   not use `TransactionLineItemsComponent`, for three *different* reasons: routing needs a
   **boolean** cell (and has no product column at all), CoA needs a **state** cell rather than a
   numeric `computed`, and order-issue needs the shared component to *not* be an entry grid
   (no line add/remove, per-cell read-only). Only the first two are `TxCellType` gaps that a
   shared-layer decision could close; the third is architectural — a read-only/execution mode for
   `TransactionLineItemsComponent` — and is the more valuable of the two if anyone revisits this.

## Untouched

`TransactionLineItemsComponent`, `transaction-line.types.ts` and `TxColumnLayout` — **not
modified** (absent from `git diff --numstat`). `src/assets/i18n/ar.json` and `en.json` — **not
edited, never `checkout`/`restore`/`stash`ed**; only *read* to confirm all 27 keys the three grids
render already exist in both files. They do — no new key was needed. No backend change. No other
Production screen. The read-only order-detail Materials tab untouched. Not deployed to `/app`.
