# WP5 report — BOM components + operations grids get column reorder/resize/hide

**Status:** DONE
**Branch:** `hazemdev2` (FE repo `/home/moonui2/public_html/moon-erp`)
**Commit:** `a8c20cad5405d78b0aff8f8a694123ecb91b480a` — `feat(production): BOM component + operation grids get column reorder, resize and hide`
**Not pushed, not merged, not deployed to `/app`** (per brief — the orchestrator deploys).

## Files changed (3, all in one directory)

| File | Change |
|---|---|
| `src/app/features/production/boms/boms.component.ts` | +45 lines, purely additive: `DragDropModule` import, `DocConfigService` + `PermissionService` injects, two `TxColumnLayout` instances, `docConfig.load()` in `ngOnInit`. |
| `src/app/features/production/boms/boms.component.html` | Components-tab and operations-tab `<thead>`/`<tbody>` converted to the ordered-column loop; substitutes-expander `colspan` derived. |
| `src/app/features/production/boms/boms.component.scss` | +92 lines: drag/resize handle styles, `table-layout: fixed` on the two grids, control-fill for the ops grid. |

No registry change. No i18n change (see below). No backend change. Nothing outside `features/production/boms/`.

## What was built

Both line grids in the BOM dialog now use `src/app/shared/util/tx-column-layout.ts`, the same
mechanism `stock-receipts` / `stock-issues` use, instantiated twice:

```ts
readonly compLayout = new TxColumnLayout('production.bom',        docConfig, permissions, host, '.editable-table table.tx-grid',     {…});
readonly opsLayout  = new TxColumnLayout('production.bom_operation', docConfig, permissions, host, '.editable-table table.tx-ops-grid', {…});
```

- Header row is a `cdkDropList` (`orientation="horizontal"`); each data `<th>` is a `cdkDrag`
  with `[style.width]="layout.widthFor(col.key)"`, a `.tx-drag-handle`, a `.tx-resize-handle`
  bound to `layout.onResizeStart($event, col.key)`, and `[attr.data-col-key]`.
- Header **and** every body row loop the SAME `layout.orderedColumns()` signal, so header/cell
  alignment is correct by construction after any reorder or hide (AC 4).
- Structural columns render **outside** the loop: `<th class="col-num">#</th>` first and
  `<th class="col-act">` last, in both `<thead>` and `<tbody>`. The CDK index math therefore
  never sees them (AC 1).
- Handles only render when `layout.canReorder()` — the exact-match `permissions.can('core.settings.manage')`
  from WP4. A read-only user sees the persisted layout and no handles (AC 7).

### Header labels
Rendered as `{{ col.labelKey | translate }}`. The WP4 registry `labelKey`s are byte-for-byte the
same i18n keys the static headers used (`PRODUCTION.BOMS.PRODUCT`, `.QUANTITY`, `.UNIT`,
`.WASTE_PERCENT`, `.ISSUE_TYPE`, `.ISSUE_LEVEL`, `.OPERATION_SEQ`, `.COST_PER_UNIT`,
`.SUPPLY_SOURCE`, `.CUSTOMER_QTY_PER_UNIT`; `.CENTER`, `.OPERATION_NAME`, `.SETUP_TIME_MIN`,
`.RUN_TIME_MIN`, `.LABOR_COST_PER_HOUR`). Verified all 15 present in **both** `ar.json` and
`en.json`, plus `DOCS.PRODUCTION_BOM` / `DOCS.PRODUCTION_BOM_OPERATION`.
**No i18n key was added, and neither i18n file was touched at all** — no `git checkout`/`restore`/`stash`
was run on any file in this WP.

### Registry
The 10 + 5 keys in the brief matched the real row model exactly (`addComponent()` /
`addOperation()` FormGroups in `boms.component.ts`, and the rendered columns).
**No mismatch found; the registry was not edited.**

## Data semantics — verified untouched

The cells were moved, not rewritten. Concrete evidence:

1. **`formControlName` multiset is identical**, HEAD vs working tree, across the whole file:
   ```
   diff <(git show HEAD:…/boms.component.html | grep -oE 'formControlName="[a-z_]+"' | sort | uniq -c) \
        <(cat …/boms.component.html            | grep -oE 'formControlName="[a-z_]+"' | sort | uniq -c)
   → IDENTICAL
   ```
2. **Control-element multiset is identical** (`<p-select`, `<p-inputNumber`,
   `<app-product-search`, `<p-button`, `<input pInputText`, `<span class="muted">`) — same diff,
   also identical. So no cell gained, lost, or swapped a control.
3. **`supply_source`** — its `<td>` was moved verbatim into `@case ('supply_source')`. Still
   `[options]="supplySourceOptions"` with `optionValue="value"`, all four distinct options
   (`factory` / `own` / `customer` / `split`). No default, no fallback, no coercion added.
   `normaliseBomSupplySource()` remains the only writer of that control's initial value, in the
   TS, unchanged.
4. **`customer_qty_per_unit`** — still editable only under `@if (comp.get('supply_source')?.value === 'split')`,
   with the `<span class="muted">—</span>` otherwise. `onSave()`'s
   `supplySource === 'split' ? normalise(...) : 0` line is byte-identical (git diff of the .ts
   shows zero changed lines in `onSave`, `addComponent`, `duplicateComponent`, `addOperation`).
5. **`cost_per_unit`** — plain `p-inputNumber`, no new logic.

### Why the saved payload for an unchanged BOM is identical

`onSave()` never reads the DOM or the column order. It builds `data: CreateBom` from
`this.form.value`, `this.components.controls` and `this.operations.value`, then writes each line
as an **object literal with a fixed key order** (`product_id, quantity, unit_id,
waste_percentage, issue_type, issue_level, operation_seq, cost_per_unit, supply_source,
customer_qty_per_unit, sort_order, notes, substitutes`). Neither the FormArray row order nor the
FormGroup key order is derived from the template, and `git diff` on the `.ts` shows **no changed
line anywhere in the save path** (the only diff hunks are the imports block, the injects block,
the two `TxColumnLayout` field declarations, and the `docConfig.load()` line in `ngOnInit`).
`JSON.stringify` therefore emits the same bytes. (AC 6)

**Hidden-column safety, checked explicitly:** when a column is hidden its `<td>` leaves the DOM,
but the FormControl is created in `addComponent()` in TS and survives — Angular's
`FormControlName.ngOnDestroy` runs `_cleanUpControl`, which detaches the view callbacks and does
**not** reset the value. So hiding `supply_source` preserves each row's stored source; a blank
can never appear, and therefore can never be read as `'own'`. `product_id` and `quantity` are
`locked: true` and cannot be hidden at all.

### Substitutes expander (AC 5)
Unchanged markup — same `<tr class="sub-row">`, same nested `<table class="sub-table" formArrayName="substitutes">`,
same `addSubstitute`/`removeSubstitute`/`toggleSubstitutes` handlers. One binding changed:
`colspan="11"` → `[attr.colspan]="compLayout.orderedColumns().length + 1"` (visible data columns
+ the pinned actions column). At the default 10 visible columns this evaluates to 11 — the old
literal — so nothing moves until a column is actually hidden.
The nested table is deliberately excluded from every new SCSS rule: all of them are scoped with
**child** combinators off `table.tx-grid` / `table.tx-ops-grid` (`> thead > tr > th`,
`> tbody > tr.comp-row > td`), and `.sub-table` carries neither class, so it keeps its auto
layout, its own padding, and is untouched by the `overflow: hidden` used for the fixed columns.

## Resize inside the PrimeNG dialog (AC 2)

This grid lives in `<app-form-dialog>` (a `p-dialog`), the exact case WP4's fix exists for.
Confirmed by construction:
- `TxColumnLayout.onResizeStart` resolves the `<th>` with `(event.target as HTMLElement).closest('th')`,
  never `host.querySelector(tableSelector)`. Our `.tx-resize-handle` is always a direct child of
  its own `<th>`, so the lookup cannot return null however the dialog is portalled.
- The width has to be able to *drive* the column, so both grids got `table-layout: fixed`
  (with auto layout the px-sized inner controls win and the drag does nothing — the same trap
  the stock grids hit). `defaultWidths` supplies a concrete px per column so nothing collapses.
- RTL is handled inside `TxColumnLayout` (`isRtl` → `effectiveDelta = -deltaX`), unchanged.

`defaultWidths` for the components grid reproduce the old `.col-*` SCSS widths exactly
(`product_id 230 / quantity 95 / unit_id 120 / waste_percentage 95 / issue_type 130 /
issue_level 130 / operation_seq 72 / cost_per_unit 105 / supply_source 130 /
customer_qty_per_unit 105`), so an untouched grid renders pixel-identically to before.
Operations defaults: `200 / 180 / 120 / 120 / 140`.

## Verification

| Check | Result |
|---|---|
| `npx tsc --noEmit` | clean, no output |
| `npx ng build --base-href /app/` | **green**; only pre-existing warnings (CommonJS bailouts; SCSS budget warnings for `validation-worklist`, `create-visit`, `dept-worklist` — none in Production) |
| `formControlName` multiset HEAD vs HEAD~ | identical |
| control-element multiset | identical |
| i18n keys required | all 17 already present in `ar.json` + `en.json`; neither file touched |
| `chown moonui2:moonui2` | applied to all 3 edited files |
| Prior commits `c03c989d1`, `1574011b8` | intact, not reverted; WP5 sits on top of them |

## Concerns / notes for the orchestrator

1. **Runtime click-through not performed.** The build is green and the mechanism is a verbatim
   port of a shipped, working pattern, but AC 1/2/3/5 are behavioural and I did not deploy
   (per brief). They need one pass in the browser after the orchestrator deploys: reorder a
   components header, resize a column edge in RTL, hide `waste_percentage` from
   Settings → Documents → BOM, open a substitutes expander, save an untouched BOM.
2. **Operations grid width nuance (cosmetic).** The pre-existing rule
   `.editable-table table { min-width: 1080px }` still applies. The components grid's default
   widths sum to 1332px, so there its persisted px widths are absolute — the owner's screen
   behaves exactly right. The operations grid's defaults sum to ~880px, under the 1080 floor, so
   until the columns total more than 1080 the browser distributes the slack proportionally and a
   saved width is honoured *relatively* rather than to the pixel. Resizing still visibly works
   and the order/hide behaviour is exact. I left `min-width` alone rather than drop it, because
   removing it would leave dead whitespace to the right of the ops table in a wide dialog.
3. **The ops grid's inline control widths are now overridden to `width: 100% !important;
   min-width: 0 !important`** (scoped to `table.tx-ops-grid > tbody > tr > td`), mirroring what
   the reference grids do — otherwise a resized ops column would show a 100px input in a 300px
   cell. The `[style]` attributes in the markup were left verbatim; only CSS overrides them. This
   is a small, intentional visual change on the operations tab: its inputs now fill their column.
4. **No CHANGELOG entry added.** The FE repo has no `docs/moonstack/CHANGELOG.md` (it lives in the
   BE repo), and the two preceding commits on this branch (`c03c989d1`, `1574011b8`) likewise
   carry none. The release note for this capability belongs to whoever cuts the release; flagging
   it so it is not lost.
