From 353d7e938e778ec5ba5dbe118e09ec6139efb6b0 Mon Sep 17 00:00:00 2001
From: r-dev
Date: Sat, 4 Apr 2026 12:32:11 +0200
Subject: [PATCH 01/42] docs : add custom fields simplification design spec
Co-Authored-By: Claude Opus 4.6 (1M context)
---
...-04-custom-fields-simplification-design.md | 214 ++++++++++++++++++
1 file changed, 214 insertions(+)
create mode 100644 docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md
diff --git a/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md b/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md
new file mode 100644
index 0000000..230cc0b
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md
@@ -0,0 +1,214 @@
+# Custom Fields Simplification — Design Spec
+
+**Date:** 2026-04-04
+**Scope:** Backend minor cleanup + Frontend unification of the custom fields system
+**Constraint:** Everything must work after — progressive migration with verification at each step
+
+## Problem
+
+The custom fields system has grown into 3 parallel frontend implementations (~2900 lines across 9 files) due to accumulated defensive code. This caused data bugs (orphaned fields, lost linkage) and makes every change risky.
+
+## 4 Custom Field Contexts
+
+1. **Machine** — fields defined directly on the machine (`CustomField.machineid` FK), values on machine
+2. **Standalone entity** — fields defined in ModelType (category), values on composant/piece/product. Visible when opening the entity directly
+3. **Machine context** — fields with `machineContextOnly=true` defined in ModelType, values stored on `MachineComponentLink`/`MachinePieceLink`. Visible only from the machine detail page
+4. **Category editor** — UI for defining/editing custom fields in a ModelType skeleton
+
+## Backend Changes
+
+### Minor — format already consistent
+
+After review, `MachineStructureController` already serializes custom fields in the same format as API Platform:
+
+```json
+// CustomFieldValue (from normalizeCustomFieldValues)
+{
+ "id": "cfv-123",
+ "value": "USOCOME",
+ "customField": {
+ "id": "cf-456",
+ "name": "MARQUE",
+ "type": "text",
+ "required": false,
+ "options": [],
+ "defaultValue": null,
+ "orderIndex": 0,
+ "machineContextOnly": false
+ }
+}
+```
+
+```json
+// CustomField definition (from normalizeCustomFieldDefinitions)
+{
+ "id": "cf-456",
+ "name": "MARQUE",
+ "type": "text",
+ "required": false,
+ "options": [],
+ "defaultValue": null,
+ "orderIndex": 0,
+ "machineContextOnly": false
+}
+```
+
+The only backend task is adding `defaultValue` to the API Platform serialization groups on `CustomField.php` so that both API Platform and the custom controller return it.
+
+**Context fields on links** are returned as two separate arrays:
+- `contextCustomFields` — definitions filtered to `machineContextOnly=true`
+- `contextCustomFieldValues` — values stored on `MachineComponentLink`/`MachinePieceLink`
+
+This format stays as-is. The frontend unified module handles the merge.
+
+**Files:**
+- `src/Entity/CustomField.php` — add `#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]` to `defaultValue`
+
+### Legacy `{key, value}` format in DB
+
+`SkeletonStructureService::normalizeCustomFieldData()` accepts two formats:
+- Legacy: `{key: "name", value: {type, required, options?, defaultValue?}}`
+- Standard: `{name, type, required, options?, defaultValue?}`
+
+**Pre-migration check required:** verify if any `ModelType` rows still have the legacy format in their structure data. If yes, write a one-time DB migration to normalize them before removing the frontend parsing code in Step 5. If no legacy data exists, the parsing code can be safely removed.
+
+## Frontend Changes
+
+### New Unified Module (2 files, ~400 lines total)
+
+**`shared/utils/customFields.ts`** (~180 lines) — Pure logic, zero Vue dependency
+
+Types:
+- `CustomFieldDefinition` — `{ id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }`
+- `CustomFieldValue` — `{ id, value, customField: CustomFieldDefinition }`
+- `CustomFieldInput` — `{ ...CustomFieldDefinition, value, customFieldId, customFieldValueId }` (the merged type used by forms)
+
+Functions:
+- `mergeDefinitionsWithValues(definitions, values)` → `CustomFieldInput[]` — the ONE merge function replacing the 3 current ones. Matches by `customField.id` then by `name`. When no value exists for a definition, uses `defaultValue` as initial value.
+- `filterByContext(fields, context: 'standalone' | 'machine')` — filters on `machineContextOnly`
+- `sortByOrder(fields)` — sorts by `orderIndex`
+- `formatValueForSave(field)` / `shouldPersist(field)` — persistence helpers
+- `formatValueForDisplay(field)` — display helper (e.g. boolean → `Oui/Non`), replaces `formatCustomFieldValue` from `customFieldUtils.ts`
+- `fieldKey(field, index)` — stable key for v-for, replaces `fieldKey` from `customFieldFormUtils.ts`
+
+**`composables/useCustomFieldInputs.ts`** (~220 lines) — Reactive, wraps pure helpers
+
+```ts
+function useCustomFieldInputs(options: {
+ definitions: MaybeRef
+ values: MaybeRef
+ entityType: 'machine' | 'composant' | 'piece' | 'product' | 'machineComponentLink' | 'machinePieceLink'
+ entityId: MaybeRef
+ context?: 'standalone' | 'machine' // defaults to 'standalone'
+}): {
+ fields: ComputedRef
+ update: (field: CustomFieldInput) => Promise
+ saveAll: () => Promise // returns failed field names
+ requiredFilled: ComputedRef
+}
+```
+
+**Usage for context 3 (machine context fields on links):**
+```ts
+// For each MachineComponentLink, instantiate with:
+const contextFields = useCustomFieldInputs({
+ definitions: link.contextCustomFields, // from MachineStructureController
+ values: link.contextCustomFieldValues, // from MachineStructureController
+ entityType: 'machineComponentLink',
+ entityId: link.id,
+ context: 'machine',
+})
+```
+
+### Files Deleted After Migration
+
+| File | Lines | Replaced by |
+|------|-------|-------------|
+| `shared/utils/entityCustomFieldLogic.ts` | 335 | `shared/utils/customFields.ts` |
+| `shared/utils/customFieldUtils.ts` | 440 | `shared/utils/customFields.ts` |
+| `shared/utils/customFieldFormUtils.ts` | 404 | `shared/utils/customFields.ts` + `composables/useCustomFieldInputs.ts` |
+| `composables/useEntityCustomFields.ts` | 181 | `composables/useCustomFieldInputs.ts` |
+
+Additionally refactored (not deleted):
+- `composables/useMachineDetailCustomFields.ts` — custom fields code extracted, uses new module (keeps non-CF logic: constructeurs, products, transforms)
+- `shared/model/componentStructure.ts` — custom fields code removed (kept: structure/skeleton logic)
+- `shared/model/componentStructureSanitize.ts` — custom fields sanitize code removed
+- `shared/model/componentStructureHydrate.ts` — custom fields hydrate code removed
+
+### All consuming files to migrate
+
+**Composables:**
+- `composables/useComponentEdit.ts` — use `useCustomFieldInputs`
+- `composables/useComponentCreate.ts` — use `useCustomFieldInputs`
+- `composables/usePieceEdit.ts` — use `useCustomFieldInputs`
+- `composables/useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for all 3 machine sub-cases
+
+**Pages:**
+- `pages/component/[id]/index.vue` — already uses composable, minimal changes
+- `pages/component/[id]/edit.vue` — already uses composable, minimal changes
+- `pages/component/create.vue` — already uses composable, minimal changes
+- `pages/pieces/create.vue` — imports from `customFieldFormUtils`, migrate to new types
+- `pages/pieces/[id]/edit.vue` — already uses composable, minimal changes
+- `pages/product/create.vue` — imports from `customFieldFormUtils`, migrate to new types
+- `pages/product/[id]/edit.vue` — imports from `customFieldFormUtils`, migrate to new types
+- `pages/product/[id]/index.vue` — imports from `customFieldFormUtils`, migrate to new types
+
+**Shared components:**
+- `components/common/CustomFieldDisplay.vue` — imports 7 functions from `entityCustomFieldLogic`, rewrite with unified `CustomFieldInput` type
+- `components/common/CustomFieldInputGrid.vue` — imports `fieldKey` + `CustomFieldInput` from `customFieldFormUtils`, update imports
+- `components/ComponentItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate
+- `components/PieceItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate
+- `components/machine/MachineCustomFieldsCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay`
+- `components/machine/MachineInfoCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay`
+- `components/model-types/ModelTypeForm.vue` — use `shared/utils/customFields.ts` types
+
+**Tests:**
+- `tests/shared/customFieldFormUtils.test.ts` — rewrite for new module or delete
+
+## Migration Strategy — Progressive (6 steps)
+
+### Step 1: Backend minor fix + DB check
+- Add `defaultValue` to serialization groups in `CustomField.php`
+- Check DB for legacy `{key, value}` format in `model_types.structure` — write migration if needed
+- **Verify:** call `/api/composants/{id}`, confirm `defaultValue` appears in `customField` objects
+
+### Step 2: Create new module
+- Write `shared/utils/customFields.ts` and `composables/useCustomFieldInputs.ts`
+- Port existing test to new module
+- **Verify:** import in a test page, confirm merge/filter/sort/defaultValue work with real data
+
+### Step 3: Migrate standalone pages (composant/piece/product)
+- Refactor composables: `useComponentEdit.ts`, `useComponentCreate.ts`, `usePieceEdit.ts`
+- Refactor pages: `pieces/create.vue`, `product/create.vue`, `product/[id]/edit.vue`, `product/[id]/index.vue`
+- Refactor shared components: `CustomFieldInputGrid.vue`, `CustomFieldDisplay.vue`
+- **Verify per page:** open entity, check fields display with values (including defaultValue on new entities), modify a value, confirm save works
+
+### Step 4: Migrate machine page + hierarchy components
+- Refactor `useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for:
+ - Machine direct fields (definitions from `machine.customFields`, values from `machine.customFieldValues`)
+ - Standalone component/piece fields (definitions from `type.customFields`, values from entity's `customFieldValues`, filtered `machineContextOnly=false`)
+ - Machine context fields (definitions from `link.contextCustomFields`, values from `link.contextCustomFieldValues`)
+- Refactor `ComponentItem.vue`, `PieceItem.vue` — use `useCustomFieldInputs` instead of `useEntityCustomFields`
+- Refactor `MachineCustomFieldsCard.vue`, `MachineInfoCard.vue` — use `formatValueForDisplay`
+- **Verify:** open a machine with components that have both normal AND machine-context custom fields, check both display and save correctly
+
+### Step 5: Migrate category editor
+- Check DB for legacy `{key, value}` format — run migration if needed
+- Clean `componentStructure.ts`, `componentStructureSanitize.ts`, `componentStructureHydrate.ts` — remove custom fields code, use unified types from `customFields.ts`
+- Refactor `ModelTypeForm.vue`
+- **Verify:** edit a component category, modify skeleton custom fields, save, check linked components see changes
+
+### Step 6: Cleanup
+- Delete the 4 old files
+- Delete or rewrite `tests/shared/customFieldFormUtils.test.ts`
+- `npm run lint:fix` + `npx nuxi typecheck` = 0 errors
+- Final smoke test of all 4 contexts
+
+## Result
+
+- **~2900 lines → ~400 lines** + simplified consumers
+- **9 custom fields files → 2**
+- **3 parallel systems → 1**
+- **1 unified data format** understood by all pages
+- **`defaultValue` properly handled** across all contexts
+- **Legacy format eliminated** from DB and code
From 875a34f169a94c7192e862a6c23e92e4ed00094c Mon Sep 17 00:00:00 2001
From: r-dev
Date: Sat, 4 Apr 2026 12:37:15 +0200
Subject: [PATCH 02/42] docs : add custom fields simplification implementation
plan
Co-Authored-By: Claude Opus 4.6 (1M context)
---
...2026-04-04-custom-fields-simplification.md | 951 ++++++++++++++++++
1 file changed, 951 insertions(+)
create mode 100644 docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
diff --git a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
new file mode 100644
index 0000000..bcd70d8
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
@@ -0,0 +1,951 @@
+# Custom Fields Simplification — Implementation 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.
+
+**Goal:** Replace 3 parallel custom fields frontend implementations (~2900 lines, 9 files) with a single unified module (~400 lines, 2 files), then migrate all consumers.
+
+**Architecture:** One pure-logic module (`customFields.ts`) with types + helpers, one reactive composable (`useCustomFieldInputs.ts`) wrapping it. The existing API composable `useCustomFields.ts` stays as-is (it's the HTTP layer). The backend already returns a consistent format — only one minor fix needed (add `defaultValue` to serialization groups).
+
+**Tech Stack:** TypeScript, Vue 3 Composition API, Nuxt 4 auto-imports
+
+**Spec:** `docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md`
+
+---
+
+## File Map
+
+### New files
+- `frontend/app/shared/utils/customFields.ts` — types + pure helpers (merge, filter, format, sort)
+- `frontend/app/composables/useCustomFieldInputs.ts` — reactive composable wrapping pure helpers + API
+
+### Files to delete (end of migration)
+- `frontend/app/shared/utils/entityCustomFieldLogic.ts`
+- `frontend/app/shared/utils/customFieldUtils.ts`
+- `frontend/app/shared/utils/customFieldFormUtils.ts`
+- `frontend/app/composables/useEntityCustomFields.ts`
+
+### Backend file (minor fix)
+- `src/Entity/CustomField.php` — add `defaultValue` to serialization groups
+
+### Files to refactor (update imports)
+- `frontend/app/composables/useComponentEdit.ts`
+- `frontend/app/composables/useComponentCreate.ts`
+- `frontend/app/composables/usePieceEdit.ts`
+- `frontend/app/composables/useMachineDetailCustomFields.ts`
+- `frontend/app/components/ComponentItem.vue`
+- `frontend/app/components/PieceItem.vue`
+- `frontend/app/components/common/CustomFieldDisplay.vue`
+- `frontend/app/components/common/CustomFieldInputGrid.vue`
+- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
+- `frontend/app/components/machine/MachineInfoCard.vue`
+- `frontend/app/pages/pieces/create.vue`
+- `frontend/app/pages/product/create.vue`
+- `frontend/app/pages/product/[id]/edit.vue`
+- `frontend/app/pages/product/[id]/index.vue`
+- `frontend/app/shared/model/componentStructure.ts`
+- `frontend/app/shared/model/componentStructureSanitize.ts`
+- `frontend/app/shared/model/componentStructureHydrate.ts`
+
+---
+
+## Task 1: Backend — Add `defaultValue` to serialization groups
+
+**Files:**
+- Modify: `src/Entity/CustomField.php:62-63`
+
+- [ ] **Step 1: Add Groups attribute to defaultValue**
+
+In `src/Entity/CustomField.php`, the `defaultValue` property (line 62-63) currently has no `#[Groups]` attribute. Add it so API Platform includes `defaultValue` in all read responses, matching what `MachineStructureController` already returns.
+
+```php
+#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
+#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
+private ?string $defaultValue = null;
+```
+
+- [ ] **Step 2: Run linter**
+
+```bash
+make php-cs-fixer-allow-risky
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/Entity/CustomField.php
+git commit -m "fix(api) : expose defaultValue in custom field serialization groups"
+```
+
+---
+
+## Task 2: Create unified pure-logic module `customFields.ts`
+
+**Files:**
+- Create: `frontend/app/shared/utils/customFields.ts`
+
+This replaces all the types and pure functions from `entityCustomFieldLogic.ts` (335 lines), `customFieldUtils.ts` (440 lines), and `customFieldFormUtils.ts` (404 lines).
+
+- [ ] **Step 1: Write the types and all pure helper functions**
+
+```typescript
+/**
+ * Unified custom field types and pure helpers.
+ *
+ * Replaces: entityCustomFieldLogic.ts, customFieldUtils.ts, customFieldFormUtils.ts
+ */
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/** A custom field definition (from ModelType structure or CustomField entity) */
+export interface CustomFieldDefinition {
+ id: string | null
+ name: string
+ type: string
+ required: boolean
+ options: string[]
+ defaultValue: string | null
+ orderIndex: number
+ machineContextOnly: boolean
+}
+
+/** A persisted custom field value (from CustomFieldValue entity via API) */
+export interface CustomFieldValue {
+ id: string
+ value: string
+ customField: CustomFieldDefinition
+}
+
+/** Merged definition + value for form display and editing */
+export interface CustomFieldInput {
+ customFieldId: string | null
+ customFieldValueId: string | null
+ name: string
+ type: string
+ required: boolean
+ options: string[]
+ defaultValue: string | null
+ orderIndex: number
+ machineContextOnly: boolean
+ value: string
+}
+
+// ---------------------------------------------------------------------------
+// Normalization — accept any shape, return canonical types
+// ---------------------------------------------------------------------------
+
+const ALLOWED_TYPES = ['text', 'number', 'select', 'boolean', 'date'] as const
+
+/**
+ * Normalize any raw field definition object into a CustomFieldDefinition.
+ * Handles both standard `{name, type}` and legacy `{key, value: {type}}` formats.
+ */
+export function normalizeDefinition(raw: any, fallbackIndex = 0): CustomFieldDefinition | null {
+ if (!raw || typeof raw !== 'object') return null
+
+ // Resolve name: standard → legacy key → label
+ const name = (
+ typeof raw.name === 'string' ? raw.name.trim() :
+ typeof raw.key === 'string' ? raw.key.trim() :
+ typeof raw.label === 'string' ? raw.label.trim() :
+ ''
+ )
+ if (!name) return null
+
+ // Resolve type: standard → nested in value → fallback
+ const rawType = (
+ typeof raw.type === 'string' ? raw.type :
+ typeof raw.value?.type === 'string' ? raw.value.type :
+ 'text'
+ ).toLowerCase()
+ const type = ALLOWED_TYPES.includes(rawType as any) ? rawType : 'text'
+
+ // Resolve required
+ const required = typeof raw.required === 'boolean' ? raw.required
+ : typeof raw.value?.required === 'boolean' ? raw.value.required
+ : false
+
+ // Resolve options
+ const optionSource = Array.isArray(raw.options) ? raw.options
+ : Array.isArray(raw.value?.options) ? raw.value.options
+ : []
+ const options = optionSource
+ .map((o: any) => typeof o === 'string' ? o.trim() : typeof o?.value === 'string' ? o.value.trim() : String(o ?? '').trim())
+ .filter((o: string) => o.length > 0 && o !== '[object Object]')
+
+ // Resolve defaultValue
+ const dv = raw.defaultValue ?? raw.value?.defaultValue ?? null
+ const defaultValue = dv !== null && dv !== undefined && dv !== '' ? String(dv) : null
+
+ // Resolve orderIndex
+ const orderIndex = typeof raw.orderIndex === 'number' ? raw.orderIndex : fallbackIndex
+
+ // Resolve machineContextOnly
+ const machineContextOnly = !!raw.machineContextOnly
+
+ // Resolve id
+ const id = typeof raw.id === 'string' ? raw.id
+ : typeof raw.customFieldId === 'string' ? raw.customFieldId
+ : null
+
+ return { id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }
+}
+
+/**
+ * Normalize a raw value entry into a CustomFieldValue.
+ * Accepts the API format: `{ id, value, customField: {...} }`
+ */
+export function normalizeValue(raw: any): CustomFieldValue | null {
+ if (!raw || typeof raw !== 'object') return null
+ const cf = raw.customField
+ const definition = normalizeDefinition(cf)
+ if (!definition) return null
+ const id = typeof raw.id === 'string' ? raw.id : ''
+ const value = raw.value !== null && raw.value !== undefined ? String(raw.value) : ''
+ return { id, value, customField: definition }
+}
+
+/**
+ * Normalize an array of raw definitions into CustomFieldDefinition[].
+ */
+export function normalizeDefinitions(raw: any): CustomFieldDefinition[] {
+ if (!Array.isArray(raw)) return []
+ return raw
+ .map((item: any, i: number) => normalizeDefinition(item, i))
+ .filter((d: any): d is CustomFieldDefinition => d !== null)
+ .sort((a, b) => a.orderIndex - b.orderIndex)
+}
+
+/**
+ * Normalize an array of raw values into CustomFieldValue[].
+ */
+export function normalizeValues(raw: any): CustomFieldValue[] {
+ if (!Array.isArray(raw)) return []
+ return raw
+ .map((item: any) => normalizeValue(item))
+ .filter((v: any): v is CustomFieldValue => v !== null)
+}
+
+// ---------------------------------------------------------------------------
+// Merge — THE one merge function
+// ---------------------------------------------------------------------------
+
+/**
+ * Merge definitions from a ModelType with persisted values from an entity.
+ * Returns a CustomFieldInput[] ready for form display.
+ *
+ * Match strategy: by customField.id first, then by name (case-sensitive).
+ * When no value exists for a definition, uses defaultValue as initial value.
+ */
+export function mergeDefinitionsWithValues(
+ rawDefinitions: any,
+ rawValues: any,
+): CustomFieldInput[] {
+ const definitions = normalizeDefinitions(rawDefinitions)
+ const values = normalizeValues(rawValues)
+
+ // Build lookup maps for values
+ const valueById = new Map()
+ const valueByName = new Map()
+ for (const v of values) {
+ if (v.customField.id) valueById.set(v.customField.id, v)
+ valueByName.set(v.customField.name, v)
+ }
+
+ const matchedValueIds = new Set()
+ const matchedNames = new Set()
+
+ // 1. Map definitions to inputs, matching values
+ const result: CustomFieldInput[] = definitions.map((def) => {
+ const matched = (def.id ? valueById.get(def.id) : undefined) ?? valueByName.get(def.name)
+
+ if (matched) {
+ if (matched.id) matchedValueIds.add(matched.id)
+ matchedNames.add(def.name)
+ return {
+ customFieldId: def.id,
+ customFieldValueId: matched.id || null,
+ name: def.name,
+ type: def.type,
+ required: def.required,
+ options: def.options,
+ defaultValue: def.defaultValue,
+ orderIndex: def.orderIndex,
+ machineContextOnly: def.machineContextOnly,
+ value: matched.value,
+ }
+ }
+
+ // No value found — use defaultValue
+ return {
+ customFieldId: def.id,
+ customFieldValueId: null,
+ name: def.name,
+ type: def.type,
+ required: def.required,
+ options: def.options,
+ defaultValue: def.defaultValue,
+ orderIndex: def.orderIndex,
+ machineContextOnly: def.machineContextOnly,
+ value: def.defaultValue ?? '',
+ }
+ })
+
+ // 2. Add orphan values (have a value but no matching definition)
+ for (const v of values) {
+ if (matchedValueIds.has(v.id)) continue
+ if (matchedNames.has(v.customField.name)) continue
+
+ result.push({
+ customFieldId: v.customField.id,
+ customFieldValueId: v.id || null,
+ name: v.customField.name,
+ type: v.customField.type,
+ required: v.customField.required,
+ options: v.customField.options,
+ defaultValue: v.customField.defaultValue,
+ orderIndex: v.customField.orderIndex,
+ machineContextOnly: v.customField.machineContextOnly,
+ value: v.value,
+ })
+ }
+
+ return result.sort((a, b) => a.orderIndex - b.orderIndex)
+}
+
+// ---------------------------------------------------------------------------
+// Filter & sort
+// ---------------------------------------------------------------------------
+
+/** Filter fields by context: standalone hides machineContextOnly, machine shows only machineContextOnly */
+export function filterByContext(
+ fields: CustomFieldInput[],
+ context: 'standalone' | 'machine',
+): CustomFieldInput[] {
+ if (context === 'machine') return fields.filter((f) => f.machineContextOnly)
+ return fields.filter((f) => !f.machineContextOnly)
+}
+
+/** Sort fields by orderIndex */
+export function sortByOrder(fields: CustomFieldInput[]): CustomFieldInput[] {
+ return [...fields].sort((a, b) => a.orderIndex - b.orderIndex)
+}
+
+// ---------------------------------------------------------------------------
+// Display helpers
+// ---------------------------------------------------------------------------
+
+/** Format a field value for display (e.g. boolean → Oui/Non) */
+export function formatValueForDisplay(field: CustomFieldInput): string {
+ const raw = field.value ?? ''
+ if (field.type === 'boolean') {
+ const normalized = String(raw).toLowerCase()
+ if (normalized === 'true' || normalized === '1') return 'Oui'
+ if (normalized === 'false' || normalized === '0') return 'Non'
+ }
+ return raw || 'Non défini'
+}
+
+/** Whether a field has a displayable value */
+export function hasDisplayableValue(field: CustomFieldInput): boolean {
+ if (field.type === 'boolean') return field.value !== undefined && field.value !== null && field.value !== ''
+ return typeof field.value === 'string' && field.value.trim().length > 0
+}
+
+/** Stable key for v-for rendering */
+export function fieldKey(field: CustomFieldInput, index: number): string {
+ return field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
+}
+
+// ---------------------------------------------------------------------------
+// Persistence helpers
+// ---------------------------------------------------------------------------
+
+/** Whether a field should be persisted (non-empty value) */
+export function shouldPersist(field: CustomFieldInput): boolean {
+ if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
+ return typeof field.value === 'string' && field.value.trim() !== ''
+}
+
+/** Format value for save (trim, boolean coercion) */
+export function formatValueForSave(field: CustomFieldInput): string {
+ if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
+ return typeof field.value === 'string' ? field.value.trim() : ''
+}
+
+/** Check if all required fields are filled */
+export function requiredFieldsFilled(fields: CustomFieldInput[]): boolean {
+ return fields.every((field) => {
+ if (!field.required) return true
+ return shouldPersist(field)
+ })
+}
+```
+
+- [ ] **Step 2: Run lint**
+
+```bash
+cd frontend && npm run lint:fix
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/app/shared/utils/customFields.ts
+git commit -m "feat(custom-fields) : add unified pure-logic custom fields module"
+```
+
+---
+
+## Task 3: Create unified composable `useCustomFieldInputs.ts`
+
+**Files:**
+- Create: `frontend/app/composables/useCustomFieldInputs.ts`
+
+This replaces `useEntityCustomFields.ts` (181 lines) and the custom field parts of `useMachineDetailCustomFields.ts`.
+
+- [ ] **Step 1: Write the composable**
+
+```typescript
+/**
+ * Unified reactive custom field management composable.
+ *
+ * Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
+ * and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
+ */
+
+import { computed, type MaybeRef, toValue } from 'vue'
+import { useCustomFields } from '~/composables/useCustomFields'
+import { useToast } from '~/composables/useToast'
+import {
+ mergeDefinitionsWithValues,
+ filterByContext,
+ formatValueForSave,
+ shouldPersist,
+ requiredFieldsFilled,
+ type CustomFieldDefinition,
+ type CustomFieldValue,
+ type CustomFieldInput,
+} from '~/shared/utils/customFields'
+
+export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }
+
+export type CustomFieldEntityType =
+ | 'machine'
+ | 'composant'
+ | 'piece'
+ | 'product'
+ | 'machineComponentLink'
+ | 'machinePieceLink'
+
+export interface UseCustomFieldInputsOptions {
+ /** Custom field definitions (from ModelType structure or machine.customFields) */
+ definitions: MaybeRef
+ /** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
+ values: MaybeRef
+ /** Entity type for API upsert calls */
+ entityType: CustomFieldEntityType
+ /** Entity ID for API upsert calls */
+ entityId: MaybeRef
+ /** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
+ context?: 'standalone' | 'machine'
+}
+
+export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
+ const { entityType, context } = options
+ const {
+ updateCustomFieldValue: updateApi,
+ upsertCustomFieldValue,
+ } = useCustomFields()
+ const { showSuccess, showError } = useToast()
+
+ // Merged fields: definitions + values
+ const allFields = computed(() => {
+ const defs = toValue(options.definitions)
+ const vals = toValue(options.values)
+ return mergeDefinitionsWithValues(defs, vals)
+ })
+
+ // Filtered by context (standalone vs machine)
+ const fields = computed(() => {
+ if (!context) return allFields.value
+ return filterByContext(allFields.value, context)
+ })
+
+ // Validation
+ const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
+
+ // Update a single field value
+ const update = async (field: CustomFieldInput): Promise => {
+ const id = toValue(options.entityId)
+ if (!id) {
+ showError(`Impossible de sauvegarder le champ "${field.name}"`)
+ return false
+ }
+
+ const value = formatValueForSave(field)
+
+ // Update existing value
+ if (field.customFieldValueId) {
+ const result: any = await updateApi(field.customFieldValueId, { value })
+ if (result.success) {
+ showSuccess(`Champ "${field.name}" mis à jour`)
+ return true
+ }
+ showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
+ return false
+ }
+
+ // Create new value via upsert
+ if (!field.customFieldId) {
+ showError(`Impossible de sauvegarder le champ "${field.name}" (identifiant manquant)`)
+ return false
+ }
+
+ const result: any = await upsertCustomFieldValue(
+ field.customFieldId,
+ entityType,
+ id,
+ value,
+ )
+
+ if (result.success) {
+ // Update field with the newly created value ID
+ if (result.data?.id) {
+ field.customFieldValueId = result.data.id
+ }
+ showSuccess(`Champ "${field.name}" enregistré`)
+ return true
+ }
+
+ showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
+ return false
+ }
+
+ // Save all fields that have values
+ const saveAll = async (): Promise => {
+ const id = toValue(options.entityId)
+ if (!id) return ['(entity ID missing)']
+
+ const failed: string[] = []
+
+ for (const field of fields.value) {
+ if (!shouldPersist(field)) continue
+
+ const value = formatValueForSave(field)
+
+ if (field.customFieldValueId) {
+ const result: any = await updateApi(field.customFieldValueId, { value })
+ if (!result.success) failed.push(field.name)
+ continue
+ }
+
+ if (!field.customFieldId) {
+ failed.push(field.name)
+ continue
+ }
+
+ const result: any = await upsertCustomFieldValue(
+ field.customFieldId,
+ entityType,
+ id,
+ value,
+ )
+
+ if (result.success) {
+ if (result.data?.id) {
+ field.customFieldValueId = result.data.id
+ }
+ } else {
+ failed.push(field.name)
+ }
+ }
+
+ return failed
+ }
+
+ return {
+ /** All merged fields filtered by context */
+ fields,
+ /** All merged fields (unfiltered) */
+ allFields,
+ /** Whether all required fields have values */
+ requiredFilled,
+ /** Update a single field value via API */
+ update,
+ /** Save all fields with values, returns list of failed field names */
+ saveAll,
+ }
+}
+```
+
+- [ ] **Step 2: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/app/composables/useCustomFieldInputs.ts
+git commit -m "feat(custom-fields) : add unified useCustomFieldInputs composable"
+```
+
+---
+
+## Task 4: Migrate `CustomFieldInputGrid.vue` and `CustomFieldDisplay.vue`
+
+These are shared components used everywhere. Migrate them first so downstream consumers can use the new types.
+
+**Files:**
+- Modify: `frontend/app/components/common/CustomFieldInputGrid.vue`
+- Modify: `frontend/app/components/common/CustomFieldDisplay.vue`
+
+- [ ] **Step 1: Migrate `CustomFieldInputGrid.vue`**
+
+Replace the import from `customFieldFormUtils`:
+
+```typescript
+// OLD
+import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
+// NEW
+import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
+```
+
+- [ ] **Step 2: Migrate `CustomFieldDisplay.vue`**
+
+Replace all imports from `entityCustomFieldLogic` with the new module. Read the file first to identify the exact imports, then replace:
+
+```typescript
+// OLD
+import {
+ resolveFieldKey,
+ resolveFieldName,
+ resolveFieldType,
+ resolveFieldOptions,
+ resolveFieldReadOnly,
+ formatFieldDisplayValue,
+ resolveCustomFieldId,
+} from '~/shared/utils/entityCustomFieldLogic'
+
+// NEW
+import {
+ fieldKey,
+ formatValueForDisplay,
+ hasDisplayableValue,
+ type CustomFieldInput,
+} from '~/shared/utils/customFields'
+```
+
+Note: `CustomFieldDisplay.vue` accesses field properties directly (`.name`, `.type`, `.options`, `.value`). With the new typed `CustomFieldInput`, direct property access replaces the `resolveFieldXxx()` wrapper functions. Read the full file and adapt the template accordingly.
+
+- [ ] **Step 3: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue
+git commit -m "refactor(custom-fields) : migrate shared display components to unified module"
+```
+
+---
+
+## Task 5: Migrate standalone composables (`useComponentEdit`, `useComponentCreate`, `usePieceEdit`)
+
+**Files:**
+- Modify: `frontend/app/composables/useComponentEdit.ts`
+- Modify: `frontend/app/composables/useComponentCreate.ts`
+- Modify: `frontend/app/composables/usePieceEdit.ts`
+
+For each file:
+
+1. Read the full file to understand the current custom field logic
+2. Replace imports from `customFieldFormUtils` with the new module
+3. Replace `buildCustomFieldInputs(structure, values)` with `mergeDefinitionsWithValues(structure?.customFields, values)`
+4. Replace `requiredCustomFieldsFilled(customFieldInputs.value)` with `requiredFieldsFilled(fields)`
+5. Replace `saveCustomFieldValues(...)` with `saveAll()` from `useCustomFieldInputs`
+6. Replace `normalizeCustomFieldInputs(structure)` with `normalizeDefinitions(structure?.customFields)`
+
+- [ ] **Step 1: Migrate `useComponentEdit.ts`**
+
+Read the file. Replace the `customFieldFormUtils` imports and the `refreshCustomFieldInputs` / `buildCustomFieldInputs` calls with `useCustomFieldInputs`. The composable should receive `selectedTypeStructure.value?.customFields` as definitions and `component.value?.customFieldValues` as values.
+
+- [ ] **Step 2: Migrate `useComponentCreate.ts`**
+
+Same pattern. Definitions come from `selectedType.value?.structure?.customFields`, values are empty `[]` for creation.
+
+- [ ] **Step 3: Migrate `usePieceEdit.ts`**
+
+Same pattern. Definitions come from the piece type structure, values from `piece.customFieldValues`.
+
+- [ ] **Step 4: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 5: Verify**
+
+Open each page in the browser:
+- `/component/{id}` — check custom fields display and edit
+- `/component/create` — check custom fields with default values
+- `/pieces/{id}/edit` — check custom fields display and edit
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
+git commit -m "refactor(custom-fields) : migrate standalone composables to unified module"
+```
+
+---
+
+## Task 6: Migrate standalone pages (product + piece create)
+
+**Files:**
+- Modify: `frontend/app/pages/pieces/create.vue`
+- Modify: `frontend/app/pages/product/create.vue`
+- Modify: `frontend/app/pages/product/[id]/edit.vue`
+- Modify: `frontend/app/pages/product/[id]/index.vue`
+
+These pages import directly from `customFieldFormUtils`. Replace with the new module.
+
+- [ ] **Step 1: Read each file and identify all `customFieldFormUtils` imports**
+
+- [ ] **Step 2: For each page, replace imports**
+
+Replace:
+```typescript
+import { CustomFieldInput, normalizeCustomFieldInputs, buildCustomFieldInputs, requiredCustomFieldsFilled, saveCustomFieldValues } from '~/shared/utils/customFieldFormUtils'
+```
+With:
+```typescript
+import { type CustomFieldInput, normalizeDefinitions, mergeDefinitionsWithValues, requiredFieldsFilled } from '~/shared/utils/customFields'
+import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
+```
+
+Adapt the logic in each page to use `useCustomFieldInputs` or the pure helpers as appropriate.
+
+- [ ] **Step 3: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Verify**
+
+Open each page in the browser:
+- `/pieces/create` — check custom fields appear when selecting a type
+- `/product/create` — same
+- `/product/{id}/edit` — check fields display with values
+- `/product/{id}` — check read-only display
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/pages/pieces/create.vue frontend/app/pages/product/create.vue frontend/app/pages/product/\[id\]/edit.vue frontend/app/pages/product/\[id\]/index.vue
+git commit -m "refactor(custom-fields) : migrate product and piece pages to unified module"
+```
+
+---
+
+## Task 7: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields`
+
+**Files:**
+- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
+- Modify: `frontend/app/components/machine/MachineInfoCard.vue`
+- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts`
+
+- [ ] **Step 1: Migrate `MachineCustomFieldsCard.vue` and `MachineInfoCard.vue`**
+
+Replace:
+```typescript
+import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
+```
+With:
+```typescript
+import { formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
+```
+
+Replace all calls to `formatCustomFieldValue(field)` with `formatValueForDisplay(field)`.
+
+- [ ] **Step 2: Migrate `useMachineDetailCustomFields.ts`**
+
+This is the biggest migration. Read the full file. The custom field logic needs to be replaced:
+
+1. Replace imports from `customFieldUtils` with the new module
+2. Replace `syncMachineCustomFields()` — use `mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues)`
+3. Replace `transformComponentCustomFields()` and `transformCustomFields()` — for each component/piece in the hierarchy, use `mergeDefinitionsWithValues(type.customFields, entity.customFieldValues)` then `filterByContext(fields, 'standalone')`
+4. For context fields on links: `mergeDefinitionsWithValues(link.contextCustomFields, link.contextCustomFieldValues)`
+5. Replace `updateMachineCustomField()`, `saveAllMachineCustomFields()`, `saveAllContextCustomFields()` with `useCustomFieldInputs` instances or direct API calls from `useCustomFields`
+
+Keep the non-custom-field logic (constructeurs, products, transforms) in this file.
+
+- [ ] **Step 3: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Verify**
+
+Open a machine page (`/machine/{id}`) that has:
+- Machine-level custom fields
+- Components with regular custom fields
+- Components with machineContextOnly fields
+Check display, edit, and save for all three.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/components/machine/MachineCustomFieldsCard.vue frontend/app/components/machine/MachineInfoCard.vue frontend/app/composables/useMachineDetailCustomFields.ts
+git commit -m "refactor(custom-fields) : migrate machine page to unified module"
+```
+
+---
+
+## Task 8: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`)
+
+**Files:**
+- Modify: `frontend/app/components/ComponentItem.vue`
+- Modify: `frontend/app/components/PieceItem.vue`
+
+- [ ] **Step 1: Migrate `ComponentItem.vue`**
+
+Read the file. Replace:
+```typescript
+import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
+import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCustomFieldLogic'
+```
+With:
+```typescript
+import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
+import { fieldKey, formatValueForDisplay, hasDisplayableValue, type CustomFieldInput } from '~/shared/utils/customFields'
+```
+
+Replace `useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })` with `useCustomFieldInputs(...)`, passing the type's customFields as definitions and the entity's customFieldValues as values.
+
+Update the template to use the new field properties directly instead of `resolveFieldXxx()` functions.
+
+- [ ] **Step 2: Migrate `PieceItem.vue`**
+
+Same pattern as ComponentItem but with `entityType: 'piece'` and piece type fields.
+
+- [ ] **Step 3: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Verify**
+
+Open a machine page and expand components/pieces in the hierarchy. Check custom fields display correctly.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/components/ComponentItem.vue frontend/app/components/PieceItem.vue
+git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to unified module"
+```
+
+---
+
+## Task 9: Clean category editor files (`componentStructure*.ts`)
+
+**Files:**
+- Modify: `frontend/app/shared/model/componentStructure.ts`
+- Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
+- Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
+
+- [ ] **Step 1: Read the three files and identify custom field code**
+
+The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the `sanitizeCustomFields` and `hydrateCustomFields` functions, plus custom field handling in `normalizeStructureForEditor` and `normalizeStructureForSave`.
+
+- [ ] **Step 2: Replace custom field sanitize/hydrate with `normalizeDefinitions` from the unified module**
+
+In `componentStructure.ts`, replace the custom field handling in `normalizeStructureForEditor`:
+```typescript
+// OLD
+const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
+const customFields = sanitizedCustomFields.map((field) => { ... })
+// NEW
+import { normalizeDefinitions } from '~/shared/utils/customFields'
+const customFields = normalizeDefinitions(source.customFields)
+```
+
+Note: The category editor needs additional properties (`optionsText` for textarea display). These can be computed in the editor component itself rather than in the structure normalization.
+
+- [ ] **Step 3: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Verify**
+
+Open `/component-category/{id}/edit` — check that custom fields are displayed, can be added/removed/reordered, and save correctly.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts
+git commit -m "refactor(custom-fields) : clean category editor structure files"
+```
+
+---
+
+## Task 10: Delete old files + final cleanup
+
+**Files:**
+- Delete: `frontend/app/shared/utils/entityCustomFieldLogic.ts`
+- Delete: `frontend/app/shared/utils/customFieldUtils.ts`
+- Delete: `frontend/app/shared/utils/customFieldFormUtils.ts`
+- Delete: `frontend/app/composables/useEntityCustomFields.ts`
+- Delete or rewrite: `frontend/tests/shared/customFieldFormUtils.test.ts`
+
+- [ ] **Step 1: Verify no remaining imports of old files**
+
+```bash
+cd frontend && grep -r "entityCustomFieldLogic\|customFieldUtils\|customFieldFormUtils\|useEntityCustomFields" app/ --include="*.ts" --include="*.vue" -l
+```
+
+Expected: no results (0 files).
+
+- [ ] **Step 2: Delete old files**
+
+```bash
+rm frontend/app/shared/utils/entityCustomFieldLogic.ts
+rm frontend/app/shared/utils/customFieldUtils.ts
+rm frontend/app/shared/utils/customFieldFormUtils.ts
+rm frontend/app/composables/useEntityCustomFields.ts
+rm frontend/tests/shared/customFieldFormUtils.test.ts
+```
+
+- [ ] **Step 3: Run full lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+Expected: 0 errors.
+
+- [ ] **Step 4: Final smoke test**
+
+Test all 4 contexts in the browser:
+1. **Machine fields** — `/machine/{id}` → machine-level custom fields
+2. **Standalone entity** — `/component/{id}` → custom fields display and edit
+3. **Machine context** — `/machine/{id}` → expand a component → machineContextOnly fields
+4. **Category editor** — `/component-category/{id}/edit` → custom field definitions
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "refactor(custom-fields) : delete old parallel custom field modules"
+```
From 1348fa9963e18bf83f0f963b0c52a8e6936fe136 Mon Sep 17 00:00:00 2001
From: r-dev
Date: Sat, 4 Apr 2026 12:46:31 +0200
Subject: [PATCH 03/42] docs : update implementation plan with review fixes
Addresses 8 issues found by dual code review:
- Add readOnly + optionsText to CustomFieldInput type
- Replace computed with mutable ref + refresh() in composable
- Add metadata fallback for fields without customFieldId
- Add onValueCreated callback to keep parent reactive state in sync
- Merge Task 4+5 to avoid type mismatch in intermediate state
- Detail surgical refactoring of transformComponentCustomFields
- Define data contract for ComponentItem/PieceItem (pre-merged)
- Fix hasDisplayableValue to handle readOnly fields
Co-Authored-By: Claude Opus 4.6 (1M context)
---
...2026-04-04-custom-fields-simplification.md | 254 ++++++++++--------
1 file changed, 143 insertions(+), 111 deletions(-)
diff --git a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
index bcd70d8..9374ccb 100644
--- a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
+++ b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
@@ -129,6 +129,9 @@ export interface CustomFieldInput {
orderIndex: number
machineContextOnly: boolean
value: string
+ readOnly?: boolean
+ /** options joined by newline — used by category editor textareas (v-model) */
+ optionsText?: string
}
// ---------------------------------------------------------------------------
@@ -260,6 +263,8 @@ export function mergeDefinitionsWithValues(
const result: CustomFieldInput[] = definitions.map((def) => {
const matched = (def.id ? valueById.get(def.id) : undefined) ?? valueByName.get(def.name)
+ const optionsText = def.options.length ? def.options.join('\n') : undefined
+
if (matched) {
if (matched.id) matchedValueIds.add(matched.id)
matchedNames.add(def.name)
@@ -274,6 +279,7 @@ export function mergeDefinitionsWithValues(
orderIndex: def.orderIndex,
machineContextOnly: def.machineContextOnly,
value: matched.value,
+ optionsText,
}
}
@@ -289,6 +295,7 @@ export function mergeDefinitionsWithValues(
orderIndex: def.orderIndex,
machineContextOnly: def.machineContextOnly,
value: def.defaultValue ?? '',
+ optionsText,
}
})
@@ -297,6 +304,7 @@ export function mergeDefinitionsWithValues(
if (matchedValueIds.has(v.id)) continue
if (matchedNames.has(v.customField.name)) continue
+ const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
result.push({
customFieldId: v.customField.id,
customFieldValueId: v.id || null,
@@ -308,6 +316,7 @@ export function mergeDefinitionsWithValues(
orderIndex: v.customField.orderIndex,
machineContextOnly: v.customField.machineContextOnly,
value: v.value,
+ optionsText: orphanOptionsText,
})
}
@@ -347,8 +356,9 @@ export function formatValueForDisplay(field: CustomFieldInput): string {
return raw || 'Non défini'
}
-/** Whether a field has a displayable value */
+/** Whether a field has a displayable value (readOnly fields always display) */
export function hasDisplayableValue(field: CustomFieldInput): boolean {
+ if (field.readOnly) return true
if (field.type === 'boolean') return field.value !== undefined && field.value !== null && field.value !== ''
return typeof field.value === 'string' && field.value.trim().length > 0
}
@@ -413,9 +423,14 @@ This replaces `useEntityCustomFields.ts` (181 lines) and the custom field parts
*
* Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
* and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
+ *
+ * DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
+ * save operations can update `customFieldValueId` in place without being
+ * overwritten on the next reactivity cycle. Call `refresh()` to re-merge
+ * from the source definitions + values (e.g. after fetching fresh data).
*/
-import { computed, type MaybeRef, toValue } from 'vue'
+import { ref, watch, computed, type MaybeRef, toValue } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
@@ -450,6 +465,8 @@ export interface UseCustomFieldInputsOptions {
entityId: MaybeRef
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
context?: 'standalone' | 'machine'
+ /** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
+ onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
}
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
@@ -460,22 +477,40 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
} = useCustomFields()
const { showSuccess, showError } = useToast()
- // Merged fields: definitions + values
- const allFields = computed(() => {
+ // Internal mutable state — NOT a computed, so save can mutate in place
+ const _allFields = ref([])
+
+ // Re-merge from source definitions + values
+ const refresh = () => {
const defs = toValue(options.definitions)
const vals = toValue(options.values)
- return mergeDefinitionsWithValues(defs, vals)
- })
+ _allFields.value = mergeDefinitionsWithValues(defs, vals)
+ }
+
+ // Auto-refresh when reactive sources change
+ watch(
+ () => [toValue(options.definitions), toValue(options.values)],
+ () => refresh(),
+ { immediate: true, deep: true },
+ )
// Filtered by context (standalone vs machine)
const fields = computed(() => {
- if (!context) return allFields.value
- return filterByContext(allFields.value, context)
+ if (!context) return _allFields.value
+ return filterByContext(_allFields.value, context)
})
// Validation
const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
+ // Build metadata for upsert when no customFieldId is available (legacy fallback)
+ const _buildMetadata = (field: CustomFieldInput) => ({
+ customFieldName: field.name,
+ customFieldType: field.type,
+ customFieldRequired: field.required,
+ customFieldOptions: field.options,
+ })
+
// Update a single field value
const update = async (field: CustomFieldInput): Promise => {
const id = toValue(options.entityId)
@@ -497,24 +532,28 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
return false
}
- // Create new value via upsert
- if (!field.customFieldId) {
- showError(`Impossible de sauvegarder le champ "${field.name}" (identifiant manquant)`)
- return false
- }
-
+ // Create new value via upsert — with metadata fallback when no ID
+ const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
+ metadata,
)
if (result.success) {
- // Update field with the newly created value ID
+ // Mutate in place (safe — _allFields is a ref, not computed)
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
+ if (result.data?.customField?.id) {
+ field.customFieldId = result.data.customField.id
+ }
+ // Notify parent to update its reactive source
+ if (options.onValueCreated && result.data) {
+ options.onValueCreated(result.data)
+ }
showSuccess(`Champ "${field.name}" enregistré`)
return true
}
@@ -541,22 +580,26 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
continue
}
- if (!field.customFieldId) {
- failed.push(field.name)
- continue
- }
-
+ // Upsert with metadata fallback when no customFieldId
+ const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
+ metadata,
)
if (result.success) {
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
+ if (result.data?.customField?.id) {
+ field.customFieldId = result.data.customField.id
+ }
+ if (options.onValueCreated && result.data) {
+ options.onValueCreated(result.data)
+ }
} else {
failed.push(field.name)
}
@@ -569,13 +612,15 @@ export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
/** All merged fields filtered by context */
fields,
/** All merged fields (unfiltered) */
- allFields,
+ allFields: _allFields,
/** Whether all required fields have values */
requiredFilled,
/** Update a single field value via API */
update,
/** Save all fields with values, returns list of failed field names */
saveAll,
+ /** Re-merge from source definitions + values (call after fetching fresh data) */
+ refresh,
}
}
```
@@ -595,18 +640,20 @@ git commit -m "feat(custom-fields) : add unified useCustomFieldInputs composable
---
-## Task 4: Migrate `CustomFieldInputGrid.vue` and `CustomFieldDisplay.vue`
+## Task 4: Migrate shared components + standalone composables (atomic batch)
-These are shared components used everywhere. Migrate them first so downstream consumers can use the new types.
+**Why batched:** `CustomFieldInputGrid.vue` and `CustomFieldDisplay.vue` receive fields from the composables. Migrating them separately would cause TypeScript errors in the intermediate state. Migrate all together.
**Files:**
- Modify: `frontend/app/components/common/CustomFieldInputGrid.vue`
- Modify: `frontend/app/components/common/CustomFieldDisplay.vue`
+- Modify: `frontend/app/composables/useComponentEdit.ts`
+- Modify: `frontend/app/composables/useComponentCreate.ts`
+- Modify: `frontend/app/composables/usePieceEdit.ts`
- [ ] **Step 1: Migrate `CustomFieldInputGrid.vue`**
-Replace the import from `customFieldFormUtils`:
-
+Replace the import:
```typescript
// OLD
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
@@ -616,97 +663,48 @@ import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
- [ ] **Step 2: Migrate `CustomFieldDisplay.vue`**
-Replace all imports from `entityCustomFieldLogic` with the new module. Read the file first to identify the exact imports, then replace:
+Replace all imports from `entityCustomFieldLogic`. With the new typed `CustomFieldInput`, direct property access (`.name`, `.type`, `.options`, `.value`, `.readOnly`) replaces the `resolveFieldXxx()` wrapper functions. Read the full file and adapt the template accordingly.
-```typescript
-// OLD
-import {
- resolveFieldKey,
- resolveFieldName,
- resolveFieldType,
- resolveFieldOptions,
- resolveFieldReadOnly,
- formatFieldDisplayValue,
- resolveCustomFieldId,
-} from '~/shared/utils/entityCustomFieldLogic'
+- [ ] **Step 3: Migrate `useComponentEdit.ts`**
-// NEW
-import {
- fieldKey,
- formatValueForDisplay,
- hasDisplayableValue,
- type CustomFieldInput,
-} from '~/shared/utils/customFields'
-```
+Read the file. Key changes:
+1. Replace `customFieldFormUtils` imports with the new module
+2. Replace the imperative `refreshCustomFieldInputs` + `buildCustomFieldInputs` pattern with `useCustomFieldInputs`
+3. Pass `selectedTypeStructure.value?.customFields` as definitions and `component.value?.customFieldValues` as values
+4. Use the `onValueCreated` callback to push new values into `component.value.customFieldValues` so the reactive source stays in sync
+5. Replace calls to `refreshCustomFieldInputs()` in watchers/fetch with calls to `refresh()` from the composable
-Note: `CustomFieldDisplay.vue` accesses field properties directly (`.name`, `.type`, `.options`, `.value`). With the new typed `CustomFieldInput`, direct property access replaces the `resolveFieldXxx()` wrapper functions. Read the full file and adapt the template accordingly.
-
-- [ ] **Step 3: Run lint + typecheck**
-
-```bash
-cd frontend && npm run lint:fix && npx nuxi typecheck
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue
-git commit -m "refactor(custom-fields) : migrate shared display components to unified module"
-```
-
----
-
-## Task 5: Migrate standalone composables (`useComponentEdit`, `useComponentCreate`, `usePieceEdit`)
-
-**Files:**
-- Modify: `frontend/app/composables/useComponentEdit.ts`
-- Modify: `frontend/app/composables/useComponentCreate.ts`
-- Modify: `frontend/app/composables/usePieceEdit.ts`
-
-For each file:
-
-1. Read the full file to understand the current custom field logic
-2. Replace imports from `customFieldFormUtils` with the new module
-3. Replace `buildCustomFieldInputs(structure, values)` with `mergeDefinitionsWithValues(structure?.customFields, values)`
-4. Replace `requiredCustomFieldsFilled(customFieldInputs.value)` with `requiredFieldsFilled(fields)`
-5. Replace `saveCustomFieldValues(...)` with `saveAll()` from `useCustomFieldInputs`
-6. Replace `normalizeCustomFieldInputs(structure)` with `normalizeDefinitions(structure?.customFields)`
-
-- [ ] **Step 1: Migrate `useComponentEdit.ts`**
-
-Read the file. Replace the `customFieldFormUtils` imports and the `refreshCustomFieldInputs` / `buildCustomFieldInputs` calls with `useCustomFieldInputs`. The composable should receive `selectedTypeStructure.value?.customFields` as definitions and `component.value?.customFieldValues` as values.
-
-- [ ] **Step 2: Migrate `useComponentCreate.ts`**
+- [ ] **Step 4: Migrate `useComponentCreate.ts`**
Same pattern. Definitions come from `selectedType.value?.structure?.customFields`, values are empty `[]` for creation.
-- [ ] **Step 3: Migrate `usePieceEdit.ts`**
+- [ ] **Step 5: Migrate `usePieceEdit.ts`**
-Same pattern. Definitions come from the piece type structure, values from `piece.customFieldValues`.
+Same pattern. Definitions from piece type structure, values from `piece.customFieldValues`.
-- [ ] **Step 4: Run lint + typecheck**
+- [ ] **Step 6: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
-- [ ] **Step 5: Verify**
+- [ ] **Step 7: Verify**
Open each page in the browser:
- `/component/{id}` — check custom fields display and edit
- `/component/create` — check custom fields with default values
- `/pieces/{id}/edit` — check custom fields display and edit
-- [ ] **Step 6: Commit**
+- [ ] **Step 8: Commit**
```bash
-git add frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
-git commit -m "refactor(custom-fields) : migrate standalone composables to unified module"
+git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
+git commit -m "refactor(custom-fields) : migrate shared components and standalone composables to unified module"
```
---
-## Task 6: Migrate standalone pages (product + piece create)
+## Task 5: Migrate standalone pages (product + piece create)
**Files:**
- Modify: `frontend/app/pages/pieces/create.vue`
@@ -755,7 +753,7 @@ git commit -m "refactor(custom-fields) : migrate product and piece pages to unif
---
-## Task 7: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields`
+## Task 6: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields`
**Files:**
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
@@ -775,17 +773,43 @@ import { formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/cus
Replace all calls to `formatCustomFieldValue(field)` with `formatValueForDisplay(field)`.
-- [ ] **Step 2: Migrate `useMachineDetailCustomFields.ts`**
+- [ ] **Step 2: Migrate `useMachineDetailCustomFields.ts` — pure custom field functions**
-This is the biggest migration. Read the full file. The custom field logic needs to be replaced:
+Replace the following pure-CF functions (~168 lines) with the new module:
-1. Replace imports from `customFieldUtils` with the new module
-2. Replace `syncMachineCustomFields()` — use `mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues)`
-3. Replace `transformComponentCustomFields()` and `transformCustomFields()` — for each component/piece in the hierarchy, use `mergeDefinitionsWithValues(type.customFields, entity.customFieldValues)` then `filterByContext(fields, 'standalone')`
-4. For context fields on links: `mergeDefinitionsWithValues(link.contextCustomFields, link.contextCustomFieldValues)`
-5. Replace `updateMachineCustomField()`, `saveAllMachineCustomFields()`, `saveAllContextCustomFields()` with `useCustomFieldInputs` instances or direct API calls from `useCustomFields`
+| Old function (lines) | Replacement |
+|---|---|
+| `syncMachineCustomFields` (269-289) | `mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues)` |
+| `setMachineCustomFieldValue` (291-299) | Direct property mutation on the mutable `CustomFieldInput` |
+| `updateMachineCustomField` (302-363) | `useCustomFieldInputs.update()` |
+| `saveAllMachineCustomFields` (461-511) | `useCustomFieldInputs.saveAll()` |
+| `saveAllContextCustomFields` (430-459) | Loop over link-level `useCustomFieldInputs` instances |
-Keep the non-custom-field logic (constructeurs, products, transforms) in this file.
+- [ ] **Step 3: Migrate `useMachineDetailCustomFields.ts` — mixed transform functions**
+
+`transformCustomFields` (lines 71-158) and `transformComponentCustomFields` (lines 161-263) mix custom field merging with constructeur/product/document logic in a single `map()`. Refactor surgically:
+
+**Inside `transformCustomFields` map callback**, replace lines 82-106 (valueEntries + merge + dedupe + filter):
+```typescript
+// OLD: ~25 lines of valueEntries building, normalizeCustomFieldValueEntry, mergeCustomFieldValuesWithDefinitions, dedupeCustomFieldEntries
+// NEW: 2 lines
+const customFields = filterByContext(
+ mergeDefinitionsWithValues(type.customFields ?? typePiece.customFields ?? [], piece.customFieldValues ?? []),
+ 'standalone',
+)
+```
+
+Keep the rest of the map callback (constructeurs lines 108-133, product lines 119-120, assembly lines 135-158) unchanged.
+
+**Inside `transformComponentCustomFields` map callback**, replace lines 175-199 (same pattern):
+```typescript
+const customFields = filterByContext(
+ mergeDefinitionsWithValues(type.customFields ?? [], component.customFieldValues ?? actualComponent?.customFieldValues ?? []),
+ 'standalone',
+)
+```
+
+Keep recursive calls (lines 201-209) and constructeur/product/document logic (lines 212-263) unchanged.
- [ ] **Step 3: Run lint + typecheck**
@@ -810,7 +834,9 @@ git commit -m "refactor(custom-fields) : migrate machine page to unified module"
---
-## Task 8: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`)
+## Task 7: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`)
+
+**Data contract decision:** The parent (`useMachineDetailCustomFields.transformComponentCustomFields`) already pre-merges custom fields into `component.customFields` (a `CustomFieldInput[]`). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates.
**Files:**
- Modify: `frontend/app/components/ComponentItem.vue`
@@ -825,17 +851,21 @@ import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCus
```
With:
```typescript
-import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
+import { useCustomFields } from '~/composables/useCustomFields'
import { fieldKey, formatValueForDisplay, hasDisplayableValue, type CustomFieldInput } from '~/shared/utils/customFields'
```
-Replace `useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })` with `useCustomFieldInputs(...)`, passing the type's customFields as definitions and the entity's customFieldValues as values.
-
-Update the template to use the new field properties directly instead of `resolveFieldXxx()` functions.
+Key changes:
+1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 6)
+2. **For display:** use `hasDisplayableValue(field)` and `formatValueForDisplay(field)` instead of `resolveFieldXxx()` wrappers
+3. **For edits/saves:** use `useCustomFields()` directly (the HTTP layer) instead of `useEntityCustomFields().updateCustomField`
+4. **For context fields** (`component.contextCustomFields` + `component.contextCustomFieldValues`): merge locally with `mergeDefinitionsWithValues` — these are NOT pre-merged by the parent since they come as separate arrays
+5. **Replace `resolveCustomFieldId(field)`** with `field.customFieldId` (direct property access on `CustomFieldInput`)
+6. **Replace `resolveFieldId(field)`** with `field.customFieldValueId`
- [ ] **Step 2: Migrate `PieceItem.vue`**
-Same pattern as ComponentItem but with `entityType: 'piece'` and piece type fields.
+Same pattern as ComponentItem but with `entityType: 'piece'` and `props.piece.customFields`.
- [ ] **Step 3: Run lint + typecheck**
@@ -856,7 +886,7 @@ git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to
---
-## Task 9: Clean category editor files (`componentStructure*.ts`)
+## Task 8: Clean category editor files (`componentStructure*.ts`)
**Files:**
- Modify: `frontend/app/shared/model/componentStructure.ts`
@@ -875,11 +905,13 @@ In `componentStructure.ts`, replace the custom field handling in `normalizeStruc
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
const customFields = sanitizedCustomFields.map((field) => { ... })
// NEW
-import { normalizeDefinitions } from '~/shared/utils/customFields'
-const customFields = normalizeDefinitions(source.customFields)
+import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
+const customFields = mergeDefinitionsWithValues(source.customFields, [])
```
-Note: The category editor needs additional properties (`optionsText` for textarea display). These can be computed in the editor component itself rather than in the structure normalization.
+**`optionsText` is now included** in `CustomFieldInput` (added in the type definition). `mergeDefinitionsWithValues` already computes `optionsText` from `options.join('\n')`, so all category editor textareas (`v-model="field.optionsText"`) will work without changes.
+
+**Important:** `normalizeStructureForEditor` is also used by `useMachineDetailCustomFields.ts` (imported as `normalizeStructureForEditor` from `~/shared/modelUtils`). Make sure the change doesn't break the machine detail page — it should be fine since the new output includes the same `options` array plus the `optionsText` string.
- [ ] **Step 3: Run lint + typecheck**
@@ -900,7 +932,7 @@ git commit -m "refactor(custom-fields) : clean category editor structure files"
---
-## Task 10: Delete old files + final cleanup
+## Task 9: Delete old files + final cleanup
**Files:**
- Delete: `frontend/app/shared/utils/entityCustomFieldLogic.ts`
From f2eff89e00374960cac0abd205b2f5f79dd9dffe Mon Sep 17 00:00:00 2001
From: r-dev
Date: Sat, 4 Apr 2026 12:49:41 +0200
Subject: [PATCH 04/42] =?UTF-8?q?docs=20:=20fix=20task=20ordering=20?=
=?UTF-8?q?=E2=80=94=20category=20editor=20before=20machine=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
normalizeStructureForEditor is used by useMachineDetailCustomFields.
Must clean it (Task 6) before migrating the machine page (Task 7).
Co-Authored-By: Claude Opus 4.6 (1M context)
---
...2026-04-04-custom-fields-simplification.md | 113 +++++++++---------
1 file changed, 59 insertions(+), 54 deletions(-)
diff --git a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
index 9374ccb..956abae 100644
--- a/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
+++ b/docs/superpowers/plans/2026-04-04-custom-fields-simplification.md
@@ -753,7 +753,56 @@ git commit -m "refactor(custom-fields) : migrate product and piece pages to unif
---
-## Task 6: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields`
+## Task 6: Clean category editor files (`componentStructure*.ts`)
+
+**WHY BEFORE MACHINE PAGE:** `normalizeStructureForEditor` is used by `useMachineDetailCustomFields.ts`. If we change it after migrating the machine page, the machine page would break. So clean this first.
+
+**Files:**
+- Modify: `frontend/app/shared/model/componentStructure.ts`
+- Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
+- Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
+
+- [ ] **Step 1: Read the three files and identify custom field code**
+
+The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the `sanitizeCustomFields` and `hydrateCustomFields` functions, plus custom field handling in `normalizeStructureForEditor` and `normalizeStructureForSave`.
+
+- [ ] **Step 2: Replace custom field sanitize/hydrate with the unified module**
+
+In `componentStructure.ts`, replace the custom field handling in `normalizeStructureForEditor`:
+```typescript
+// OLD
+const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
+const customFields = sanitizedCustomFields.map((field) => { ... })
+// NEW
+import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
+const customFields = mergeDefinitionsWithValues(source.customFields, [])
+```
+
+**`optionsText` is now included** in `CustomFieldInput` (added in the type definition). `mergeDefinitionsWithValues` already computes `optionsText` from `options.join('\n')`, so all category editor textareas (`v-model="field.optionsText"`) will work without changes.
+
+- [ ] **Step 3: Run lint + typecheck**
+
+```bash
+cd frontend && npm run lint:fix && npx nuxi typecheck
+```
+
+- [ ] **Step 4: Verify TWO things**
+
+1. Open `/component-category/{id}/edit` — check that custom fields are displayed, can be added/removed/reordered, and save correctly.
+2. Open `/machine/{id}` — check that the machine page still works (it uses `normalizeStructureForEditor` via `useMachineDetailCustomFields.ts`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts
+git commit -m "refactor(custom-fields) : clean category editor structure files"
+```
+
+---
+
+## Task 7: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields`
+
+**Depends on:** Task 6 (category editor cleaned — `normalizeStructureForEditor` already uses new types)
**Files:**
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
@@ -811,13 +860,13 @@ const customFields = filterByContext(
Keep recursive calls (lines 201-209) and constructeur/product/document logic (lines 212-263) unchanged.
-- [ ] **Step 3: Run lint + typecheck**
+- [ ] **Step 4: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
-- [ ] **Step 4: Verify**
+- [ ] **Step 5: Verify**
Open a machine page (`/machine/{id}`) that has:
- Machine-level custom fields
@@ -825,7 +874,7 @@ Open a machine page (`/machine/{id}`) that has:
- Components with machineContextOnly fields
Check display, edit, and save for all three.
-- [ ] **Step 5: Commit**
+- [ ] **Step 6: Commit**
```bash
git add frontend/app/components/machine/MachineCustomFieldsCard.vue frontend/app/components/machine/MachineInfoCard.vue frontend/app/composables/useMachineDetailCustomFields.ts
@@ -834,9 +883,11 @@ git commit -m "refactor(custom-fields) : migrate machine page to unified module"
---
-## Task 7: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`)
+## Task 8: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`)
-**Data contract decision:** The parent (`useMachineDetailCustomFields.transformComponentCustomFields`) already pre-merges custom fields into `component.customFields` (a `CustomFieldInput[]`). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates.
+**Depends on:** Task 7 (machine page — parent pre-merges custom fields into `CustomFieldInput[]`)
+
+**Data contract:** The parent (`useMachineDetailCustomFields.transformComponentCustomFields`) already pre-merges custom fields into `component.customFields` (a `CustomFieldInput[]`). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates.
**Files:**
- Modify: `frontend/app/components/ComponentItem.vue`
@@ -852,11 +903,11 @@ import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCus
With:
```typescript
import { useCustomFields } from '~/composables/useCustomFields'
-import { fieldKey, formatValueForDisplay, hasDisplayableValue, type CustomFieldInput } from '~/shared/utils/customFields'
+import { fieldKey, formatValueForDisplay, hasDisplayableValue, mergeDefinitionsWithValues, type CustomFieldInput } from '~/shared/utils/customFields'
```
Key changes:
-1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 6)
+1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 7)
2. **For display:** use `hasDisplayableValue(field)` and `formatValueForDisplay(field)` instead of `resolveFieldXxx()` wrappers
3. **For edits/saves:** use `useCustomFields()` directly (the HTTP layer) instead of `useEntityCustomFields().updateCustomField`
4. **For context fields** (`component.contextCustomFields` + `component.contextCustomFieldValues`): merge locally with `mergeDefinitionsWithValues` — these are NOT pre-merged by the parent since they come as separate arrays
@@ -886,52 +937,6 @@ git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to
---
-## Task 8: Clean category editor files (`componentStructure*.ts`)
-
-**Files:**
-- Modify: `frontend/app/shared/model/componentStructure.ts`
-- Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
-- Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
-
-- [ ] **Step 1: Read the three files and identify custom field code**
-
-The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the `sanitizeCustomFields` and `hydrateCustomFields` functions, plus custom field handling in `normalizeStructureForEditor` and `normalizeStructureForSave`.
-
-- [ ] **Step 2: Replace custom field sanitize/hydrate with `normalizeDefinitions` from the unified module**
-
-In `componentStructure.ts`, replace the custom field handling in `normalizeStructureForEditor`:
-```typescript
-// OLD
-const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
-const customFields = sanitizedCustomFields.map((field) => { ... })
-// NEW
-import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
-const customFields = mergeDefinitionsWithValues(source.customFields, [])
-```
-
-**`optionsText` is now included** in `CustomFieldInput` (added in the type definition). `mergeDefinitionsWithValues` already computes `optionsText` from `options.join('\n')`, so all category editor textareas (`v-model="field.optionsText"`) will work without changes.
-
-**Important:** `normalizeStructureForEditor` is also used by `useMachineDetailCustomFields.ts` (imported as `normalizeStructureForEditor` from `~/shared/modelUtils`). Make sure the change doesn't break the machine detail page — it should be fine since the new output includes the same `options` array plus the `optionsText` string.
-
-- [ ] **Step 3: Run lint + typecheck**
-
-```bash
-cd frontend && npm run lint:fix && npx nuxi typecheck
-```
-
-- [ ] **Step 4: Verify**
-
-Open `/component-category/{id}/edit` — check that custom fields are displayed, can be added/removed/reordered, and save correctly.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts
-git commit -m "refactor(custom-fields) : clean category editor structure files"
-```
-
----
-
## Task 9: Delete old files + final cleanup
**Files:**
From 894d5220360d76d8f8cd4e1c0ddea0db243810fd Mon Sep 17 00:00:00 2001
From: r-dev
Date: Sat, 4 Apr 2026 13:09:27 +0200
Subject: [PATCH 05/42] refactor(custom-fields) : unify 3 parallel
implementations into 1 module
Replace ~2900 lines across 9 files with ~400 lines in 2 files:
- shared/utils/customFields.ts (types + pure helpers)
- composables/useCustomFieldInputs.ts (reactive composable)
Migrated all consumers:
- Backend: add defaultValue to API Platform serialization groups
- Standalone pages: component edit/create, piece edit/create, product edit/create/detail
- Machine page: MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields
- Hierarchy: ComponentItem, PieceItem
- Shared: CustomFieldDisplay, CustomFieldInputGrid
- Category editor: componentStructure.ts
Deleted:
- entityCustomFieldLogic.ts (335 lines)
- customFieldUtils.ts (440 lines)
- customFieldFormUtils.ts (404 lines)
- useEntityCustomFields.ts (181 lines)
- customFieldFormUtils.test.ts
Co-Authored-By: Claude Opus 4.6 (1M context)
---
frontend/app/components/ComponentItem.vue | 83 ++-
frontend/app/components/PieceItem.vue | 90 +++-
.../components/common/CustomFieldDisplay.vue | 60 +--
.../common/CustomFieldInputGrid.vue | 2 +-
.../machine/MachineCustomFieldsCard.vue | 4 +-
.../components/machine/MachineInfoCard.vue | 4 +-
.../app/composables/useComponentCreate.ts | 43 +-
frontend/app/composables/useComponentEdit.ts | 57 +-
.../app/composables/useCustomFieldInputs.ts | 205 +++++++
.../app/composables/useEntityCustomFields.ts | 181 -------
.../useMachineDetailCustomFields.ts | 120 +----
frontend/app/composables/usePieceEdit.ts | 75 +--
frontend/app/pages/component/[id]/index.vue | 2 +-
frontend/app/pages/piece/[id].vue | 2 +-
frontend/app/pages/pieces/create.vue | 39 +-
frontend/app/pages/product/[id]/edit.vue | 37 +-
frontend/app/pages/product/[id]/index.vue | 39 +-
frontend/app/pages/product/create.vue | 69 +--
.../app/shared/model/componentStructure.ts | 37 +-
.../app/shared/utils/customFieldFormUtils.ts | 404 --------------
frontend/app/shared/utils/customFieldUtils.ts | 440 ---------------
frontend/app/shared/utils/customFields.ts | 303 +++++++++++
.../shared/utils/entityCustomFieldLogic.ts | 335 ------------
.../tests/shared/customFieldFormUtils.test.ts | 508 ------------------
src/Entity/CustomField.php | 1 +
25 files changed, 861 insertions(+), 2279 deletions(-)
create mode 100644 frontend/app/composables/useCustomFieldInputs.ts
delete mode 100644 frontend/app/composables/useEntityCustomFields.ts
delete mode 100644 frontend/app/shared/utils/customFieldFormUtils.ts
delete mode 100644 frontend/app/shared/utils/customFieldUtils.ts
create mode 100644 frontend/app/shared/utils/customFields.ts
delete mode 100644 frontend/app/shared/utils/entityCustomFieldLogic.ts
delete mode 100644 frontend/tests/shared/customFieldFormUtils.test.ts
diff --git a/frontend/app/components/ComponentItem.vue b/frontend/app/components/ComponentItem.vue
index d21fed9..cd324bf 100644
--- a/frontend/app/components/ComponentItem.vue
+++ b/frontend/app/components/ComponentItem.vue
@@ -335,13 +335,8 @@ import {
} from '~/shared/utils/documentDisplayUtils'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
-import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
-import {
- mergeFieldDefinitionsWithValues,
- dedupeMergedFields,
- resolveCustomFieldId,
- resolveFieldId,
-} from '~/shared/utils/entityCustomFieldLogic'
+import { useCustomFields } from '~/composables/useCustomFields'
+import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const props = defineProps({
component: { type: Object, required: true },
@@ -377,25 +372,81 @@ const {
} = useEntityProductDisplay({ entity: () => props.component })
const {
- displayedCustomFields,
- updateCustomField: updateComponentCustomField,
-} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
+ updateCustomFieldValue: updateCustomFieldValueApi,
+ upsertCustomFieldValue,
+} = useCustomFields()
+const { showSuccess, showError } = useToast()
+// Parent already pre-merges standalone custom fields into props.component.customFields
+const displayedCustomFields = computed(() => {
+ const fields = props.component?.customFields
+ return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
+})
+
+const updateComponentCustomField = async (field) => {
+ if (!field || field.readOnly) return
+
+ const e = props.component
+ const fieldValueId = field.customFieldValueId
+
+ if (fieldValueId) {
+ const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
+ if (result.success) {
+ showSuccess(`Champ "${field.name}" mis à jour avec succès`)
+ } else {
+ showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
+ }
+ return
+ }
+
+ if (!e?.id) {
+ showError('Impossible de créer la valeur pour ce champ')
+ return
+ }
+
+ const metadata = field.customFieldId ? undefined : {
+ customFieldName: field.name,
+ customFieldType: field.type,
+ customFieldRequired: field.required,
+ customFieldOptions: field.options,
+ }
+ const result = await upsertCustomFieldValue(
+ field.customFieldId,
+ 'composant',
+ e.id,
+ field.value ?? '',
+ metadata,
+ )
+
+ if (result.success) {
+ const newValue = result.data
+ if (newValue?.id) {
+ field.customFieldValueId = newValue.id
+ field.value = newValue.value ?? field.value ?? ''
+ if (newValue.customField?.id) {
+ field.customFieldId = newValue.customField.id
+ }
+ }
+ showSuccess(`Champ "${field.name}" créé avec succès`)
+ } else {
+ showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
+ }
+}
+
+// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
- return dedupeMergedFields(
- mergeFieldDefinitionsWithValues(definitions, values),
- )
+ return mergeDefinitionsWithValues(definitions, values)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
- const customFieldId = resolveCustomFieldId(field)
- const customFieldValueId = resolveFieldId(field)
+ const customFieldId = field.customFieldId
+ const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
@@ -405,7 +456,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
- fieldName: field.name || field.customField?.name || 'Champ contextuel',
+ fieldName: field.name || 'Champ contextuel',
})
}
diff --git a/frontend/app/components/PieceItem.vue b/frontend/app/components/PieceItem.vue
index c715deb..1bb461d 100644
--- a/frontend/app/components/PieceItem.vue
+++ b/frontend/app/components/PieceItem.vue
@@ -319,16 +319,10 @@ import {
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
-import {
- resolveFieldId,
- resolveFieldReadOnly,
- resolveCustomFieldId,
- mergeFieldDefinitionsWithValues,
- dedupeMergedFields,
-} from '~/shared/utils/entityCustomFieldLogic'
+import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
+import { useCustomFields } from '~/composables/useCustomFields'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
-import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
piece: { type: Object, required: true },
@@ -392,25 +386,81 @@ const {
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
const {
- displayedCustomFields,
- updateCustomField,
-} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
+ updateCustomFieldValue: updateCustomFieldValueApi,
+ upsertCustomFieldValue,
+} = useCustomFields()
+const { showSuccess, showError } = useToast()
+// Parent already pre-merges standalone custom fields into props.piece.customFields
+const displayedCustomFields = computed(() => {
+ const fields = props.piece?.customFields
+ return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
+})
+
+const updateCustomField = async (field) => {
+ if (!field || field.readOnly) return
+
+ const e = props.piece
+ const fieldValueId = field.customFieldValueId
+
+ if (fieldValueId) {
+ const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
+ if (result.success) {
+ showSuccess(`Champ "${field.name}" mis à jour avec succès`)
+ } else {
+ showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
+ }
+ return
+ }
+
+ if (!e?.id) {
+ showError('Impossible de créer la valeur pour ce champ')
+ return
+ }
+
+ const metadata = field.customFieldId ? undefined : {
+ customFieldName: field.name,
+ customFieldType: field.type,
+ customFieldRequired: field.required,
+ customFieldOptions: field.options,
+ }
+ const result = await upsertCustomFieldValue(
+ field.customFieldId,
+ 'piece',
+ e.id,
+ field.value ?? '',
+ metadata,
+ )
+
+ if (result.success) {
+ const newValue = result.data
+ if (newValue?.id) {
+ field.customFieldValueId = newValue.id
+ field.value = newValue.value ?? field.value ?? ''
+ if (newValue.customField?.id) {
+ field.customFieldId = newValue.customField.id
+ }
+ }
+ showSuccess(`Champ "${field.name}" créé avec succès`)
+ } else {
+ showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
+ }
+}
+
+// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
- return dedupeMergedFields(
- mergeFieldDefinitionsWithValues(definitions, values),
- )
+ return mergeDefinitionsWithValues(definitions, values)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
- const customFieldId = resolveCustomFieldId(field)
- const customFieldValueId = resolveFieldId(field)
+ const customFieldId = field.customFieldId
+ const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
@@ -420,7 +470,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
- fieldName: field.name || field.customField?.name || 'Champ contextuel',
+ fieldName: field.name || 'Champ contextuel',
})
}
@@ -544,8 +594,8 @@ const handleProductChange = async (value) => {
// --- Custom field event handlers ---
const handleCustomFieldInput = (field, value) => {
- if (resolveFieldReadOnly(field)) return
- const fieldValueId = resolveFieldId(field)
+ if (field.readOnly) return
+ const fieldValueId = field.customFieldValueId
if (!fieldValueId) return
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
if (fieldValue) fieldValue.value = value
@@ -553,7 +603,7 @@ const handleCustomFieldInput = (field, value) => {
const handleCustomFieldBlur = async (field) => {
await updateCustomField(field)
- const cfId = field?.customFieldId || field?.customField?.id || null
+ const cfId = field?.customFieldId || null
if (cfId || field?.customFieldValueId) {
emit('custom-field-update', {
fieldId: cfId,
diff --git a/frontend/app/components/common/CustomFieldDisplay.vue b/frontend/app/components/common/CustomFieldDisplay.vue
index 5dcab65..ba6166f 100644
--- a/frontend/app/components/common/CustomFieldDisplay.vue
+++ b/frontend/app/components/common/CustomFieldDisplay.vue
@@ -9,15 +9,15 @@
@@ -102,9 +102,9 @@
placeholder="Nom affiché dans le catalogue"
required
>
-
+
@@ -143,9 +143,9 @@
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
-
+
@@ -255,7 +255,7 @@
-
@@ -89,9 +102,9 @@
placeholder="Nom affiché dans le catalogue"
required
>
-
+
@@ -118,10 +131,10 @@
-
+
@@ -141,9 +154,9 @@
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
-
+
@@ -246,7 +259,7 @@
-
diff --git a/frontend/app/pages/product/[id]/index.vue b/frontend/app/pages/product/[id]/index.vue
index 40134af..51de9e1 100644
--- a/frontend/app/pages/product/[id]/index.vue
+++ b/frontend/app/pages/product/[id]/index.vue
@@ -50,19 +50,32 @@
Catégorie de produit
-
+
La catégorie d'origine ne peut pas être modifiée depuis cette page.
-
+
@@ -81,9 +94,9 @@
placeholder="Nom affiché dans le catalogue"
required
>
-
+
@@ -104,9 +117,9 @@
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
-
+
@@ -200,9 +213,9 @@
-
+
@@ -273,6 +286,14 @@
:field-labels="historyFieldLabels"
/>
+
+
diff --git a/frontend/app/pages/product/[id]/index.vue b/frontend/app/pages/product/[id]/index.vue
index 1eed495..072d131 100644
--- a/frontend/app/pages/product/[id]/index.vue
+++ b/frontend/app/pages/product/[id]/index.vue
@@ -198,6 +198,8 @@
{{ structurePreview }}
+
+
From 14ed38704f04305a08aec5045c69a1a8abcc7175 Mon Sep 17 00:00:00 2001
From: r-dev
Date: Sat, 4 Apr 2026 17:29:39 +0200
Subject: [PATCH 33/42] feat(api) : add machine count to category related items
endpoint
---
frontend/app/pages/constructeurs.vue | 50 +++++-
.../ModelTypeRelatedItemsController.php | 142 ++++++++++++++++++
2 files changed, 191 insertions(+), 1 deletion(-)
create mode 100644 src/Controller/ModelTypeRelatedItemsController.php
diff --git a/frontend/app/pages/constructeurs.vue b/frontend/app/pages/constructeurs.vue
index eeef20f..95b2bdd 100644
--- a/frontend/app/pages/constructeurs.vue
+++ b/frontend/app/pages/constructeurs.vue
@@ -48,6 +48,39 @@
{{ formatDate(row.createdAt) }}
+
+
+ {{ stats[row.id].composantCount }}
+
+ —
+
+
+
+
+ {{ stats[row.id].pieceCount }}
+
+ —
+
+
+
+
+ {{ stats[row.id].machineCount }}
+
+ —
+
+
@@ -103,6 +136,7 @@ import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
+const api = useApi()
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError } = useToast()
@@ -112,12 +146,16 @@ const columns = [
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'createdAt', label: 'Date de création', sortable: true },
+ { key: 'composantCount', label: 'Composants', align: 'center' },
+ { key: 'pieceCount', label: 'Pièces', align: 'center' },
+ { key: 'machineCount', label: 'Machines', align: 'center' },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const searchTerm = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
+const stats = ref({})
const currentSort = computed(() => ({
field: sortKey.value,
@@ -236,5 +274,15 @@ const confirmDelete = async (constructeur) => {
}
}
-onMounted(() => loadConstructeurs())
+const loadStats = async () => {
+ const result = await api.get('/constructeurs/stats')
+ if (result.success && result.data) {
+ stats.value = result.data
+ }
+}
+
+onMounted(() => {
+ loadConstructeurs()
+ loadStats()
+})
diff --git a/src/Controller/ModelTypeRelatedItemsController.php b/src/Controller/ModelTypeRelatedItemsController.php
new file mode 100644
index 0000000..cb13e43
--- /dev/null
+++ b/src/Controller/ModelTypeRelatedItemsController.php
@@ -0,0 +1,142 @@
+denyAccessUnlessGranted('ROLE_VIEWER');
+
+ $modelType = $this->modelTypes->find($id);
+
+ if (!$modelType) {
+ return new JsonResponse(
+ ['message' => 'Catégorie introuvable.'],
+ Response::HTTP_NOT_FOUND,
+ );
+ }
+
+ $items = match ($modelType->getCategory()) {
+ ModelCategory::COMPONENT => $this->fetchComposantsWithMachineCount($id),
+ ModelCategory::PIECE => $this->fetchPiecesWithMachineCount($id),
+ ModelCategory::PRODUCT => $this->fetchProductsWithMachineCount($id),
+ };
+
+ return new JsonResponse($items);
+ }
+
+ /**
+ * @return list
+ */
+ private function fetchComposantsWithMachineCount(string $modelTypeId): array
+ {
+ $qb = $this->em->createQueryBuilder();
+ $qb->select(
+ 'c.id',
+ 'c.name',
+ 'c.reference',
+ 'COALESCE(c.referenceAuto, c.reference) as displayReference',
+ 'COUNT(DISTINCT mcl.id) as machineCount',
+ )
+ ->from(Composant::class, 'c')
+ ->leftJoin(MachineComponentLink::class, 'mcl', 'WITH', 'mcl.composant = c')
+ ->where('c.typeComposant = :modelTypeId')
+ ->setParameter('modelTypeId', $modelTypeId)
+ ->groupBy('c.id', 'c.name', 'c.reference', 'c.referenceAuto')
+ ->orderBy('c.name', 'ASC')
+ ;
+
+ return $this->formatResults($qb->getQuery()->getArrayResult());
+ }
+
+ /**
+ * @return list
+ */
+ private function fetchPiecesWithMachineCount(string $modelTypeId): array
+ {
+ $qb = $this->em->createQueryBuilder();
+ $qb->select(
+ 'p.id',
+ 'p.name',
+ 'p.reference',
+ 'COALESCE(p.referenceAuto, p.reference) as displayReference',
+ 'COUNT(DISTINCT mpl.id) as machineCount',
+ )
+ ->from(Piece::class, 'p')
+ ->leftJoin(MachinePieceLink::class, 'mpl', 'WITH', 'mpl.piece = p')
+ ->where('p.typePiece = :modelTypeId')
+ ->setParameter('modelTypeId', $modelTypeId)
+ ->groupBy('p.id', 'p.name', 'p.reference', 'p.referenceAuto')
+ ->orderBy('p.name', 'ASC')
+ ;
+
+ return $this->formatResults($qb->getQuery()->getArrayResult());
+ }
+
+ /**
+ * @return list
+ */
+ private function fetchProductsWithMachineCount(string $modelTypeId): array
+ {
+ $qb = $this->em->createQueryBuilder();
+ $qb->select(
+ 'pr.id',
+ 'pr.name',
+ 'pr.reference',
+ 'COUNT(DISTINCT mpl.id) as machineCount',
+ )
+ ->from(Product::class, 'pr')
+ ->leftJoin(MachineProductLink::class, 'mpl', 'WITH', 'mpl.product = pr')
+ ->where('pr.typeProduct = :modelTypeId')
+ ->setParameter('modelTypeId', $modelTypeId)
+ ->groupBy('pr.id', 'pr.name', 'pr.reference')
+ ->orderBy('pr.name', 'ASC')
+ ;
+
+ return $this->formatResults($qb->getQuery()->getArrayResult());
+ }
+
+ /**
+ * @param array> $rows
+ *
+ * @return list
+ */
+ private function formatResults(array $rows): array
+ {
+ return array_values(array_map(
+ static fn (array $row): array => [
+ 'id' => (string) $row['id'],
+ 'name' => (string) ($row['name'] ?? ''),
+ 'reference' => isset($row['displayReference']) && '' !== $row['displayReference']
+ ? (string) $row['displayReference']
+ : (isset($row['reference']) && '' !== $row['reference'] ? (string) $row['reference'] : null),
+ 'machineCount' => (int) $row['machineCount'],
+ ],
+ $rows,
+ ));
+ }
+}
From ef4e2088289f58ea98ab190959c59b4745bf6493 Mon Sep 17 00:00:00 2001
From: r-dev
Date: Sat, 4 Apr 2026 17:31:34 +0200
Subject: [PATCH 34/42] feat(ui) : enrich category related items modal with
machine counts and navigation links
---
.../model-types/RelatedItemsModal.vue | 93 +++++++------------
1 file changed, 34 insertions(+), 59 deletions(-)
diff --git a/frontend/app/components/model-types/RelatedItemsModal.vue b/frontend/app/components/model-types/RelatedItemsModal.vue
index 646d76c..01f9c0c 100644
--- a/frontend/app/components/model-types/RelatedItemsModal.vue
+++ b/frontend/app/components/model-types/RelatedItemsModal.vue
@@ -31,16 +31,28 @@
:key="entry.id"
class="px-2 py-1"
>
-
- {{ entry.name }}
-
- Référence: {{ entry.reference }}
-
-
+
+
+ {{ entry.name }}
+
+
+ Référence: {{ entry.reference }}
+
+
+
+
+ {{ entry.machineCount }} machine{{ entry.machineCount > 1 ? 's' : '' }}
+
+ Aucune machine
+
+
@@ -57,14 +69,13 @@
diff --git a/frontend/app/pages/component/[id]/edit.vue b/frontend/app/pages/component/[id]/edit.vue
deleted file mode 100644
index 8cb830c..0000000
--- a/frontend/app/pages/component/[id]/edit.vue
+++ /dev/null
@@ -1,452 +0,0 @@
-
-
-
-
-
-
-
-
Chargement du composant…
-
-
-
-
-
-
Composant introuvable
-
- Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
-
-
-
-
- Retour au catalogue
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Sous-composants
-
-
-
- setSubcomponentSlotSelection(slot.slotId, value)"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- Téléversement des documents en cours…
-
-
- Chargement des documents en cours…
-
-
-
-
-
-
-
-
-
-
- Annuler
-
- { await submitEdition(); versionRefreshKey++ }">
-
- Enregistrer les modifications
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/app/pages/component/create.vue b/frontend/app/pages/component/create.vue
index 61b4c56..8b2e005 100644
--- a/frontend/app/pages/component/create.vue
+++ b/frontend/app/pages/component/create.vue
@@ -1,212 +1,240 @@
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
-
-
-
-
- Sélection des éléments du squelette
-
-
- Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sélection des éléments du squelette
+
+
+ Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
+
+
+
+ {{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
+
+
+
+
+
+ Chargement du catalogue de pièces, produits et composants…
+
+
+
+ Impossible de générer les emplacements définis par le squelette.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Téléversement des documents en cours…
-
- {{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
-
-
+
-
-
- Chargement du catalogue de pièces, produits et composants…
-
-
-
- Impossible de générer les emplacements définis par le squelette.
-
-
-
-
-
-