From d6441bef06d3835f86b0cc1b4585af5dc9bad551 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 3 Apr 2026 09:21:25 +0200 Subject: [PATCH 01/24] feat(ui) : highlight empty slots with category name in red - Empty component slots (pieces, products, subcomponents) now display the category/type name with red styling instead of generic labels - Machine view: empty structure pieces show type name + "manquant" in red - Backend: include typePiece in structure slot data for name resolution Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/app/components/PieceItem.vue | 5 ++-- frontend/app/composables/useComponentEdit.ts | 12 ++++++--- .../app/composables/useMachineHierarchy.ts | 5 +++- frontend/app/pages/component/[id]/edit.vue | 6 ++--- frontend/app/pages/component/[id]/index.vue | 25 +++++++++++-------- frontend/app/pages/piece/[id].vue | 9 ++++--- src/Controller/MachineStructureController.php | 1 + 7 files changed, 40 insertions(+), 23 deletions(-) diff --git a/frontend/app/components/PieceItem.vue b/frontend/app/components/PieceItem.vue index a081d6a..6007b39 100644 --- a/frontend/app/components/PieceItem.vue +++ b/frontend/app/components/PieceItem.vue @@ -14,7 +14,7 @@ /> -
+
-

+

{{ pieceData.name }} + — manquant { const edits = slotEdits.pieces[slot.slotId] + const selectedPieceId = edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null) return { slotId: slot.slotId, typePieceId: slot.typePieceId, - selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null), + selectedPieceId, selectedPieceName: slot.selectedPieceName ?? null, quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1), position: slot.position ?? i, label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`, + isEmpty: !selectedPieceId, } }) }) @@ -305,14 +307,16 @@ export function useComponentEdit(componentId: string) { if (!structure?.products) return [] return (structure.products as any[]).map((slot: any, i: number) => { const edits = slotEdits.products[slot.slotId] + const selectedProductId = edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null) return { slotId: slot.slotId, typeProductId: slot.typeProductId, - selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null), + selectedProductId, selectedProductName: slot.selectedProductName ?? null, familyCode: slot.familyCode, position: slot.position ?? i, label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`, + isEmpty: !selectedProductId, } }) }) @@ -322,15 +326,17 @@ export function useComponentEdit(componentId: string) { if (!structure?.subcomponents) return [] return (structure.subcomponents as any[]).map((slot: any, i: number) => { const edits = slotEdits.subcomponents[slot.slotId] + const selectedComponentId = edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null) return { slotId: slot.slotId, typeComposantId: slot.typeComposantId, - selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null), + selectedComponentId, selectedComponentName: slot.selectedComponentName ?? null, alias: slot.alias, familyCode: slot.familyCode, position: slot.position ?? i, label: slot.alias || `Sous-composant #${i + 1}`, + isEmpty: !selectedComponentId, } }) }) diff --git a/frontend/app/composables/useMachineHierarchy.ts b/frontend/app/composables/useMachineHierarchy.ts index b46c6cb..441d1be 100644 --- a/frontend/app/composables/useMachineHierarchy.ts +++ b/frontend/app/composables/useMachineHierarchy.ts @@ -227,11 +227,13 @@ export const buildMachineHierarchyFromLinks = ( const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1) + const isEmpty = !resolved + const typePieceName = (resolved?.typePiece as AnyRecord)?.name || (definition.typePiece as AnyRecord)?.name || (def.typePiece as AnyRecord)?.name || null return { ...(resolved || {}), id: resolved?.id || `structure-piece-${composantId}-${index}`, pieceId: resolved?.id || null, - name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`, + name: resolved?.name || definition.role || definition.name || def.role || def.name || (typePieceName ? `${typePieceName}` : `Pièce ${index + 1}`), reference: resolved?.reference || definition.reference || def.reference || null, prix: resolved?.prix ?? null, constructeurs: resolved?.constructeurs || [], @@ -243,6 +245,7 @@ export const buildMachineHierarchyFromLinks = ( parentComponentLinkId: machineComponentLinkId, parentComponentName: componentName, _structurePiece: true, + _emptySlot: isEmpty, } }) as AnyRecord[] diff --git a/frontend/app/pages/component/[id]/edit.vue b/frontend/app/pages/component/[id]/edit.vue index 8e2d591..8cb830c 100644 --- a/frontend/app/pages/component/[id]/edit.vue +++ b/frontend/app/pages/component/[id]/edit.vue @@ -195,7 +195,7 @@ class="form-control" >
@@ -231,7 +231,7 @@ class="form-control" > -
- {{ slot.selectedPieceName || '— Non sélectionné' }} - x{{ slot.quantity }} +
+ +
@@ -272,7 +275,7 @@ class="form-control" > -
- {{ slot.selectedProductName || '— Non sélectionné' }} +
+ +
@@ -298,7 +302,7 @@ class="form-control" > -
- {{ slot.selectedComponentName || '— Non sélectionné' }} +
+ +

diff --git a/frontend/app/pages/piece/[id].vue b/frontend/app/pages/piece/[id].vue index d1851ff..46a7da9 100644 --- a/frontend/app/pages/piece/[id].vue +++ b/frontend/app/pages/piece/[id].vue @@ -224,7 +224,7 @@ class="form-control" > @@ -244,10 +244,11 @@ class="form-control" > -
- {{ productSelectionLabels[index] || '— Non sélectionné' }} +
+ +
diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index 30e4b9e..edfdd9e 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -728,6 +728,7 @@ class MachineStructureController extends AbstractController $pieceData = [ 'slotId' => $slot->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(), + 'typePiece' => $this->normalizeModelType($slot->getTypePiece()), 'quantity' => $slot->getQuantity(), 'selectedPieceId' => $slot->getSelectedPiece()?->getId(), ]; From 1529d21f124b57330bb9aac6db171def6080ffbe Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 3 Apr 2026 09:25:07 +0200 Subject: [PATCH 02/24] docs(custom-fields) : add spec and implementation plans for machine context custom fields Co-Authored-By: Claude Opus 4.6 (1M context) --- ...026-04-02-machine-context-custom-fields.md | 1182 +++++++++++++++++ ...26-04-02-machine-context-fields-backend.md | 841 ++++++++++++ ...6-04-02-machine-context-fields-frontend.md | 404 ++++++ ...02-machine-context-custom-fields-design.md | 154 +++ 4 files changed, 2581 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md create mode 100644 docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md create mode 100644 docs/superpowers/plans/2026-04-02-machine-context-fields-frontend.md create mode 100644 docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md diff --git a/docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md b/docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md new file mode 100644 index 0000000..2b03cdf --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md @@ -0,0 +1,1182 @@ +# Machine Context Custom Fields — Implementation Plan (SUPERSEDED) + +> **This plan has been split into two parallel plans:** +> - **Backend:** `2026-04-02-machine-context-fields-backend.md` +> - **Frontend:** `2026-04-02-machine-context-fields-frontend.md` +> +> Use those plans instead. This file is kept for reference only. + +**Goal:** Allow defining custom fields on ModelTypes that only appear and store values per machine-link (not globally on the piece/composant). + +**Architecture:** Add `machineContextOnly` boolean on `CustomField`. Add nullable FKs on `CustomFieldValue` pointing to `MachineComponentLink` / `MachinePieceLink`. The `MachineStructureController` exposes `contextCustomFields` and `contextCustomFieldValues` on each link in the response. Frontend structure editors get a toggle, machine detail components get a new "Champs contextuels" section, and standalone pages filter these fields out. + +**Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5 + +**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md` + +--- + +## File Map + +### Backend — Create +- `migrations/VersionXXXX_MachineContextCustomFields.php` — migration +- `tests/Api/Entity/MachineContextCustomFieldTest.php` — dedicated test class + +### Backend — Modify +- `src/Entity/CustomField.php` — add `machineContextOnly` property +- `src/Entity/CustomFieldValue.php` — add `machineComponentLink` and `machinePieceLink` FKs +- `src/Entity/MachineComponentLink.php` — add `contextFieldValues` collection +- `src/Entity/MachinePieceLink.php` — add `contextFieldValues` collection +- `src/Controller/MachineStructureController.php` — normalize context fields in structure response + clone context values +- `src/Controller/CustomFieldValueController.php` — support link-based upsert/lookup +- `tests/AbstractApiTestCase.php` — add `machineContextOnly` param to `createCustomField()`, add link params to `createCustomFieldValue()` + +### Frontend — Modify +- `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types +- `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle per field +- `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle per field +- `frontend/app/composables/usePieceStructureEditorLogic.ts` — add `machineContextOnly: false` in `createEmptyField()` +- `frontend/app/composables/useStructureNodeCrud.ts` — add `machineContextOnly: false` in `addCustomField()` +- `frontend/app/composables/useEntityCustomFields.ts` — filter out `machineContextOnly` fields +- `frontend/app/composables/useMachineDetailCustomFields.ts` — propagate context fields + filter from normal merge +- `frontend/app/components/ComponentItem.vue` — display context custom fields section +- `frontend/app/components/PieceItem.vue` — display context custom fields section + +--- + +## Task 1: Migration + Entity `CustomField` — `machineContextOnly` + +**Files:** +- Modify: `src/Entity/CustomField.php` (add property after line 56) + +- [ ] **Step 1: Add `machineContextOnly` property to `CustomField` entity** + +In `src/Entity/CustomField.php`, add after the `$required` property (line 56): + +```php +#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')] +#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])] +private bool $machineContextOnly = false; +``` + +Add getter/setter before the closing `}`: + +```php +public function isMachineContextOnly(): bool +{ + return $this->machineContextOnly; +} + +public function setMachineContextOnly(bool $machineContextOnly): static +{ + $this->machineContextOnly = $machineContextOnly; + + return $this; +} +``` + +- [ ] **Step 2: Generate and adjust migration** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff +``` + +Edit the generated migration to use idempotent SQL: + +```sql +ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL; +``` + +- [ ] **Step 3: Run migration** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 4: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/CustomField.php migrations/ +git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity" +``` + +--- + +## Task 2: Entity `CustomFieldValue` — link FKs + inverse collections + +**Files:** +- Modify: `src/Entity/CustomFieldValue.php` (add after line 67 — `$product` property) +- Modify: `src/Entity/MachineComponentLink.php` (add after line 72 — `$productLinks`) +- Modify: `src/Entity/MachinePieceLink.php` (add after line 61 — `$productLinks`) + +- [ ] **Step 1: Add FKs to `CustomFieldValue`** + +In `src/Entity/CustomFieldValue.php`, add after the `$product` property (line 67): + +```php +#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')] +#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] +private ?MachineComponentLink $machineComponentLink = null; + +#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')] +#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] +private ?MachinePieceLink $machinePieceLink = null; +``` + +Add getters/setters before the closing `}`: + +```php +public function getMachineComponentLink(): ?MachineComponentLink +{ + return $this->machineComponentLink; +} + +public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static +{ + $this->machineComponentLink = $machineComponentLink; + + return $this; +} + +public function getMachinePieceLink(): ?MachinePieceLink +{ + return $this->machinePieceLink; +} + +public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static +{ + $this->machinePieceLink = $machinePieceLink; + + return $this; +} +``` + +- [ ] **Step 2: Add `contextFieldValues` collection to `MachineComponentLink`** + +In `src/Entity/MachineComponentLink.php`, add after the `$productLinks` collection (line 72): + +```php +/** + * @var Collection + */ +#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)] +private Collection $contextFieldValues; +``` + +In the constructor (line 95), add: +```php +$this->contextFieldValues = new ArrayCollection(); +``` + +Add getter before the closing `}`: +```php +/** + * @return Collection + */ +public function getContextFieldValues(): Collection +{ + return $this->contextFieldValues; +} +``` + +- [ ] **Step 3: Add `contextFieldValues` collection to `MachinePieceLink`** + +In `src/Entity/MachinePieceLink.php`, add after the `$productLinks` collection (line 61): + +```php +/** + * @var Collection + */ +#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)] +private Collection $contextFieldValues; +``` + +In the constructor (line 86), add: +```php +$this->contextFieldValues = new ArrayCollection(); +``` + +Add getter: +```php +/** + * @return Collection + */ +public function getContextFieldValues(): Collection +{ + return $this->contextFieldValues; +} +``` + +- [ ] **Step 4: Generate and adjust migration** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff +``` + +Edit migration to use idempotent SQL: +```sql +ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL; +ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN + ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link + FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN + ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link + FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid); +CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid); +``` + +- [ ] **Step 5: Run migration + linter** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/ +git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context" +``` + +--- + +## Task 3: Test factories — extend helpers + +**Files:** +- Modify: `tests/AbstractApiTestCase.php:399-461` + +- [ ] **Step 1: Update `createCustomField()` factory** + +In `tests/AbstractApiTestCase.php`, add `bool $machineContextOnly = false` parameter at the end of `createCustomField` (line 399), and add `$cf->setMachineContextOnly($machineContextOnly);` after `$cf->setOrderIndex($orderIndex);` (line 411). + +- [ ] **Step 2: Update `createCustomFieldValue()` factory** + +Add two new nullable parameters at the end of `createCustomFieldValue` (line 432): + +```php +?MachineComponentLink $machineComponentLink = null, +?MachinePieceLink $machinePieceLink = null, +``` + +Add the corresponding setter calls after the `$product` setter (line 453): + +```php +if (null !== $machineComponentLink) { + $cfv->setMachineComponentLink($machineComponentLink); +} +if (null !== $machinePieceLink) { + $cfv->setMachinePieceLink($machinePieceLink); +} +``` + +- [ ] **Step 3: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/AbstractApiTestCase.php +git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params" +``` + +--- + +## Task 4: `MachineStructureController` — normalize context fields + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` + +- [ ] **Step 1: Add `machineContextOnly` to all normalization methods** + +In `normalizeCustomFields` (line 601), add to the output array at line 615: +```php +'machineContextOnly' => $customField->isMachineContextOnly(), +``` + +In `normalizeCustomFieldDefinitions` (line 838), add to the output array at line 852: +```php +'machineContextOnly' => $cf->isMachineContextOnly(), +``` + +In `normalizeCustomFieldValues` (line 861), add to the nested `customField` array at line 879: +```php +'machineContextOnly' => $cf->isMachineContextOnly(), +``` + +- [ ] **Step 2: Add `normalizeContextCustomFieldDefinitions` helper** + +Add a new private method after `normalizeCustomFieldValues`: + +```php +private function normalizeContextCustomFieldDefinitions(Collection $customFields): array +{ + $items = []; + foreach ($customFields as $cf) { + if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) { + continue; + } + $items[] = [ + 'id' => $cf->getId(), + 'name' => $cf->getName(), + 'type' => $cf->getType(), + 'required' => $cf->isRequired(), + 'options' => $cf->getOptions(), + 'defaultValue' => $cf->getDefaultValue(), + 'orderIndex' => $cf->getOrderIndex(), + 'machineContextOnly' => true, + ]; + } + + usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']); + + return $items; +} +``` + +- [ ] **Step 3: Update `normalizeComponentLinks` to include context fields** + +In `normalizeComponentLinks` (line 622), add `$type` variable and context field keys to the returned array: + +```php +private function normalizeComponentLinks(array $links): array +{ + return array_map(function (MachineComponentLink $link): array { + $composant = $link->getComposant(); + $parentLink = $link->getParentLink(); + $type = $composant->getTypeComposant(); + + return [ + 'id' => $link->getId(), + 'linkId' => $link->getId(), + 'machineId' => $link->getMachine()->getId(), + 'composantId' => $composant->getId(), + 'composant' => $this->normalizeComposant($composant), + 'parentLinkId' => $parentLink?->getId(), + 'parentComponentLinkId' => $parentLink?->getId(), + 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'overrides' => $this->normalizeOverrides($link), + 'childLinks' => [], + 'pieceLinks' => [], + 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [], + 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), + ]; + }, $links); +} +``` + +- [ ] **Step 4: Update `normalizePieceLinks` to include context fields** + +In `normalizePieceLinks` (line 644), same pattern: + +```php +private function normalizePieceLinks(array $links): array +{ + return array_map(function (MachinePieceLink $link): array { + $piece = $link->getPiece(); + $parentLink = $link->getParentLink(); + $type = $piece->getTypePiece(); + + return [ + 'id' => $link->getId(), + 'linkId' => $link->getId(), + 'machineId' => $link->getMachine()->getId(), + 'pieceId' => $piece->getId(), + 'piece' => $this->normalizePiece($piece), + 'parentLinkId' => $parentLink?->getId(), + 'parentComponentLinkId' => $parentLink?->getId(), + 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'overrides' => $this->normalizeOverrides($link), + 'quantity' => $this->resolvePieceQuantity($link), + 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [], + 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), + ]; + }, $links); +} +``` + +- [ ] **Step 5: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Controller/MachineStructureController.php +git commit -m "feat(custom-fields) : expose context custom fields in machine structure response" +``` + +--- + +## Task 5: `CustomFieldValueController` — support link-based upsert + +**Files:** +- Modify: `src/Controller/CustomFieldValueController.php` + +- [ ] **Step 1: Inject link repositories in constructor** + +In the constructor (line 24), add: + +```php +private readonly MachineComponentLinkRepository $machineComponentLinkRepository, +private readonly MachinePieceLinkRepository $machinePieceLinkRepository, +``` + +Add use statements at the top: +```php +use App\Repository\MachineComponentLinkRepository; +use App\Repository\MachinePieceLinkRepository; +``` + +- [ ] **Step 2: Extend `resolveTarget` to support link entities** + +In `resolveTarget` (line 211), the method applies `strtolower()` on entityType at line 213. The candidate loop (line 217) and `match` block (line 233) must both use lowercase. + +Update the candidate list in the foreach: +```php +foreach (['machine', 'composant', 'piece', 'product'] as $candidate) { +``` + +Replace with: +```php +foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) { +``` + +The `$entityType` is lowercased at line 213, so when `machineComponentLinkId` is found, `$entityType` becomes `machinecomponentlink`. Update the match block: + +```php +return match ($entityType) { + 'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository), + 'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository), + 'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository), + 'product' => $this->resolveEntity('product', $entityId, $this->productRepository), + 'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository), + 'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository), + default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400), +}; +``` + +Note: the match keys are lowercase (post-strtolower), but `resolveEntity` returns the original camelCase type for `applyTarget`. + +- [ ] **Step 3: Extend `applyTarget` for link entities** + +Add two new cases in `applyTarget` (line 252): + +```php +case 'machineComponentLink': + $value->setMachineComponentLink($entity); + $value->setComposant($entity->getComposant()); + + break; + +case 'machinePieceLink': + $value->setMachinePieceLink($entity); + $value->setPiece($entity->getPiece()); + + break; +``` + +- [ ] **Step 4: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Controller/CustomFieldValueController.php +git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController" +``` + +--- + +## Task 6: Clone support — copy context field values + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` (clone methods, around line 163) + +- [ ] **Step 1: Add `cloneContextFieldValues` helper method** + +Add after `cloneProductLinks`: + +```php +/** + * @param array $componentLinkMap + * @param array $pieceLinkMap + */ +private function cloneContextFieldValues( + array $componentLinkMap, + array $pieceLinkMap, +): void { + foreach ($componentLinkMap as $oldLinkId => $newLink) { + $oldLink = $this->machineComponentLinkRepository->find($oldLinkId); + if (!$oldLink) { + continue; + } + foreach ($oldLink->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachineComponentLink($newLink); + $newValue->setComposant($newLink->getComposant()); + $this->entityManager->persist($newValue); + } + } + + foreach ($pieceLinkMap as $oldLinkId => $newLink) { + $oldLink = $this->machinePieceLinkRepository->find($oldLinkId); + if (!$oldLink) { + continue; + } + foreach ($oldLink->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachinePieceLink($newLink); + $newValue->setPiece($newLink->getPiece()); + $this->entityManager->persist($newValue); + } + } +} +``` + +- [ ] **Step 2: Call from `cloneMachine` method** + +In `cloneMachine` (line 113), after the `cloneProductLinks` call (line 163) and before `$this->entityManager->flush()` (line 165), add: + +```php +$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap); +``` + +- [ ] **Step 3: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Controller/MachineStructureController.php +git commit -m "feat(custom-fields) : clone context field values on machine clone" +``` + +--- + +## Task 7: Backend tests + +**Files:** +- Create: `tests/Api/Entity/MachineContextCustomFieldTest.php` + +- [ ] **Step 1: Write test class** + +```php +createGestionnaireClient(); + + $site = $this->createSite('Site A'); + $modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT); + + $contextField = $this->createCustomField( + name: 'Voltage', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + $normalField = $this->createCustomField( + name: 'Serial', + type: 'text', + typeComposant: $modelType, + ); + + $machine = $this->createMachine('Machine A', $site); + $composant = $this->createComposant('Motor 1', 'MOT-001', $modelType); + $link = $this->createMachineComponentLink($machine, $composant); + + $this->createCustomFieldValue( + customField: $contextField, + value: '220', + composant: $composant, + machineComponentLink: $link, + ); + + $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $componentLink = $data['componentLinks'][0]; + + // Context fields on the link + $this->assertArrayHasKey('contextCustomFields', $componentLink); + $this->assertCount(1, $componentLink['contextCustomFields']); + $this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']); + $this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']); + + // Context values on the link + $this->assertArrayHasKey('contextCustomFieldValues', $componentLink); + $this->assertCount(1, $componentLink['contextCustomFieldValues']); + $this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']); + + // Normal fields still in composant.customFields + $normalFields = array_filter( + $componentLink['composant']['customFields'], + fn (array $f) => $f['name'] === 'Serial', + ); + $this->assertCount(1, $normalFields); + } + + public function testStructureReturnsContextFieldsOnPieceLink(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site B'); + $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE); + $contextField = $this->createCustomField( + name: 'Wear Level', + type: 'select', + typePiece: $modelType, + machineContextOnly: true, + ); + $contextField->setOptions(['Good', 'Fair', 'Replace']); + $this->getEntityManager()->flush(); + + $machine = $this->createMachine('Machine B', $site); + $piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType); + $link = $this->createMachinePieceLink($machine, $piece); + + $this->createCustomFieldValue( + customField: $contextField, + value: 'Fair', + piece: $piece, + machinePieceLink: $link, + ); + + $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + $data = $response->toArray(); + + $pieceLink = $data['pieceLinks'][0]; + $this->assertCount(1, $pieceLink['contextCustomFields']); + $this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']); + $this->assertCount(1, $pieceLink['contextCustomFieldValues']); + $this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']); + } + + public function testUpsertContextFieldValueViaComponentLink(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site C'); + $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'Flow Rate', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $machine = $this->createMachine('Machine C', $site); + $composant = $this->createComposant('Pump 1', 'PMP-001', $modelType); + $link = $this->createMachineComponentLink($machine, $composant); + + $response = $client->request('POST', '/api/custom-fields/values/upsert', [ + 'json' => [ + 'customFieldId' => $contextField->getId(), + 'machineComponentLinkId' => $link->getId(), + 'value' => '380', + ], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame('380', $data['value']); + } + + public function testSameComposantDifferentValuesPerMachine(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site D'); + $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'Pressure', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $machineA = $this->createMachine('Machine A', $site); + $machineB = $this->createMachine('Machine B', $site); + $composant = $this->createComposant('Valve 1', 'VLV-001', $modelType); + + $linkA = $this->createMachineComponentLink($machineA, $composant); + $linkB = $this->createMachineComponentLink($machineB, $composant); + + $this->createCustomFieldValue( + customField: $contextField, + value: '100', + composant: $composant, + machineComponentLink: $linkA, + ); + $this->createCustomFieldValue( + customField: $contextField, + value: '200', + composant: $composant, + machineComponentLink: $linkB, + ); + + $dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray(); + $this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']); + + $dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray(); + $this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']); + } + + public function testMachineContextOnlyFieldSerialization(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site E'); + $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'Calibration Date', + type: 'date', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $response = $client->request('GET', '/api/custom_fields/'.$contextField->getId()); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertTrue($data['machineContextOnly']); + } + + public function testCloneMachineCopiesContextFieldValues(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site F'); + $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'RPM Setting', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $source = $this->createMachine('Source Machine', $site); + $composant = $this->createComposant('Motor C', 'MOTC-001', $modelType); + $link = $this->createMachineComponentLink($source, $composant); + + $this->createCustomFieldValue( + customField: $contextField, + value: '3000', + composant: $composant, + machineComponentLink: $link, + ); + + $response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'json' => [ + 'name' => 'Cloned Machine', + 'siteId' => $site->getId(), + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + + $clonedLink = $data['componentLinks'][0] ?? null; + $this->assertNotNull($clonedLink); + $this->assertCount(1, $clonedLink['contextCustomFieldValues']); + $this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']); + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php +``` + +Expected: All 6 tests pass. + +- [ ] **Step 3: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/Api/Entity/MachineContextCustomFieldTest.php +git commit -m "test(custom-fields) : add tests for machine context custom fields" +``` + +--- + +## Task 8: Frontend types — add `machineContextOnly` + +**Files:** +- Modify: `frontend/app/shared/types/inventory.ts` + +- [ ] **Step 1: Add `machineContextOnly` to `ComponentModelCustomField`** + +In the `ComponentModelCustomField` interface (around line 14), add: + +```typescript +machineContextOnly?: boolean +``` + +- [ ] **Step 2: Add `machineContextOnly` to `PieceModelCustomField`** + +In the `PieceModelCustomField` interface (around line 65), add: + +```typescript +machineContextOnly?: boolean +``` + +- [ ] **Step 3: Commit** + +```bash +cd frontend && git add app/shared/types/inventory.ts +git commit -m "feat(custom-fields) : add machineContextOnly to custom field types" +``` + +--- + +## Task 9: Structure editors — add toggle + +**Files:** +- Modify: `frontend/app/components/PieceModelStructureEditor.vue:122-125` +- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts:283-290` +- Modify: `frontend/app/components/StructureNodeEditor.vue:121-125` +- Modify: `frontend/app/composables/useStructureNodeCrud.ts:49-62` + +- [ ] **Step 1: Add toggle in `PieceModelStructureEditor.vue`** + +After the "Obligatoire" checkbox block (line 125), add: + +```vue +
+ + Contexte machine uniquement +
+``` + +- [ ] **Step 2: Update `createEmptyField` in `usePieceStructureEditorLogic.ts`** + +In `createEmptyField` (line 283), add `machineContextOnly: false` to the returned object: + +```typescript +const createEmptyField = (orderIndex: number): EditorField => ({ + uid: createUid('field'), + name: '', + type: 'text', + required: false, + optionsText: '', + machineContextOnly: false, + orderIndex, +}) +``` + +- [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`** + +After the "Obligatoire" checkbox (around line 121-125), add: + +```vue +
+ + Contexte machine uniquement +
+``` + +- [ ] **Step 4: Update `addCustomField` in `useStructureNodeCrud.ts`** + +In `addCustomField` (line 49), add `machineContextOnly: false` to the pushed object at line 53: + +```typescript +fields.push({ + name: '', + type: 'text', + required: false, + optionsText: '', + options: [], + machineContextOnly: false, + orderIndex: nextIndex, +}) +``` + +- [ ] **Step 5: Run lint + typecheck** + +```bash +cd frontend && npm run lint:fix && npx nuxi typecheck +``` + +- [ ] **Step 6: Commit** + +```bash +cd frontend && git add . +git commit -m "feat(custom-fields) : add machineContextOnly toggle in structure editors" +``` + +--- + +## Task 10: Frontend — filter context fields on standalone pages + machine-detail transform + +**Files:** +- Modify: `frontend/app/composables/useEntityCustomFields.ts:42-49` +- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts:96,186,141-154,241-256` + +- [ ] **Step 1: Filter `machineContextOnly` from `displayedCustomFields` in `useEntityCustomFields.ts`** + +Update the `displayedCustomFields` computed (line 42): + +```typescript +const displayedCustomFields = computed(() => + dedupeMergedFields( + mergeFieldDefinitionsWithValues( + definitionSources.value, + entity().customFieldValues, + ), + ).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly), +) +``` + +- [ ] **Step 2: Filter `machineContextOnly` from normal customFields merge in `useMachineDetailCustomFields.ts`** + +In `transformCustomFields` (line 70), after the `customFields` merge at line 96, filter out context fields. Change the return object (line 141-154) to filter: + +Replace `customFields,` (line 143) with: + +```typescript +customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly), +contextCustomFields: piece.contextCustomFields ?? [], +contextCustomFieldValues: piece.contextCustomFieldValues ?? [], +``` + +In `transformComponentCustomFields` (line 158), same pattern. Replace `customFields,` (line 243) with: + +```typescript +customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly), +contextCustomFields: component.contextCustomFields ?? [], +contextCustomFieldValues: component.contextCustomFieldValues ?? [], +``` + +- [ ] **Step 3: Run lint + typecheck** + +```bash +cd frontend && npm run lint:fix && npx nuxi typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +cd frontend && git add . +git commit -m "feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views" +``` + +--- + +## Task 11: Frontend — display context fields in machine view + +**Files:** +- Modify: `frontend/app/components/ComponentItem.vue` +- Modify: `frontend/app/components/PieceItem.vue` + +Context fields are on the `component` / `piece` object (set by the transform in Task 10), not as separate props. + +- [ ] **Step 1: Add context fields section in `ComponentItem.vue`** + +After the existing `CustomFieldDisplay` block (line 195), add: + +```vue + +
+

+ Champs contextuels +

+ +
+``` + +In the script section, add: + +```typescript +import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic' + +const mergedContextFields = computed(() => { + const definitions = props.component?.contextCustomFields ?? [] + const values = props.component?.contextCustomFieldValues ?? [] + if (!definitions.length && !values.length) return [] + return dedupeMergedFields( + mergeFieldDefinitionsWithValues(definitions, values), + ) +}) + +const updateContextCustomField = async (field: any) => { + const linkId = props.component?.linkId + if (!linkId || !field) return + + const customFieldId = field.customFieldId || field.customField?.id + if (!customFieldId) return + + const { upsertCustomFieldValue } = useCustomFields() + const result: any = await upsertCustomFieldValue( + customFieldId, + 'machineComponentLink', + linkId, + field.value ?? '', + ) + + if (result.success) { + showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`) + } else { + showError(`Erreur lors de la mise à jour du champ contextuel`) + } +} +``` + +Note: `useCustomFields`, `showSuccess`, and `showError` may need to be imported or may already be available in the component. Check the existing imports and add if missing: + +```typescript +import { useCustomFields } from '~/composables/useCustomFields' +import { useToast } from '~/composables/useToast' +// ... in setup: +const { showSuccess, showError } = useToast() +``` + +- [ ] **Step 2: Add context fields section in `PieceItem.vue`** + +Same pattern. After the existing `CustomFieldDisplay` (line 236), add: + +```vue + +
+

+ Champs contextuels +

+ +
+``` + +In the script: + +```typescript +const mergedContextFields = computed(() => { + const definitions = props.piece?.contextCustomFields ?? [] + const values = props.piece?.contextCustomFieldValues ?? [] + if (!definitions.length && !values.length) return [] + return dedupeMergedFields( + mergeFieldDefinitionsWithValues(definitions, values), + ) +}) + +const updateContextCustomField = async (field: any) => { + const linkId = props.piece?.linkId + if (!linkId || !field) return + + const customFieldId = field.customFieldId || field.customField?.id + if (!customFieldId) return + + const { upsertCustomFieldValue } = useCustomFields() + const result: any = await upsertCustomFieldValue( + customFieldId, + 'machinePieceLink', + linkId, + field.value ?? '', + ) + + if (result.success) { + showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`) + } else { + showError(`Erreur lors de la mise à jour du champ contextuel`) + } +} +``` + +- [ ] **Step 3: Run lint + typecheck** + +```bash +cd frontend && npm run lint:fix && npx nuxi typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +cd frontend && git add . +git commit -m "feat(custom-fields) : display context custom fields in machine view" +``` + +--- + +## Task 12: Frontend build + full backend test verification + +- [ ] **Step 1: Run full frontend build** + +```bash +cd frontend && npm run build +``` + +Expected: Build succeeds. + +- [ ] **Step 2: Run all backend tests** + +```bash +make test +``` + +Expected: All tests pass. + +- [ ] **Step 3: Update frontend submodule pointer** + +```bash +cd /home/matthieu/dev_malio/Inventory +git add frontend +git commit -m "chore : update frontend submodule for context custom fields" +``` diff --git a/docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md b/docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md new file mode 100644 index 0000000..b0314f3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md @@ -0,0 +1,841 @@ +# Machine Context Custom Fields — Backend Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Parallel plan:** This is the backend half. The frontend plan is at `2026-04-02-machine-context-fields-frontend.md`. Both can run in parallel on separate worktrees — they share no files. + +**Goal:** Add `machineContextOnly` flag on `CustomField`, link FKs on `CustomFieldValue`, structure controller normalization, upsert support, clone support, and tests. + +**Architecture:** `CustomField.machineContextOnly` flags definitions. `CustomFieldValue` gets nullable FKs to `MachineComponentLink`/`MachinePieceLink`. The structure response returns `contextCustomFields` and `contextCustomFieldValues` per link. Upsert and clone are extended. + +**Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12 + +**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md` + +--- + +## File Map + +### Create +- `migrations/VersionXXXX_MachineContextCustomFields.php` — migration +- `tests/Api/Entity/MachineContextCustomFieldTest.php` — test class + +### Modify +- `src/Entity/CustomField.php` — add `machineContextOnly` property +- `src/Entity/CustomFieldValue.php` — add `machineComponentLink` and `machinePieceLink` FKs +- `src/Entity/MachineComponentLink.php` — add `contextFieldValues` collection +- `src/Entity/MachinePieceLink.php` — add `contextFieldValues` collection +- `src/Controller/MachineStructureController.php` — normalize context fields + clone +- `src/Controller/CustomFieldValueController.php` — link-based upsert +- `tests/AbstractApiTestCase.php` — extend factories + +--- + +## Task 1: Migration + Entity `CustomField` — `machineContextOnly` + +**Files:** +- Modify: `src/Entity/CustomField.php` (add property after line 56) + +- [ ] **Step 1: Add `machineContextOnly` property to `CustomField` entity** + +In `src/Entity/CustomField.php`, add after the `$required` property (line 56): + +```php +#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')] +#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])] +private bool $machineContextOnly = false; +``` + +Add getter/setter before the closing `}`: + +```php +public function isMachineContextOnly(): bool +{ + return $this->machineContextOnly; +} + +public function setMachineContextOnly(bool $machineContextOnly): static +{ + $this->machineContextOnly = $machineContextOnly; + + return $this; +} +``` + +- [ ] **Step 2: Generate and adjust migration** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff +``` + +Edit the generated migration to use idempotent SQL: + +```sql +ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL; +``` + +- [ ] **Step 3: Run migration** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 4: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/CustomField.php migrations/ +git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity" +``` + +--- + +## Task 2: Entity `CustomFieldValue` — link FKs + inverse collections + +**Files:** +- Modify: `src/Entity/CustomFieldValue.php` (add after line 67 — `$product` property) +- Modify: `src/Entity/MachineComponentLink.php` (add after line 72 — `$productLinks`) +- Modify: `src/Entity/MachinePieceLink.php` (add after line 61 — `$productLinks`) + +- [ ] **Step 1: Add FKs to `CustomFieldValue`** + +In `src/Entity/CustomFieldValue.php`, add after the `$product` property (line 67): + +```php +#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')] +#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] +private ?MachineComponentLink $machineComponentLink = null; + +#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')] +#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] +private ?MachinePieceLink $machinePieceLink = null; +``` + +Add getters/setters before the closing `}`: + +```php +public function getMachineComponentLink(): ?MachineComponentLink +{ + return $this->machineComponentLink; +} + +public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static +{ + $this->machineComponentLink = $machineComponentLink; + + return $this; +} + +public function getMachinePieceLink(): ?MachinePieceLink +{ + return $this->machinePieceLink; +} + +public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static +{ + $this->machinePieceLink = $machinePieceLink; + + return $this; +} +``` + +- [ ] **Step 2: Add `contextFieldValues` collection to `MachineComponentLink`** + +In `src/Entity/MachineComponentLink.php`, add after the `$productLinks` collection (line 72): + +```php +/** + * @var Collection + */ +#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)] +private Collection $contextFieldValues; +``` + +In the constructor (line 95), add: +```php +$this->contextFieldValues = new ArrayCollection(); +``` + +Add getter before the closing `}`: +```php +/** + * @return Collection + */ +public function getContextFieldValues(): Collection +{ + return $this->contextFieldValues; +} +``` + +- [ ] **Step 3: Add `contextFieldValues` collection to `MachinePieceLink`** + +In `src/Entity/MachinePieceLink.php`, add after the `$productLinks` collection (line 61): + +```php +/** + * @var Collection + */ +#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)] +private Collection $contextFieldValues; +``` + +In the constructor (line 86), add: +```php +$this->contextFieldValues = new ArrayCollection(); +``` + +Add getter: +```php +/** + * @return Collection + */ +public function getContextFieldValues(): Collection +{ + return $this->contextFieldValues; +} +``` + +- [ ] **Step 4: Generate and adjust migration** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff +``` + +Edit migration to use idempotent SQL: +```sql +ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL; +ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN + ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link + FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN + ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link + FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid); +CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid); +``` + +- [ ] **Step 5: Run migration + linter** + +```bash +docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/ +git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context" +``` + +--- + +## Task 3: Test factories — extend helpers + +**Files:** +- Modify: `tests/AbstractApiTestCase.php:399-461` + +- [ ] **Step 1: Update `createCustomField()` factory** + +In `tests/AbstractApiTestCase.php`, add `bool $machineContextOnly = false` parameter at the end of `createCustomField` (line 399), and add `$cf->setMachineContextOnly($machineContextOnly);` after `$cf->setOrderIndex($orderIndex);` (line 411). + +- [ ] **Step 2: Update `createCustomFieldValue()` factory** + +Add two new nullable parameters at the end of `createCustomFieldValue` (line 432): + +```php +?MachineComponentLink $machineComponentLink = null, +?MachinePieceLink $machinePieceLink = null, +``` + +Add the corresponding setter calls **after the closing `}` of the `if (null !== $product)` block** (after line 454, NOT inside it): + +```php +if (null !== $machineComponentLink) { + $cfv->setMachineComponentLink($machineComponentLink); +} +if (null !== $machinePieceLink) { + $cfv->setMachinePieceLink($machinePieceLink); +} +``` + +- [ ] **Step 3: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/AbstractApiTestCase.php +git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params" +``` + +--- + +## Task 4: `MachineStructureController` — normalize context fields + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` + +- [ ] **Step 1: Add `machineContextOnly` to all normalization methods** + +In `normalizeCustomFields` (line 601), add to the output array at line 615: +```php +'machineContextOnly' => $customField->isMachineContextOnly(), +``` + +In `normalizeCustomFieldDefinitions` (line 838), add to the output array at line 852: +```php +'machineContextOnly' => $cf->isMachineContextOnly(), +``` + +In `normalizeCustomFieldValues` (line 861), add to the nested `customField` array at line 879: +```php +'machineContextOnly' => $cf->isMachineContextOnly(), +``` + +- [ ] **Step 2: Add `normalizeContextCustomFieldDefinitions` helper** + +Add a new private method after `normalizeCustomFieldValues`: + +```php +private function normalizeContextCustomFieldDefinitions(Collection $customFields): array +{ + $items = []; + foreach ($customFields as $cf) { + if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) { + continue; + } + $items[] = [ + 'id' => $cf->getId(), + 'name' => $cf->getName(), + 'type' => $cf->getType(), + 'required' => $cf->isRequired(), + 'options' => $cf->getOptions(), + 'defaultValue' => $cf->getDefaultValue(), + 'orderIndex' => $cf->getOrderIndex(), + 'machineContextOnly' => true, + ]; + } + + usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']); + + return $items; +} +``` + +- [ ] **Step 3: Update `normalizeComponentLinks` to include context fields** + +In `normalizeComponentLinks` (line 622), add `$type` variable and context field keys to the returned array: + +```php +private function normalizeComponentLinks(array $links): array +{ + return array_map(function (MachineComponentLink $link): array { + $composant = $link->getComposant(); + $parentLink = $link->getParentLink(); + $type = $composant->getTypeComposant(); + + return [ + 'id' => $link->getId(), + 'linkId' => $link->getId(), + 'machineId' => $link->getMachine()->getId(), + 'composantId' => $composant->getId(), + 'composant' => $this->normalizeComposant($composant), + 'parentLinkId' => $parentLink?->getId(), + 'parentComponentLinkId' => $parentLink?->getId(), + 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'overrides' => $this->normalizeOverrides($link), + 'childLinks' => [], + 'pieceLinks' => [], + 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [], + 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), + ]; + }, $links); +} +``` + +- [ ] **Step 4: Update `normalizePieceLinks` to include context fields** + +In `normalizePieceLinks` (line 644): + +```php +private function normalizePieceLinks(array $links): array +{ + return array_map(function (MachinePieceLink $link): array { + $piece = $link->getPiece(); + $parentLink = $link->getParentLink(); + $type = $piece->getTypePiece(); + + return [ + 'id' => $link->getId(), + 'linkId' => $link->getId(), + 'machineId' => $link->getMachine()->getId(), + 'pieceId' => $piece->getId(), + 'piece' => $this->normalizePiece($piece), + 'parentLinkId' => $parentLink?->getId(), + 'parentComponentLinkId' => $parentLink?->getId(), + 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'overrides' => $this->normalizeOverrides($link), + 'quantity' => $this->resolvePieceQuantity($link), + 'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [], + 'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()), + ]; + }, $links); +} +``` + +- [ ] **Step 5: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/Controller/MachineStructureController.php +git commit -m "feat(custom-fields) : expose context custom fields in machine structure response" +``` + +--- + +## Task 5: `CustomFieldValueController` — support link-based upsert + +**Files:** +- Modify: `src/Controller/CustomFieldValueController.php` + +- [ ] **Step 1: Inject link repositories in constructor** + +In the constructor (line 24), add: + +```php +private readonly MachineComponentLinkRepository $machineComponentLinkRepository, +private readonly MachinePieceLinkRepository $machinePieceLinkRepository, +``` + +Add use statements at the top: +```php +use App\Repository\MachineComponentLinkRepository; +use App\Repository\MachinePieceLinkRepository; +``` + +- [ ] **Step 2: Extend `resolveTarget` to support link entities** + +In `resolveTarget` (line 211), the method applies `strtolower()` on entityType at line 213. The candidate loop and match block must handle this. + +Update the candidate list in the foreach (line 217): +```php +foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) { +``` + +**IMPORTANT:** The candidate loop assigns `$entityType` in camelCase, but `strtolower()` (line 213) only applies when `entityType` comes from the payload directly. Add a normalization after the loop closes (after the existing `break;` at line 226), before the empty-check at line 229: + +```php +$entityType = strtolower($entityType); +``` + +This ensures both code paths (direct payload `entityType` and candidate loop) deliver lowercase to the match block. + +Update the match block (line 233) — all keys lowercase, but `resolveEntity` returns camelCase type for `applyTarget`: + +```php +return match ($entityType) { + 'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository), + 'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository), + 'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository), + 'product' => $this->resolveEntity('product', $entityId, $this->productRepository), + 'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository), + 'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository), + default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400), +}; +``` + +- [ ] **Step 3: Extend `applyTarget` for link entities** + +Add two new cases in `applyTarget` (line 252): + +```php +case 'machineComponentLink': + $value->setMachineComponentLink($entity); + $value->setComposant($entity->getComposant()); + + break; + +case 'machinePieceLink': + $value->setMachinePieceLink($entity); + $value->setPiece($entity->getPiece()); + + break; +``` + +- [ ] **Step 4: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Controller/CustomFieldValueController.php +git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController" +``` + +--- + +## Task 6: Clone support — copy context field values + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` (after `cloneProductLinks`, before `flush` in `cloneMachine`) + +- [ ] **Step 1: Add `cloneContextFieldValues` helper method** + +Add after `cloneProductLinks`: + +```php +/** + * @param array $componentLinkMap + * @param array $pieceLinkMap + */ +private function cloneContextFieldValues( + array $componentLinkMap, + array $pieceLinkMap, +): void { + foreach ($componentLinkMap as $oldLinkId => $newLink) { + $oldLink = $this->machineComponentLinkRepository->find($oldLinkId); + if (!$oldLink) { + continue; + } + foreach ($oldLink->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachineComponentLink($newLink); + $newValue->setComposant($newLink->getComposant()); + $this->entityManager->persist($newValue); + } + } + + foreach ($pieceLinkMap as $oldLinkId => $newLink) { + $oldLink = $this->machinePieceLinkRepository->find($oldLinkId); + if (!$oldLink) { + continue; + } + foreach ($oldLink->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachinePieceLink($newLink); + $newValue->setPiece($newLink->getPiece()); + $this->entityManager->persist($newValue); + } + } +} +``` + +- [ ] **Step 2: Call from `cloneMachine` method** + +In `cloneMachine` (line 113), after `$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);` (line 163) and before `$this->entityManager->flush();` (line 165), add: + +```php +$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap); +``` + +- [ ] **Step 3: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Controller/MachineStructureController.php +git commit -m "feat(custom-fields) : clone context field values on machine clone" +``` + +--- + +## Task 7: Backend tests + +**Files:** +- Create: `tests/Api/Entity/MachineContextCustomFieldTest.php` + +- [ ] **Step 1: Write test class** + +```php +createGestionnaireClient(); + + $site = $this->createSite('Site A'); + $modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT); + + $contextField = $this->createCustomField( + name: 'Voltage', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + $normalField = $this->createCustomField( + name: 'Serial', + type: 'text', + typeComposant: $modelType, + ); + + $machine = $this->createMachine('Machine A', $site); + $composant = $this->createComposant('Motor 1', 'MOT-001', $modelType); + $link = $this->createMachineComponentLink($machine, $composant); + + $this->createCustomFieldValue( + customField: $contextField, + value: '220', + composant: $composant, + machineComponentLink: $link, + ); + + $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $componentLink = $data['componentLinks'][0]; + + $this->assertArrayHasKey('contextCustomFields', $componentLink); + $this->assertCount(1, $componentLink['contextCustomFields']); + $this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']); + $this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']); + + $this->assertArrayHasKey('contextCustomFieldValues', $componentLink); + $this->assertCount(1, $componentLink['contextCustomFieldValues']); + $this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']); + + $normalFields = array_filter( + $componentLink['composant']['customFields'], + fn (array $f) => $f['name'] === 'Serial', + ); + $this->assertCount(1, $normalFields); + } + + public function testStructureReturnsContextFieldsOnPieceLink(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site B'); + $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE); + $contextField = $this->createCustomField( + name: 'Wear Level', + type: 'select', + typePiece: $modelType, + machineContextOnly: true, + ); + $contextField->setOptions(['Good', 'Fair', 'Replace']); + $this->getEntityManager()->flush(); + + $machine = $this->createMachine('Machine B', $site); + $piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType); + $link = $this->createMachinePieceLink($machine, $piece); + + $this->createCustomFieldValue( + customField: $contextField, + value: 'Fair', + piece: $piece, + machinePieceLink: $link, + ); + + $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + $data = $response->toArray(); + + $pieceLink = $data['pieceLinks'][0]; + $this->assertCount(1, $pieceLink['contextCustomFields']); + $this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']); + $this->assertCount(1, $pieceLink['contextCustomFieldValues']); + $this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']); + } + + public function testUpsertContextFieldValueViaComponentLink(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site C'); + $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'Flow Rate', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $machine = $this->createMachine('Machine C', $site); + $composant = $this->createComposant('Pump 1', 'PMP-001', $modelType); + $link = $this->createMachineComponentLink($machine, $composant); + + $response = $client->request('POST', '/api/custom-fields/values/upsert', [ + 'json' => [ + 'customFieldId' => $contextField->getId(), + 'machineComponentLinkId' => $link->getId(), + 'value' => '380', + ], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame('380', $data['value']); + } + + public function testSameComposantDifferentValuesPerMachine(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site D'); + $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'Pressure', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $machineA = $this->createMachine('Machine A', $site); + $machineB = $this->createMachine('Machine B', $site); + $composant = $this->createComposant('Valve 1', 'VLV-001', $modelType); + + $linkA = $this->createMachineComponentLink($machineA, $composant); + $linkB = $this->createMachineComponentLink($machineB, $composant); + + $this->createCustomFieldValue( + customField: $contextField, + value: '100', + composant: $composant, + machineComponentLink: $linkA, + ); + $this->createCustomFieldValue( + customField: $contextField, + value: '200', + composant: $composant, + machineComponentLink: $linkB, + ); + + $dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray(); + $this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']); + + $dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray(); + $this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']); + } + + public function testMachineContextOnlyFieldSerialization(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site E'); + $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'Calibration Date', + type: 'date', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $response = $client->request('GET', '/api/custom_fields/'.$contextField->getId()); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertTrue($data['machineContextOnly']); + } + + public function testCloneMachineCopiesContextFieldValues(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site F'); + $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'RPM Setting', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $source = $this->createMachine('Source Machine', $site); + $composant = $this->createComposant('Motor C', 'MOTC-001', $modelType); + $link = $this->createMachineComponentLink($source, $composant); + + $this->createCustomFieldValue( + customField: $contextField, + value: '3000', + composant: $composant, + machineComponentLink: $link, + ); + + $response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'json' => [ + 'name' => 'Cloned Machine', + 'siteId' => $site->getId(), + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + + $clonedLink = $data['componentLinks'][0] ?? null; + $this->assertNotNull($clonedLink); + $this->assertCount(1, $clonedLink['contextCustomFieldValues']); + $this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']); + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php +``` + +Expected: All 6 tests pass. + +- [ ] **Step 3: Run linter** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 4: Run full test suite** + +```bash +make test +``` + +Expected: All existing tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add tests/Api/Entity/MachineContextCustomFieldTest.php +git commit -m "test(custom-fields) : add tests for machine context custom fields" +``` diff --git a/docs/superpowers/plans/2026-04-02-machine-context-fields-frontend.md b/docs/superpowers/plans/2026-04-02-machine-context-fields-frontend.md new file mode 100644 index 0000000..9b41504 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-machine-context-fields-frontend.md @@ -0,0 +1,404 @@ +# Machine Context Custom Fields — Frontend Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Parallel plan:** This is the frontend half. The backend plan is at `2026-04-02-machine-context-fields-backend.md`. Both can run in parallel on separate worktrees — they share no files. Frontend tests requiring the API will need the backend done first. + +**Goal:** Add `machineContextOnly` toggle in structure editors, filter context fields from standalone pages, and display/edit them in the machine detail view. + +**Architecture:** Structure editors get a checkbox per field. The machine-detail transform propagates `contextCustomFields`/`contextCustomFieldValues` from the API link response onto the component/piece objects. Standalone entity views filter these out. Machine view displays them in a separate "Champs contextuels" section using the existing `CustomFieldDisplay` component, saving via upsert with the link ID. + +**Tech Stack:** Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5 + +**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md` + +--- + +## File Map + +### Modify +- `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types +- `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle +- `frontend/app/composables/usePieceStructureEditorLogic.ts` — add default in `createEmptyField()` +- `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle +- `frontend/app/composables/useStructureNodeCrud.ts` — add default in `addCustomField()` +- `frontend/app/composables/useEntityCustomFields.ts` — filter out `machineContextOnly` fields +- `frontend/app/composables/useMachineDetailCustomFields.ts` — propagate context fields, filter from normal merge +- `frontend/app/components/ComponentItem.vue` — display context custom fields section +- `frontend/app/components/PieceItem.vue` — display context custom fields section + +--- + +## Task 1: Types — add `machineContextOnly` + +**Files:** +- Modify: `frontend/app/shared/types/inventory.ts` + +- [ ] **Step 1: Add `machineContextOnly` to `ComponentModelCustomField`** + +In the `ComponentModelCustomField` interface (around line 14), add: + +```typescript +machineContextOnly?: boolean +``` + +- [ ] **Step 2: Add `machineContextOnly` to `PieceModelCustomField`** + +In the `PieceModelCustomField` interface (around line 65), add: + +```typescript +machineContextOnly?: boolean +``` + +- [ ] **Step 3: Commit** + +```bash +cd frontend && git add app/shared/types/inventory.ts +git commit -m "feat(custom-fields) : add machineContextOnly to custom field types" +``` + +--- + +## Task 2: Structure editors — add toggle + +**Files:** +- Modify: `frontend/app/components/PieceModelStructureEditor.vue:122-125` +- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts:283-290` +- Modify: `frontend/app/components/StructureNodeEditor.vue:121-125` +- Modify: `frontend/app/composables/useStructureNodeCrud.ts:49-62` + +- [ ] **Step 1: Add toggle in `PieceModelStructureEditor.vue`** + +After the "Obligatoire" checkbox block (line 125, after `
` closing the required checkbox), add: + +```vue +
+ + Contexte machine uniquement +
+``` + +- [ ] **Step 2: Update `usePieceStructureEditorLogic.ts` — 3 functions** + +**a) `createEmptyField`** (line 283) — add `machineContextOnly: false` to the returned object: + +```typescript +const createEmptyField = (orderIndex: number): EditorField => ({ + uid: createUid('field'), + name: '', + type: 'text', + required: false, + optionsText: '', + machineContextOnly: false, + orderIndex, +}) +``` + +**b) `toEditorField`** (line 78-91) — add `machineContextOnly` to the returned object, after the `orderIndex` line (line 90): + +```typescript +machineContextOnly: Boolean(input?.machineContextOnly), +``` + +**c) `buildPayload`** (line 160-165) — add `machineContextOnly` to the `payload` object after `orderIndex` (line 164): + +```typescript +machineContextOnly: Boolean(field.machineContextOnly), +``` + +- [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`** + +After the "Obligatoire" checkbox closing `
` (line 123) and **before** the `