3 Commits

Author SHA1 Message Date
Matthieu
41f5319b67 chore(changelog) : add v1.7.0 and v1.8.0 entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:00:18 +01:00
Matthieu
c7fd8328d6 fix(errors) : humanize backend error messages for end users
Add centralized error translation layer (humanizeError) that converts
raw Symfony/Doctrine/API Platform messages into user-friendly French.
Fix useApi to extract errors from all backend response formats
(violations, error, message, hydra:description, detail).
Add toast deduplication to prevent double display. Replace error toast
icon (X → CircleX) to distinguish from the dismiss button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:48:51 +01:00
Matthieu
55e2a4fafe fix(navbar) : reorder nav groups and add lucide icons
- Reorder: Composants, Pieces, Produits (was Pieces, Produits, Composants)
- Add icons to all nav links and dropdown groups
- Dashboard, Factory, ClipboardList, Cpu, Puzzle, Package, Link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:26 +01:00
17 changed files with 295 additions and 67 deletions

View File

@@ -24,7 +24,7 @@
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideX
<IconLucideCircleX
v-else-if="toast.type === 'error'"
class="w-4 h-4"
aria-hidden="true"
@@ -64,6 +64,7 @@
import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import IconLucideCircleX from '~icons/lucide/circle-x'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideInfo from '~icons/lucide/info'

View File

@@ -24,9 +24,10 @@
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="rounded-md px-2 py-1 transition-colors"
class="rounded-md px-2 py-1 transition-colors flex items-center gap-2"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
@@ -46,7 +47,10 @@
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
>
<span>{{ group.label }}</span>
<span class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
@@ -100,9 +104,10 @@
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="transition-colors px-3 py-2 rounded-md"
class="transition-colors px-3 py-2 rounded-md flex items-center gap-1.5"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
@@ -119,13 +124,14 @@
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 transition-colors"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
>
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
@@ -233,7 +239,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
@@ -243,6 +249,13 @@ import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{
@@ -253,25 +266,38 @@ defineEmits<{
interface NavLink {
to: string
label: string
icon?: Component
}
interface NavGroup {
id: string
label: string
icon?: Component
activePaths: string[]
children: NavLink[]
}
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble' },
{ to: '/machines', label: 'Parc Machines' },
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
{ to: '/machine-skeleton', label: 'Squelettes', icon: IconLucideClipboardList },
]
const navGroups: NavGroup[] = [
{
id: 'component',
label: 'Composants',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{
id: 'pieces',
label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'],
children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
@@ -281,24 +307,17 @@ const navGroups: NavGroup[] = [
{
id: 'products',
label: 'Produits',
icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'],
children: [
{ to: '/product-catalog', label: 'Catalogue des produits' },
{ to: '/product-category', label: 'Catégorie de produit' },
],
},
{
id: 'component',
label: 'Composant',
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{
id: 'resources',
label: 'Ressources liées',
icon: IconLucideLink,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
children: [
{ to: '/sites', label: 'Sites' },

View File

@@ -119,6 +119,7 @@ import {
type ModelTypeListResponse,
} from "~/services/modelTypes";
import { useToast } from "~/composables/useToast";
import { humanizeError } from "~/shared/utils/errorMessages";
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
const DEFAULT_DESCRIPTION =
@@ -183,7 +184,8 @@ useHead(() => ({
title: headingText.value,
}));
const extractErrorMessage = (error: unknown) => {
const extractErrorMessage = (error: unknown): string => {
let raw: string | null = null;
if (error && typeof error === "object") {
const maybeFetchError = error as {
data?: Record<string, unknown>;
@@ -192,21 +194,16 @@ const extractErrorMessage = (error: unknown) => {
};
if (maybeFetchError.data) {
const data = maybeFetchError.data;
if (typeof data.message === "string") {
return data.message;
}
if (Array.isArray(data.message) && data.message.length > 0) {
return data.message[0];
}
}
if (typeof maybeFetchError.statusMessage === "string") {
return maybeFetchError.statusMessage;
}
if (typeof maybeFetchError.message === "string") {
return maybeFetchError.message;
if (typeof data['hydra:description'] === "string") raw = data['hydra:description'];
else if (typeof data.detail === "string") raw = data.detail;
else if (typeof data.message === "string") raw = data.message;
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0];
else if (typeof data.error === "string") raw = data.error;
}
if (!raw && typeof maybeFetchError.statusMessage === "string") raw = maybeFetchError.statusMessage;
if (!raw && typeof maybeFetchError.message === "string") raw = maybeFetchError.message;
}
return "Une erreur est survenue lors de la communication avec le serveur.";
return humanizeError(raw);
};
const refresh = async ({

View File

@@ -1,4 +1,5 @@
import { useToast } from './useToast'
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
export interface ApiResponse<T = any> {
success: boolean
@@ -59,23 +60,26 @@ export function useApi() {
} else {
const contentType = response.headers.get('content-type') || ''
let errorData: Record<string, unknown> = {}
if (contentType.includes('application/json')) {
if (contentType.includes('json')) {
errorData = await response.json().catch(() => ({}))
} else {
const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {}
}
const errorMessage = response.status === 403
const rawMessage = response.status === 403
? 'Permissions insuffisantes pour cette action.'
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
const errorMessage = humanizeError(rawMessage)
showError(errorMessage)
return { success: false, error: errorMessage, status: response.status }
}
} catch (error) {
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
showError(`Erreur réseau: ${errorMessage}`)
const errorMessage = err.name === 'AbortError'
? 'La requête a pris trop de temps. Veuillez réessayer.'
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
showError(errorMessage)
return { success: false, error: errorMessage }
}
}

View File

@@ -7,6 +7,7 @@
import { ref, type Ref } from 'vue'
import { useToast } from './useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import {
listModelTypes,
createModelType,
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true, data: state.types.value }
} catch (error) {
const err = error as Error & { message?: string }
const message = err?.message || 'Erreur inconnue'
showError(`Impossible de charger les types de ${label}: ${message}`)
const message = humanizeError(err?.message)
showError(`Impossible de charger les types de ${label}.`)
return { success: false, error: message }
} finally {
state.loading.value = false
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la création du type de ${label}: ${message}`)
const raw = err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de créer le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
const raw = err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
const raw = err?.data?.message || err?.message
const message = humanizeError(raw)
showError(`Impossible de supprimer le type de ${label} : ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false

View File

@@ -15,6 +15,7 @@ import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
import {
useMachineCreatePreview,
@@ -365,10 +366,10 @@ export function useMachineCreatePage() {
clearRequirementSelections()
await navigateTo('/machines')
} else if (result.error) {
toast.showError(`Impossible de créer la machine: ${result.error}`)
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
}
} catch (error: any) {
toast.showError(`Erreur lors de la création: ${error.message}`)
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
} finally {
submitting.value = false
}

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { humanizeError } from '~/shared/utils/errorMessages'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
@@ -168,9 +169,9 @@ export function useProducts() {
return result as ProductListResult
} catch (err) {
console.error('Erreur lors du chargement des produits:', err)
const message = (err as Error)?.message ?? 'Erreur inconnue'
const message = humanizeError((err as Error)?.message)
error.value = message
showError(`Impossible de charger les produits: ${message}`)
showError(`Impossible de charger les produits.`)
return { success: false, error: message }
} finally {
loading.value = false
@@ -197,9 +198,9 @@ export function useProducts() {
return { success: false, error: result.error }
} catch (err) {
console.error('Erreur lors de la création du produit:', err)
const message = (err as Error)?.message ?? 'Erreur inconnue'
const message = humanizeError((err as Error)?.message)
error.value = message
showError(message)
showError('Impossible de créer le produit.')
return { success: false, error: message }
} finally {
loading.value = false
@@ -223,9 +224,9 @@ export function useProducts() {
return { success: false, error: result.error }
} catch (err) {
console.error('Erreur lors de la mise à jour du produit:', err)
const message = (err as Error)?.message ?? 'Erreur inconnue'
const message = humanizeError((err as Error)?.message)
error.value = message
showError(message)
showError('Impossible de mettre à jour le produit.')
return { success: false, error: message }
} finally {
loading.value = false
@@ -248,9 +249,9 @@ export function useProducts() {
return { success: false, error: result.error }
} catch (err) {
console.error('Erreur lors de la suppression du produit:', err)
const message = (err as Error)?.message ?? 'Erreur inconnue'
const message = humanizeError((err as Error)?.message)
error.value = message
showError(message)
showError('Impossible de supprimer le produit.')
return { success: false, error: message }
} finally {
loading.value = false

View File

@@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { navigateTo, useRoute } from '#imports'
import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConfirm } from '~/composables/useConfirm'
import { getFileIcon } from '~/utils/fileIcons'
@@ -274,10 +275,10 @@ export function useSiteManagement() {
if (result.success) {
showSuccess(`Site "${site.name}" supprimé avec succès`)
} else {
showError(`Erreur lors de la suppression: ${result.error}`)
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
}
} catch (error: any) {
showError(`Erreur lors de la suppression: ${error.message}`)
showError(`Impossible de supprimer le site : ${humanizeError(error.message)}`)
}
}

View File

@@ -13,8 +13,19 @@ const toasts = ref<Toast[]>([])
const MAX_TOASTS = 3
let nextId = 1
// Anti-doublon : ignore un toast identique affiché dans les 2 dernières secondes
const recentMessages = new Map<string, number>()
const DEDUP_WINDOW = 2000
export function useToast() {
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
const dedupKey = `${type}::${message}`
const lastShown = recentMessages.get(dedupKey)
if (lastShown && Date.now() - lastShown < DEDUP_WINDOW) {
return -1
}
recentMessages.set(dedupKey, Date.now())
const id = nextId++
const toast: Toast = {
id,

View File

@@ -69,6 +69,37 @@ const badgeClass = (type: ChangeType) => {
}
const releases: Release[] = [
{
version: 'v1.8.0',
date: '2026-03-03',
changes: [
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
],
},
{
version: 'v1.7.0',
date: '2026-03-02',
changes: [
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
],
},
{
version: 'v1.6.1',
date: '2026-02-12',
@@ -171,7 +202,7 @@ const releases: Release[] = [
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },

View File

@@ -370,6 +370,7 @@ import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
@@ -998,7 +999,7 @@ const submitCreation = async () => {
toast.showError(result.error)
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du composant')
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
} finally {
submitting.value = false
uploadingDocuments.value = false

View File

@@ -472,6 +472,7 @@ import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideUser from '~icons/lucide/user'
@@ -731,10 +732,10 @@ const confirmDeleteMachine = async (machine) => {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
await loadMachines()
} else {
showError(`Erreur lors de la suppression: ${result.error}`)
showError(`Impossible de supprimer la machine : ${result.error}`)
}
} catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`)
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
}
}
}

View File

@@ -104,6 +104,7 @@
import { ref, computed, onMounted } from "vue";
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
import { useToast } from "~/composables/useToast";
import { humanizeError } from "~/shared/utils/errorMessages";
import IconLucidePlus from "~icons/lucide/plus";
import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
@@ -148,10 +149,10 @@ const confirmDeleteType = async (type) => {
if (result.success) {
showSuccess(`Type "${type.name}" supprimé avec succès`);
} else {
showError(`Erreur lors de la suppression: ${result.error}`);
showError(`Impossible de supprimer le type : ${result.error}`);
}
} catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`);
showError(`Impossible de supprimer le type : ${humanizeError(error.message)}`);
}
}
};

View File

@@ -138,6 +138,7 @@ import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
@@ -214,10 +215,10 @@ const confirmDeleteMachine = async (machine) => {
if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
} else {
showError(`Erreur lors de la suppression: ${result.error}`)
showError(`Impossible de supprimer la machine : ${result.error}`)
}
} catch (error) {
showError(`Erreur lors de la suppression: ${error.message}`)
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
}
}
}

View File

@@ -315,6 +315,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -599,7 +600,7 @@ const submitCreation = async () => {
toast.showError(result.error)
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création de la pièce')
toast.showError(humanizeError(error?.message) || 'Impossible de créer la pièce')
} finally {
submitting.value = false
uploadingDocuments.value = false

View File

@@ -407,6 +407,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
@@ -700,7 +701,7 @@ const submitEdition = async () => {
await router.push('/product-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
} finally {
saving.value = false
}

View File

@@ -0,0 +1,152 @@
/**
* Translates raw backend error messages (Symfony, Doctrine, API Platform)
* into user-friendly French messages for display in toasts/alerts.
*/
const EXACT_MATCHES: Record<string, string> = {
// UniqueConstraintSubscriber (HTTP 409)
'nom duplique': 'Un élément avec ce nom existe déjà.',
// English backend messages → French
'Machine not found.': 'Machine introuvable.',
'Composant not found.': 'Composant introuvable.',
'Piece not found.': 'Pièce introuvable.',
'Product not found.': 'Produit introuvable.',
'Site not found.': 'Site introuvable.',
'Custom field not found.': 'Champ personnalisé introuvable.',
'Custom field value not found.': 'Valeur du champ personnalisé introuvable.',
'Document not found.': 'Document introuvable.',
'File not found on disk.': 'Le fichier n\'a pas été trouvé sur le serveur.',
'Invalid document data.': 'Les données du document sont invalides.',
'Invalid JSON payload.': 'Les données envoyées sont invalides.',
'Unsupported entity type.': 'Type d\'entité non supporté.',
'Entity target is missing.': 'La cible de l\'entité est manquante.',
'customFieldId or customFieldName is required.': 'L\'identifiant du champ personnalisé est requis.',
// Symfony validator messages
'This value should not be blank.': 'Ce champ ne peut pas être vide.',
'This value is not a valid email address.': 'L\'adresse email n\'est pas valide.',
'This value is already used.': 'Cette valeur est déjà utilisée.',
'This field is missing.': 'Un champ obligatoire est manquant.',
// HTTP status texts (used in "Erreur XXX: StatusText" fallback)
'Internal Server Error': 'Erreur interne du serveur. Veuillez réessayer.',
'Bad Request': 'Requête invalide.',
'Not Found': 'Ressource introuvable.',
'Conflict': 'Un élément similaire existe déjà.',
'Unprocessable Entity': 'Données invalides.',
'Unprocessable Content': 'Données invalides.',
'Service Unavailable': 'Service temporairement indisponible. Veuillez réessayer.',
'Gateway Timeout': 'Le serveur met trop de temps à répondre. Veuillez réessayer.',
}
const TECHNICAL_PATTERNS: Array<[RegExp, string]> = [
// Database / Doctrine errors
[/SQLSTATE\[/i, 'Une erreur est survenue. Veuillez réessayer.'],
[/An exception occurred/i, 'Une erreur est survenue. Veuillez réessayer.'],
[/Duplicate entry/i, 'Un élément avec ces données existe déjà.'],
[/unique.*constraint.*violation/i, 'Un élément avec ces données existe déjà.'],
[/foreign key constraint/i, 'Impossible de supprimer cet élément car il est utilisé ailleurs.'],
[/violates not-null constraint/i, 'Un champ obligatoire n\'a pas été renseigné.'],
[/violates check constraint/i, 'Une valeur saisie est invalide.'],
// Symfony / API Platform internal messages
[/Expected argument of type/i, 'Les données envoyées sont invalides.'],
[/Could not denormalize/i, 'Les données envoyées sont invalides.'],
[/The JSON value could not be decoded/i, 'Les données envoyées sont invalides.'],
[/Syntax error.*JSON/i, 'Les données envoyées sont invalides.'],
[/No route found/i, 'Ressource introuvable.'],
[/Access Denied/i, 'Permissions insuffisantes pour cette action.'],
]
/**
* Detects if a message contains technical jargon that should not be shown to users.
*/
function containsTechnicalJargon(message: string): boolean {
const patterns = [
/stack trace/i,
/exception/i,
/\bat\s+[\w\\]+::/,
/vendor\//,
/\.php/,
/doctrine/i,
/symfony/i,
/SQLSTATE/i,
/PDOException/i,
/DBALException/i,
/RuntimeException/i,
/TypeError/i,
/LogicException/i,
/InvalidArgumentException/i,
/UnexpectedValueException/i,
/constraint.*violation/i,
/entity.*manager/i,
/Hydra error/i,
]
return patterns.some((p) => p.test(message))
}
/**
* Translates a raw backend error message into a user-friendly French message.
*
* Usage:
* import { humanizeError } from '~/shared/utils/errorMessages'
* showError(humanizeError(rawMessage))
*/
export function humanizeError(rawMessage: string | undefined | null): string {
if (!rawMessage) return 'Une erreur est survenue.'
const trimmed = rawMessage.trim()
if (!trimmed) return 'Une erreur est survenue.'
// 1. Exact match
if (EXACT_MATCHES[trimmed]) return EXACT_MATCHES[trimmed]
// 2. "Erreur XXX: StatusText" pattern — translate the status text
const httpMatch = trimmed.match(/^Erreur (\d{3})\s*:\s*(.+)$/)
if (httpMatch) {
const statusText = httpMatch[2]!.trim()
if (EXACT_MATCHES[statusText]) return EXACT_MATCHES[statusText]
return `Erreur serveur (${httpMatch[1]}). Veuillez réessayer.`
}
// 3. Regex patterns for technical errors
for (const [pattern, replacement] of TECHNICAL_PATTERNS) {
if (pattern.test(trimmed)) return replacement
}
// 4. If it contains technical jargon, replace with generic message
if (containsTechnicalJargon(trimmed)) {
return 'Une erreur est survenue. Veuillez réessayer.'
}
// 5. Already user-friendly — return as-is
return trimmed
}
/**
* Extracts the best error message from various backend response formats.
* Handles: { message }, { error }, { detail }, { "hydra:description" }
*/
export function extractApiErrorMessage(errorData: Record<string, unknown>): string | null {
if (!errorData || typeof errorData !== 'object') return null
// Symfony validator violations — priorité max (message propre sans préfixe champ)
if (Array.isArray(errorData.violations) && errorData.violations.length > 0) {
const first = errorData.violations[0] as Record<string, unknown>
if (typeof first?.message === 'string') return first.message
}
// UniqueConstraintSubscriber format ({ success: false, error: "nom duplique" })
if (typeof errorData.error === 'string') return errorData.error
// Custom controllers format
if (typeof errorData.message === 'string') return errorData.message
if (Array.isArray(errorData.message) && typeof errorData.message[0] === 'string') return errorData.message[0]
// API Platform hydra format (fallback — peut contenir "propertyPath: message")
if (typeof errorData['hydra:description'] === 'string') return errorData['hydra:description']
if (typeof errorData.detail === 'string') return errorData.detail
return null
}