fix : code review — correct 15 issues across UX overhaul (phases 1-4)

Critical fixes:
- Make MigrateConstructeurLinks migration no-op (legacy tables already dropped)
- Add explicit ON CONFLICT (id) target in RestoreConstructeurLinks migration
- Replace N+1 queries with 4 bulk GROUP BY in ConstructeurStatsController
- Declare missing versionListRef template ref in machine detail page
- Add missing await on removeMachineDocument, cast activeTab as string

Important fixes:
- Add lang="ts" to ToastContainer and constructeurs page
- Type entityType as union in UsedInSection/useUsedIn
- Remove dead duration param from showError
- Update back-link props to new /catalogues/* URLs (3 pages)
- Replace raw error blocks with EmptyState in component/piece detail pages
- Type handleFillEntity params and machineInfoCardRef

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:26:05 +02:00
parent 8a841832b2
commit 244bfdc3e4
12 changed files with 92 additions and 136 deletions

View File

@@ -67,7 +67,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
@@ -77,7 +77,7 @@ import IconLucideInfo from '~icons/lucide/info'
const { toasts, removeToast } = useToast()
const getToastClasses = (type) => {
const getToastClasses = (type: ToastType) => {
switch (type) {
case 'success':
return 'alert-success text-success-content'

View File

@@ -38,7 +38,7 @@
<script setup lang="ts">
const props = defineProps<{
entityType: string
entityType: 'composants' | 'pieces' | 'products'
entityId: string | null
}>()

View File

@@ -56,8 +56,8 @@ export function useToast() {
return showToast(message, 'success', duration)
}
const showError = (message: string, duration = 5000): number => {
return showToast(message, 'error', duration)
const showError = (message: string): number => {
return showToast(message, 'error', 0)
}
const showWarning = (message: string, duration = 6000): number => {

View File

@@ -17,7 +17,7 @@ interface UsedInData {
pieces: UsedInEntity[]
}
export function useUsedIn(entityType: Ref<string>, entityId: Ref<string | null>) {
export function useUsedIn(entityType: Ref<'composants' | 'pieces' | 'products'>, entityId: Ref<string | null>) {
const data = ref<UsedInData>({ machines: [], composants: [], pieces: [] })
const loading = ref(false)

View File

@@ -18,19 +18,13 @@
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<EmptyState
v-else-if="!component"
title="Composant introuvable"
description="Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé."
action-label="Retour au catalogue"
action-to="/catalogues/composants"
/>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
@@ -39,7 +33,7 @@
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/component-catalog"
back-link="/catalogues/composants"
@toggle-edit="isEditMode = !isEditMode"
/>
@@ -129,6 +123,17 @@
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="component.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
<span class="font-mono font-semibold">{{ component.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</p>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || component.reference || constructeurLinks.length"

View File

@@ -124,7 +124,7 @@
</main>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'

View File

@@ -223,12 +223,13 @@ if (!machineId) {
}
const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref(null)
const machineInfoCardRef = ref<{ saveFieldDefinitions?: () => Promise<void> } | null>(null)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const { confirm: confirmDialog } = useConfirm()
const activeTab = ref(route.query.tab || 'general')
const versionListRef = ref<InstanceType<typeof EntityVersionList> | null>(null)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
@@ -306,7 +307,7 @@ const handleAddEntity = async (payload) => {
refreshVersions()
}
const handleFillEntity = (linkId, entityKind, modelTypeId) => {
const handleFillEntity = (linkId: string, entityKind: string, modelTypeId: string) => {
fillLinkId.value = linkId
fillTypeId.value = modelTypeId
addModalKind.value = entityKind
@@ -341,7 +342,7 @@ const confirmRemovePiece = async (id: string) => {
const confirmRemoveDocument = async (id: string) => {
if (!await confirmDialog({ title: 'Supprimer ce document ?', message: 'Le fichier sera supprimé définitivement.', confirmText: 'Supprimer', dangerous: true })) return
d.removeMachineDocument(id)
await d.removeMachineDocument(id)
}
onMounted(() => {

View File

@@ -18,19 +18,13 @@
<p class="text-sm text-base-content/70">Chargement de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<EmptyState
v-else-if="!piece"
title="Pièce introuvable"
description="Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée."
action-label="Retour au catalogue"
action-to="/catalogues/pieces"
/>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
@@ -39,7 +33,7 @@
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/pieces-catalog"
back-link="/catalogues/pieces"
@toggle-edit="isEditMode = !isEditMode"
/>

View File

@@ -39,7 +39,7 @@
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/product-catalog"
back-link="/catalogues/produits"
@toggle-edit="isEditMode = !isEditMode"
/>

View File

@@ -21,69 +21,13 @@ final class Version20260405_MigrateConstructeurLinks extends AbstractMigration
public function up(Schema $schema): void
{
// Composant links (a = composantId, b = constructeurId)
$this->addSql("
INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
SELECT
'clmig_cc_' || ROW_NUMBER() OVER (ORDER BY a, b),
a, b, NULL, NOW(), NOW()
FROM _composantconstructeurs
WHERE NOT EXISTS (
SELECT 1 FROM composant_constructeur_links
WHERE composantid = _composantconstructeurs.a
AND constructeurid = _composantconstructeurs.b
)
");
// Piece links
$this->addSql("
INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat)
SELECT
'clmig_pc_' || ROW_NUMBER() OVER (ORDER BY a, b),
a, b, NULL, NOW(), NOW()
FROM _piececonstructeurs
WHERE NOT EXISTS (
SELECT 1 FROM piece_constructeur_links
WHERE pieceid = _piececonstructeurs.a
AND constructeurid = _piececonstructeurs.b
)
");
// Machine links
$this->addSql("
INSERT INTO machine_constructeur_links (id, machineid, constructeurid, supplierreference, createdat, updatedat)
SELECT
'clmig_mc_' || ROW_NUMBER() OVER (ORDER BY a, b),
a, b, NULL, NOW(), NOW()
FROM _machineconstructeurs
WHERE NOT EXISTS (
SELECT 1 FROM machine_constructeur_links
WHERE machineid = _machineconstructeurs.a
AND constructeurid = _machineconstructeurs.b
)
");
// Product links
$this->addSql("
INSERT INTO product_constructeur_links (id, productid, constructeurid, supplierreference, createdat, updatedat)
SELECT
'clmig_prc_' || ROW_NUMBER() OVER (ORDER BY a, b),
a, b, NULL, NOW(), NOW()
FROM _productconstructeurs
WHERE NOT EXISTS (
SELECT 1 FROM product_constructeur_links
WHERE productid = _productconstructeurs.a
AND constructeurid = _productconstructeurs.b
)
");
// No-op: legacy M2M tables (_composantconstructeurs, _piececonstructeurs, etc.)
// were already dropped by Version20260331121257.
// The actual data restoration is handled by Version20260405_RestoreConstructeurLinksFromBackup.
}
public function down(Schema $schema): void
{
// Remove only the migrated rows (identifiable by 'clmig_' prefix)
$this->addSql("DELETE FROM composant_constructeur_links WHERE id LIKE 'clmig_cc_%'");
$this->addSql("DELETE FROM piece_constructeur_links WHERE id LIKE 'clmig_pc_%'");
$this->addSql("DELETE FROM machine_constructeur_links WHERE id LIKE 'clmig_mc_%'");
$this->addSql("DELETE FROM product_constructeur_links WHERE id LIKE 'clmig_prc_%'");
// No-op
}
}

View File

@@ -38,7 +38,7 @@ final class Version20260405_RestoreConstructeurLinksFromBackup extends AbstractM
('clbkp3_cc_003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, NOW(), NOW()),
('clbkp3_cc_004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_cc_005', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW())
ON CONFLICT DO NOTHING
ON CONFLICT (id) DO NOTHING
");
// === PIECE-CONSTRUCTEUR LINKS (25 from backup 3) ===
@@ -70,7 +70,7 @@ final class Version20260405_RestoreConstructeurLinksFromBackup extends AbstractM
('clbkp3_pc_023', 'cl6480b97f6516fba22ce86434', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_pc_024', 'cl9579b05774d92096117841b0', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_025', 'cl5b02c64fcc5ae8a3bfb6e5e6', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW())
ON CONFLICT DO NOTHING
ON CONFLICT (id) DO NOTHING
");
// Note: 6 additional Limatech piece links from backup (3) used old Limatech ID 'cla14aa4a50a799c2e54391be7'.
@@ -83,7 +83,7 @@ final class Version20260405_RestoreConstructeurLinksFromBackup extends AbstractM
('clbkp3_pc_027', 'clfd3cb41a407ab5a3f9d5baae', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_028', 'cld08dae22796b5855152580d9', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_029', 'clf6012fca41994c1e81ce2dba', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW())
ON CONFLICT DO NOTHING
ON CONFLICT (id) DO NOTHING
");
// Re-enable FK checks

View File

@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur;
use App\Entity\MachineConstructeurLink;
use App\Entity\PieceConstructeurLink;
use App\Entity\ProductConstructeurLink;
use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -19,7 +19,6 @@ final class ConstructeurStatsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ConstructeurRepository $constructeurs,
) {}
#[Route('/api/constructeurs/{id}/stats', name: 'api_constructeur_stats', methods: ['GET'])]
@@ -27,7 +26,7 @@ final class ConstructeurStatsController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$constructeur = $this->constructeurs->find($id);
$constructeur = $this->em->find(Constructeur::class, $id);
if (!$constructeur) {
return new JsonResponse(
['message' => 'Fournisseur introuvable.'],
@@ -35,7 +34,14 @@ final class ConstructeurStatsController extends AbstractController
);
}
return new JsonResponse($this->buildStats($id));
$bulk = $this->fetchAllStats();
return new JsonResponse($bulk[$id] ?? [
'composantCount' => 0,
'pieceCount' => 0,
'machineCount' => 0,
'productCount' => 0,
]);
}
#[Route('/api/constructeurs/stats', name: 'api_constructeurs_stats_bulk', methods: ['GET'], priority: 1)]
@@ -43,42 +49,48 @@ final class ConstructeurStatsController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
return new JsonResponse($this->fetchAllStats());
}
/** @return array<string, array{composantCount: int, pieceCount: int, machineCount: int, productCount: int}> */
private function fetchAllStats(): array
{
$result = [];
// Initialize all constructeurs with zero counts
$allIds = $this->em->createQuery(
'SELECT c.id FROM App\Entity\Constructeur c',
)->getSingleColumnResult();
$result = [];
foreach ($allIds as $id) {
$result[$id] = $this->buildStats($id);
$result[$id] = [
'composantCount' => 0,
'pieceCount' => 0,
'machineCount' => 0,
'productCount' => 0,
];
}
return new JsonResponse($result);
}
/** @return array{composantCount: int, pieceCount: int, machineCount: int, productCount: int} */
private function buildStats(string $constructeurId): array
{
$composantCount = (int) $this->em->createQuery(
'SELECT COUNT(l.id) FROM '.ComposantConstructeurLink::class.' l WHERE l.constructeur = :id',
)->setParameter('id', $constructeurId)->getSingleScalarResult();
$pieceCount = (int) $this->em->createQuery(
'SELECT COUNT(l.id) FROM '.PieceConstructeurLink::class.' l WHERE l.constructeur = :id',
)->setParameter('id', $constructeurId)->getSingleScalarResult();
$machineCount = (int) $this->em->createQuery(
'SELECT COUNT(l.id) FROM '.MachineConstructeurLink::class.' l WHERE l.constructeur = :id',
)->setParameter('id', $constructeurId)->getSingleScalarResult();
$productCount = (int) $this->em->createQuery(
'SELECT COUNT(l.id) FROM '.ProductConstructeurLink::class.' l WHERE l.constructeur = :id',
)->setParameter('id', $constructeurId)->getSingleScalarResult();
return [
'composantCount' => $composantCount,
'pieceCount' => $pieceCount,
'machineCount' => $machineCount,
'productCount' => $productCount,
// 4 bulk queries instead of 4N
$counts = [
'composantCount' => ComposantConstructeurLink::class,
'pieceCount' => PieceConstructeurLink::class,
'machineCount' => MachineConstructeurLink::class,
'productCount' => ProductConstructeurLink::class,
];
foreach ($counts as $key => $entityClass) {
$rows = $this->em->createQuery(
'SELECT IDENTITY(l.constructeur) AS cid, COUNT(l.id) AS cnt FROM '.$entityClass.' l GROUP BY l.constructeur',
)->getResult();
foreach ($rows as $row) {
if (isset($result[$row['cid']])) {
$result[$row['cid']][$key] = (int) $row['cnt'];
}
}
}
return $result;
}
}