# 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
  readOnly?: boolean
  /** options joined by newline — used by category editor textareas (v-model) */
  optionsText?: 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<string, CustomFieldValue>()
  const valueByName = new Map<string, CustomFieldValue>()
  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<string>()
  const matchedNames = new Set<string>()

  // 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)

    const optionsText = def.options.length ? def.options.join('\n') : undefined

    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,
        optionsText,
      }
    }

    // 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 ?? '',
      optionsText,
    }
  })

  // 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

    const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
    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,
      optionsText: orphanOptionsText,
    })
  }

  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 (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
}

/** 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.
 *
 * 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 { ref, watch, 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<any[]>
  /** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
  values: MaybeRef<any[]>
  /** Entity type for API upsert calls */
  entityType: CustomFieldEntityType
  /** Entity ID for API upsert calls */
  entityId: MaybeRef<string | null>
  /** 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) {
  const { entityType, context } = options
  const {
    updateCustomFieldValue: updateApi,
    upsertCustomFieldValue,
  } = useCustomFields()
  const { showSuccess, showError } = useToast()

  // Internal mutable state — NOT a computed, so save can mutate in place
  const _allFields = ref<CustomFieldInput[]>([])

  // Re-merge from source definitions + values
  const refresh = () => {
    const defs = toValue(options.definitions)
    const vals = toValue(options.values)
    _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<CustomFieldInput[]>(() => {
    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<boolean> => {
    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 — 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) {
      // 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
    }

    showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
    return false
  }

  // Save all fields that have values
  const saveAll = async (): Promise<string[]> => {
    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
      }

      // 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)
      }
    }

    return failed
  }

  return {
    /** All merged fields filtered by context */
    fields,
    /** All merged fields (unfiltered) */
    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,
  }
}
```

- [ ] **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 shared components + standalone composables (atomic batch)

**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:
```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 typed `CustomFieldInput`, direct property access (`.name`, `.type`, `.options`, `.value`, `.readOnly`) replaces the `resolveFieldXxx()` wrapper functions. Read the full file and adapt the template accordingly.

- [ ] **Step 3: Migrate `useComponentEdit.ts`**

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

- [ ] **Step 4: Migrate `useComponentCreate.ts`**

Same pattern. Definitions come from `selectedType.value?.structure?.customFields`, values are empty `[]` for creation.

- [ ] **Step 5: Migrate `usePieceEdit.ts`**

Same pattern. Definitions from piece type structure, values from `piece.customFieldValues`.

- [ ] **Step 6: Run lint + typecheck**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```

- [ ] **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 8: Commit**

```bash
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 5: 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 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`
- 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` — pure custom field functions**

Replace the following pure-CF functions (~168 lines) with the new module:

| 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 |

- [ ] **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 4: Run lint + typecheck**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```

- [ ] **Step 5: 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 6: 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`)

**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`
- 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 { useCustomFields } from '~/composables/useCustomFields'
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 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
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 `props.piece.customFields`.

- [ ] **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: 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"
```
