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:
@@ -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'
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
entityType: string
|
||||
entityType: 'composants' | 'pieces' | 'products'
|
||||
entityId: string | null
|
||||
}>()
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user