Files
Inventory/docs/superpowers/plans/2026-03-23-parc-machines-ux.md
Matthieu be859e57db refactor : rename Inventory_frontend to frontend in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:19 +02:00

11 KiB

Parc Machines UX Improvements — 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: Multi-select site filter with checkboxes, alphabetical sorting on Parc Machines, and OR search (name/reference) on catalog pages.

Architecture: Frontend-only changes for tasks 1-2 (Vue reactivity + computed sort). Backend Doctrine Extension for task 3 that intercepts ?q= parameter and builds an OR clause across name and reference fields, with corresponding frontend composable changes.

Tech Stack: Vue 3 (reactive Set), DaisyUI 5 checkboxes, Symfony/API Platform Doctrine ORM Extension, PHPUnit

Spec: docs/superpowers/specs/2026-03-23-parc-machines-ux-design.md


Task 1: Multi-select site checkboxes on Parc Machines

Files:

  • Modify: frontend/app/pages/machines/index.vue

  • Step 1: Replace selectedSite ref with reactive Set

In <script setup>, replace:

const selectedSite = ref('')

with:

const selectedSites = reactive(new Set())
  • Step 2: Replace <select> with checkboxes in template

Replace the site filter <div class="form-control"> block (the one containing the <select>) with:

<div class="form-control">
  <label class="label">
    <span class="label-text">Sites</span>
  </label>
  <div class="flex flex-wrap gap-3">
    <label
      v-for="site in sites"
      :key="site.id"
      class="flex items-center gap-2 cursor-pointer"
    >
      <input
        type="checkbox"
        class="checkbox checkbox-sm"
        :checked="selectedSites.has(site.id)"
        @change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
      >
      <span class="text-sm">{{ site.name }}</span>
    </label>
  </div>
</div>
  • Step 3: Update filteredMachines computed for multi-select

Replace:

if (selectedSite.value) {
  filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
}

with:

if (selectedSites.size > 0) {
  filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
  • Step 4: Clean up unused ref import if needed

Check if ref is still used elsewhere in the file (it is — searchQuery uses it). If so, keep it. Remove only if no longer referenced.

  • Step 5: Add reactive to imports

Add reactive to the import from vue:

import { ref, reactive, computed, onMounted } from 'vue'
  • Step 6: Verify in browser

Open http://localhost:3001/machines. Confirm:

  • Checkboxes appear for each site

  • Checking one site filters machines to that site only

  • Checking multiple sites shows machines from all selected sites

  • Unchecking all shows all machines

  • Step 7: Run frontend lint

Run: cd frontend && npm run lint:fix


Task 2: Alphabetical sorting on Parc Machines

Files:

  • Modify: frontend/app/pages/machines/index.vue

  • Step 1: Add sort to filteredMachines computed

At the end of the filteredMachines computed, just before return filtered, add:

filtered = [...filtered].sort((a, b) =>
  (a.name || '').localeCompare(b.name || '', 'fr')
)

The full computed should now be:

const filteredMachines = computed(() => {
  let filtered = enrichedMachines.value

  if (selectedSites.size > 0) {
    filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
  }

  if (searchQuery.value.trim()) {
    const term = searchQuery.value.trim().toLowerCase()
    filtered = filtered.filter(machine =>
      machine.name?.toLowerCase().includes(term)
      || machine.reference?.toLowerCase().includes(term),
    )
  }

  filtered = [...filtered].sort((a, b) =>
    (a.name || '').localeCompare(b.name || '', 'fr')
  )

  return filtered
})
  • Step 2: Verify in browser

Open http://localhost:3001/machines. Confirm machines are sorted A→Z by name. Test with site filter active — should still be sorted.

  • Step 3: Commit Tasks 1 + 2
cd frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"

Files:

  • Create: src/Doctrine/SearchByNameOrReferenceExtension.php

  • Step 1: Add reference parameter to createComposant factory

In tests/AbstractApiTestCase.php, update the createComposant method to accept an optional $reference parameter:

Find:

protected function createComposant(string $name = 'Composant Test', ?ModelType $type = null): Composant
{
    $c = new Composant();
    $c->setName($name);
    if (null !== $type) {
        $c->setTypeComposant($type);
    }

Replace with:

protected function createComposant(string $name = 'Composant Test', ?string $reference = null, ?ModelType $type = null): Composant
{
    $c = new Composant();
    $c->setName($name);
    if (null !== $reference) {
        $c->setReference($reference);
    }
    if (null !== $type) {
        $c->setTypeComposant($type);
    }
  • Step 2: Write failing tests for OR search

Add new test methods in tests/Api/FilterTest.php:

public function testOrSearchByNameOnPieces(): void
{
    $this->createPiece('Joint torique', 'REF-JT-001');
    $this->createPiece('Roulement', 'REF-RL-002');

    $client = $this->createViewerClient();
    $client->request('GET', '/api/pieces?q=joint');

    $this->assertResponseIsSuccessful();
    $this->assertJsonContains(['totalItems' => 1]);
}

public function testOrSearchByReferenceOnPieces(): void
{
    $this->createPiece('Joint torique', 'REF-JT-001');
    $this->createPiece('Roulement', 'REF-RL-002');

    $client = $this->createViewerClient();
    $client->request('GET', '/api/pieces?q=RL-002');

    $this->assertResponseIsSuccessful();
    $this->assertJsonContains(['totalItems' => 1]);
}

public function testOrSearchMatchesBothNameAndReference(): void
{
    $this->createComposant('Pompe REF-X', 'REF-POMPE-01');
    $this->createComposant('Vanne', 'REF-VANNE-01');
    $this->createComposant('Moteur', 'POMPE-MOTEUR');

    $client = $this->createViewerClient();
    $client->request('GET', '/api/composants?q=pompe');

    $this->assertResponseIsSuccessful();
    // Matches "Pompe REF-X" (name) and "Moteur" (reference contains POMPE)
    $this->assertJsonContains(['totalItems' => 2]);
}

public function testOrSearchEmptyQueryReturnsAll(): void
{
    $this->createProduct('Produit A', 'REF-A');
    $this->createProduct('Produit B', 'REF-B');

    $client = $this->createViewerClient();
    $client->request('GET', '/api/products?q=');

    $this->assertResponseIsSuccessful();
    $data = $client->getResponse()->toArray();
    $this->assertGreaterThanOrEqual(2, $data['totalItems']);
}

public function testOrSearchOnProducts(): void
{
    $this->createProduct('Huile moteur', 'HM-500');
    $this->createProduct('Graisse', 'GR-100');

    $client = $this->createViewerClient();
    $client->request('GET', '/api/products?q=HM-500');

    $this->assertResponseIsSuccessful();
    $this->assertJsonContains(['totalItems' => 1]);
}
  • Step 3: Run tests to verify they fail

Run: make test FILES=tests/Api/FilterTest.php Expected: New tests fail (the q parameter is not handled yet).

  • Step 4: Create the Doctrine Extension

Create src/Doctrine/SearchByNameOrReferenceExtension.php:

<?php

declare(strict_types=1);

namespace App\Doctrine;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Composant;
use App\Entity\Piece;
use App\Entity\Product;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;

final class SearchByNameOrReferenceExtension implements QueryCollectionExtensionInterface
{
    private const SUPPORTED_CLASSES = [
        Piece::class,
        Composant::class,
        Product::class,
    ];

    public function __construct(
        private readonly RequestStack $requestStack,
    ) {}

    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        ?Operation $operation = null,
        array $context = [],
    ): void {
        if (!\in_array($resourceClass, self::SUPPORTED_CLASSES, true)) {
            return;
        }

        $request = $this->requestStack->getCurrentRequest();
        if (null === $request) {
            return;
        }

        $q = $request->query->get('q', '');
        if (!\is_string($q) || '' === trim($q)) {
            return;
        }

        $escaped = addcslashes(trim($q), '%_');
        $paramName = $queryNameGenerator->generateParameterName('searchQ');
        $alias = $queryBuilder->getRootAliases()[0];

        $queryBuilder
            ->andWhere(sprintf('LOWER(%s.name) LIKE :%s OR LOWER(%s.reference) LIKE :%s', $alias, $paramName, $alias, $paramName))
            ->setParameter($paramName, '%' . strtolower($escaped) . '%');
    }
}
  • Step 5: Run tests to verify they pass

Run: make test FILES=tests/Api/FilterTest.php Expected: All tests pass, including the new OR search tests.

  • Step 6: Run full test suite

Run: make test Expected: All tests pass (no regressions).

  • Step 7: Run php-cs-fixer

Run: make php-cs-fixer-allow-risky

  • Step 8: Commit backend changes
git add src/Doctrine/SearchByNameOrReferenceExtension.php tests/Api/FilterTest.php tests/AbstractApiTestCase.php && git commit -m "feat(search) : OR search extension for name/reference on Piece, Composant, Product"

Task 4: Frontend — Switch composables from name to q

Files:

  • Modify: frontend/app/composables/usePieces.ts

  • Modify: frontend/app/composables/useComposants.ts

  • Modify: frontend/app/composables/useProducts.ts

  • Step 1: Update usePieces.ts

In the loadPieces function, replace:

if (search && search.trim()) {
  params.set('name', search.trim())
}

with:

if (search && search.trim()) {
  params.set('q', search.trim())
}
  • Step 2: Update useComposants.ts

Same change in the loadComposants function:

params.set('name', search.trim())

params.set('q', search.trim())
  • Step 3: Update useProducts.ts

Same change in the loadProducts function:

params.set('name', search.trim())

params.set('q', search.trim())
  • Step 4: Run frontend lint

Run: cd frontend && npm run lint:fix

  • Step 5: Verify in browser

Open each catalog page and test search:

  • http://localhost:3001/pieces-catalog — search by name, then by reference
  • http://localhost:3001/component-catalog — search by name, then by reference
  • http://localhost:3001/product-catalog — search by name, then by reference

Confirm that searching by a reference value returns the correct results.

  • Step 6: Commit frontend changes
cd frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference"
  • Step 7: Update submodule pointer in main repo
cd /home/matthieu/dev_malio/Inventory && git add frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"