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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ interface UsedInData {
pieces: UsedInEntity[] 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 data = ref<UsedInData>({ machines: [], composants: [], pieces: [] })
const loading = ref(false) const loading = ref(false)

View File

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

View File

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

View File

@@ -223,12 +223,13 @@ if (!machineId) {
} }
const d = useMachineDetailData(machineId) const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref(null) const machineInfoCardRef = ref<{ saveFieldDefinitions?: () => Promise<void> } | null>(null)
const versionRefreshKey = ref(0) const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ } const refreshVersions = () => { versionRefreshKey.value++ }
const { confirm: confirmDialog } = useConfirm() 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) => { watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true }) navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
}) })
@@ -306,7 +307,7 @@ const handleAddEntity = async (payload) => {
refreshVersions() refreshVersions()
} }
const handleFillEntity = (linkId, entityKind, modelTypeId) => { const handleFillEntity = (linkId: string, entityKind: string, modelTypeId: string) => {
fillLinkId.value = linkId fillLinkId.value = linkId
fillTypeId.value = modelTypeId fillTypeId.value = modelTypeId
addModalKind.value = entityKind addModalKind.value = entityKind
@@ -341,7 +342,7 @@ const confirmRemovePiece = async (id: string) => {
const confirmRemoveDocument = 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 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(() => { onMounted(() => {

View File

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

View File

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

View File

@@ -21,69 +21,13 @@ final class Version20260405_MigrateConstructeurLinks extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// Composant links (a = composantId, b = constructeurId) // No-op: legacy M2M tables (_composantconstructeurs, _piececonstructeurs, etc.)
$this->addSql(" // were already dropped by Version20260331121257.
INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) // The actual data restoration is handled by Version20260405_RestoreConstructeurLinksFromBackup.
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
)
");
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// Remove only the migrated rows (identifiable by 'clmig_' prefix) // No-op
$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_%'");
} }
} }

View File

@@ -38,7 +38,7 @@ final class Version20260405_RestoreConstructeurLinksFromBackup extends AbstractM
('clbkp3_cc_003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, NOW(), NOW()), ('clbkp3_cc_003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, NOW(), NOW()),
('clbkp3_cc_004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()), ('clbkp3_cc_004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_cc_005', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', 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) === // === 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_023', 'cl6480b97f6516fba22ce86434', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_pc_024', 'cl9579b05774d92096117841b0', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()), ('clbkp3_pc_024', 'cl9579b05774d92096117841b0', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_025', 'cl5b02c64fcc5ae8a3bfb6e5e6', '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'. // 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_027', 'clfd3cb41a407ab5a3f9d5baae', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_028', 'cld08dae22796b5855152580d9', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()), ('clbkp3_pc_028', 'cld08dae22796b5855152580d9', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_029', 'clf6012fca41994c1e81ce2dba', '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 // Re-enable FK checks

View File

@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\ComposantConstructeurLink; use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur;
use App\Entity\MachineConstructeurLink; use App\Entity\MachineConstructeurLink;
use App\Entity\PieceConstructeurLink; use App\Entity\PieceConstructeurLink;
use App\Entity\ProductConstructeurLink; use App\Entity\ProductConstructeurLink;
use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -19,7 +19,6 @@ final class ConstructeurStatsController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly ConstructeurRepository $constructeurs,
) {} ) {}
#[Route('/api/constructeurs/{id}/stats', name: 'api_constructeur_stats', methods: ['GET'])] #[Route('/api/constructeurs/{id}/stats', name: 'api_constructeur_stats', methods: ['GET'])]
@@ -27,7 +26,7 @@ final class ConstructeurStatsController extends AbstractController
{ {
$this->denyAccessUnlessGranted('ROLE_VIEWER'); $this->denyAccessUnlessGranted('ROLE_VIEWER');
$constructeur = $this->constructeurs->find($id); $constructeur = $this->em->find(Constructeur::class, $id);
if (!$constructeur) { if (!$constructeur) {
return new JsonResponse( return new JsonResponse(
['message' => 'Fournisseur introuvable.'], ['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)] #[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'); $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( $allIds = $this->em->createQuery(
'SELECT c.id FROM App\Entity\Constructeur c', 'SELECT c.id FROM App\Entity\Constructeur c',
)->getSingleColumnResult(); )->getSingleColumnResult();
$result = [];
foreach ($allIds as $id) { foreach ($allIds as $id) {
$result[$id] = $this->buildStats($id); $result[$id] = [
'composantCount' => 0,
'pieceCount' => 0,
'machineCount' => 0,
'productCount' => 0,
];
} }
return new JsonResponse($result); // 4 bulk queries instead of 4N
} $counts = [
'composantCount' => ComposantConstructeurLink::class,
/** @return array{composantCount: int, pieceCount: int, machineCount: int, productCount: int} */ 'pieceCount' => PieceConstructeurLink::class,
private function buildStats(string $constructeurId): array 'machineCount' => MachineConstructeurLink::class,
{ 'productCount' => ProductConstructeurLink::class,
$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,
]; ];
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;
} }
} }