Compare commits

..

4 Commits

Author SHA1 Message Date
gitea-actions
6e2c5179a9 chore : bump version to v1.9.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
2026-04-06 18:46:40 +00:00
3cd18a721a feat(ui) : refonte cartes dépliantes structure machine + DataTable parc machines + fix activity-log
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Parc Machines transformé en DataTable avec filtres (site, date création, recherche)
- Vue d'ensemble : ajout filtre par plage de dates de création
- Activity-log : correction des liens entités (routes singulier sans /edit, ajout machine/document/model_type)
- ComponentItem & PieceItem : refonte complète des cartes dépliantes (design industriel raffiné)
  - Header compact avec tags colorés contrastés (référence, réf. auto, prix, produit, champs machine)
  - Panneau déplié structuré en sections avec mini-headers
  - Bordure gauche primary pour hiérarchie visuelle
- Ajout referenceAuto dans header et infos pour composants et pièces
- Suppression double encadrement ComponentHierarchy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:46:25 +02:00
gitea-actions
191e071957 chore : bump version to v1.9.25
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-06 16:54:32 +00:00
f964df76b9 feat(custom-fields) : messages warning champs obligatoires + commandes make frontend
All checks were successful
Auto Tag Develop / tag (push) Successful in 10s
Ajoute des messages visuels (warning + error) quand des champs perso
obligatoires ne sont pas renseignés sur les pages composant (création
et édition). Ajoute make test-front et make test-front-watch au Makefile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:54:22 +02:00
10 changed files with 822 additions and 600 deletions

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.24' app.version: '1.9.26'

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-3">
<!-- Root Components --> <!-- Root Components -->
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4"> <div v-for="component in components" :key="component.id">
<ComponentItem <ComponentItem
:component="component" :component="component"
:is-edit-mode="isEditMode" :is-edit-mode="isEditMode"

View File

@@ -13,29 +13,42 @@
@updated="handleDocumentUpdated" @updated="handleDocumentUpdated"
/> />
<!-- Component Header --> <!-- HEADER BAR -->
<div <div
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-shadow" class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
:class="[ :class="[
component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200', component.pendingEntity
!isCollapsed ? 'sticky top-16 z-10 shadow-sm' : '', ? 'bg-error/10 border border-error/40 hover:border-error/60'
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
!isCollapsed ? 'sticky top-16 z-10 shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
]" ]"
@click="toggleCollapse" @click="toggleCollapse"
> >
<IconLucideChevronRight <!-- Chevron -->
class="w-4 h-4 shrink-0 transition-transform text-base-content/50" <div
:class="{ 'rotate-90': !isCollapsed }" class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
aria-hidden="true" :class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
/> >
<div class="flex-1 min-w-0"> <IconLucideChevronRight
class="w-3.5 h-3.5 transition-transform duration-200"
:class="[
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Row 1: Name + identifiers -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'"> <h3 class="text-sm font-bold tracking-tight truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
<NuxtLink <NuxtLink
v-if="!isEditMode && !component.pendingEntity && component.composantId" v-if="!isEditMode && !component.pendingEntity && component.composantId"
:to="machineId :to="machineId
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } } ? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
: `/component/${component.composantId}`" : `/component/${component.composantId}`"
class="hover:underline hover:text-primary transition-colors" class="hover:text-primary transition-colors"
@click.stop @click.stop
> >
{{ component.name }} {{ component.name }}
@@ -51,232 +64,282 @@
> >
À remplir À remplir
</button> </button>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span> <span v-if="component.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span> <span v-if="component.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ component.referenceAuto }}</span>
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div> </div>
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
<!-- Row 2: Metadata tags -->
<div
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
class="flex flex-wrap items-center gap-1.5"
>
<span <span
v-for="constructeur in componentConstructeursDisplay" v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id" :key="constructeur.id"
class="text-xs text-base-content/50" class="text-[0.65rem] text-base-content/45"
> >
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span> <span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span> </span>
<span v-if="displayProductName" class="badge badge-info badge-xs"> <span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }} {{ displayProductName }}
</span> </span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div> </div>
</div> </div>
<!-- Delete button -->
<button <button
v-if="showDelete" v-if="showDelete"
type="button" type="button"
class="btn btn-ghost btn-xs text-error shrink-0" class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
title="Supprimer ce composant" title="Supprimer ce composant"
@click.stop="$emit('delete')" @click.stop="$emit('delete')"
> >
Supprimer <IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</div> </div>
<!-- Expanded content --> <!-- EXPANDED PANEL -->
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7"> <div v-show="!isCollapsed && !component.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3"> <!-- Section: Informations -->
<div class="form-control"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent"> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
</div> </div>
<div class="form-control"> <div class="p-4">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label> <!-- Edit mode -->
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent"> <div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Nom</span></label>
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only mode -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Nom</p>
<p class="text-sm text-base-content font-medium">{{ component.name }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
<p class="text-sm text-base-content" :class="component.reference ? 'font-mono' : 'text-base-content/30'">{{ component.reference || '—' }}</p>
</div>
<div v-if="component.referenceAuto">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
<p class="text-sm text-base-content font-mono">{{ component.referenceAuto }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
<p class="text-sm" :class="component.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ component.prix ? `${component.prix}` : '—' }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-sm text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-sm text-base-content/30"></p>
</div>
</div>
</div> </div>
<div class="form-control"> </div>
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent"> <!-- Section: Produit catalogue -->
<div v-if="displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
</div> </div>
<div class="form-control"> <div class="p-4">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label> <div class="flex items-start justify-between gap-3">
<ConstructeurSelect <div class="space-y-1.5">
class="w-full" <p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
:model-value="componentConstructeurIds" <div class="flex flex-wrap gap-x-4 gap-y-1">
:initial-options="componentConstructeursDisplay" <p
@update:model-value="handleConstructeurChange" v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/55"
>
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0 gap-1"
>
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
Voir
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200/50 space-y-2">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/35">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Section: Champs personnalisés -->
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="false"
@field-blur="updateComponentCustomField"
/> />
</div> </div>
</div> </div>
<!-- Read-only info --> <div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm"> <div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
<div> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
<p class="text-base-content">{{ component.name }}</p>
</div> </div>
<div> <div class="p-4">
<p class="text-xs text-base-content/40 mb-0.5">Référence</p> <CustomFieldDisplay
<p class="text-base-content">{{ component.reference || '—' }}</p> :fields="mergedContextFields"
</div> :is-edit-mode="isEditMode"
<div> :columns="2"
<p class="text-xs text-base-content/40 mb-0.5">Prix</p> :show-header="false"
<p class="text-base-content">{{ component.prix ? `${component.prix}` : '—' }}</p> :with-top-border="false"
</div> :editable="true"
<div> :emit-blur="false"
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p> @field-input="queueContextCustomFieldUpdate"
<div v-if="componentConstructeursDisplay.length"> />
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-base-content"></p>
</div> </div>
</div> </div>
<!-- Product --> <!-- Section: Documents -->
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="flex items-start justify-between gap-3"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
<div class="space-y-1"> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
<p class="text-xs text-base-content/40">Produit catalogue</p>
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/60"
>
{{ info.label }} : {{ info.value }}
</p>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0"
>
Voir le produit
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
title="Champs personnalisés item"
:editable="false"
@field-blur="updateComponentCustomField"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs"> <span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</div> </div>
<div class="p-4 space-y-3">
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement...</p>
<p v-if="loadingDocuments" class="text-xs text-base-content/50"> <DocumentUpload
Chargement... v-if="isEditMode"
</p> v-model="selectedFiles"
title="Déposer des fichiers pour ce composant"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentUpload <DocumentListInline
v-if="isEditMode" :documents="componentDocuments"
v-model="selectedFiles" :can-delete="isEditMode"
title="Déposer des fichiers pour ce composant" :can-edit="isEditMode"
subtitle="Formats acceptés : PDF, images, documents..." :delete-disabled="uploadingDocuments"
@files-added="handleFilesAdded" empty-text="Aucun document lié à ce composant."
/> @preview="openPreview"
@edit="openEditModal"
<DocumentListInline @delete="removeDocument"
:documents="componentDocuments" />
:can-delete="isEditMode" </div>
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à ce composant."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div> </div>
<!-- Component Pieces (real MachinePieceLinks) --> <!-- Section: Pièces du composant -->
<div v-if="linkedPieces.length > 0" class="space-y-2"> <div v-if="linkedPieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Pièces du composant <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Pièces du composant
<div class="space-y-2"> <span class="ml-1 text-base-content/25">({{ linkedPieces.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<PieceItem <PieceItem
v-for="piece in linkedPieces" v-for="piece in linkedPieces"
:key="piece.id" :key="piece.id"
@@ -290,12 +353,15 @@
</div> </div>
</div> </div>
<!-- Structure pieces (read-only, from composant definition) --> <!-- ── Section: Pièces structure ── -->
<div v-if="structurePieces.length > 0" class="space-y-2"> <div v-if="structurePieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Pièces incluses par défaut <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Pièces incluses par défaut
<div class="space-y-2"> <span class="ml-1 text-base-content/25">({{ structurePieces.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<PieceItem <PieceItem
v-for="piece in structurePieces" v-for="piece in structurePieces"
:key="piece.id" :key="piece.id"
@@ -305,12 +371,15 @@
</div> </div>
</div> </div>
<!-- Sub Components --> <!-- ── Section: Sous-composants ── -->
<div v-if="childComponents.length > 0" class="space-y-2"> <div v-if="childComponents.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Sous-composants <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Sous-composants
<div class="space-y-2 pl-4 border-l-2 border-base-200"> <span class="ml-1 text-base-content/25">({{ childComponents.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<ComponentItem <ComponentItem
v-for="subComponent in childComponents" v-for="subComponent in childComponents"
:key="subComponent.id" :key="subComponent.id"
@@ -336,6 +405,8 @@ import DocumentUpload from './DocumentUpload.vue'
import ConstructeurSelect from './ConstructeurSelect.vue' import ConstructeurSelect from './ConstructeurSelect.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucideExternalLink from '~icons/lucide/external-link'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview' import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { import {
@@ -461,6 +532,24 @@ const mergedContextFields = computed(() => {
return mergeDefinitionsWithValues(definitions, values) return mergeDefinitionsWithValues(definitions, values)
}) })
// Context fields shown as tags on the header (consultation mode)
const visibleContextFieldTags = computed(() =>
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
)
const CONTEXT_FIELD_COLORS = [
'bg-secondary/25 text-secondary border border-secondary/35',
'bg-accent/25 text-accent border border-accent/35',
'bg-info/25 text-info border border-info/35',
'bg-success/25 text-success border border-success/35',
'bg-warning/25 text-warning border border-warning/35',
]
const contextFieldBadgeClass = (field: any) => {
const idx = visibleContextFieldTags.value.indexOf(field)
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
}
const queueContextCustomFieldUpdate = (field, value) => { const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId const linkId = props.component?.linkId
if (!linkId || !field) return if (!linkId || !field) return

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="space-y-4"> <div>
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
@@ -13,303 +13,333 @@
@updated="handleDocumentUpdated" @updated="handleDocumentUpdated"
/> />
<!-- Piece Header (collapsible, same pattern as ComponentItem) --> <!-- HEADER BAR -->
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'"> <div
<div class="flex items-start gap-3 flex-1 min-w-0"> class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
<button :class="[
type="button" piece._emptySlot || piece.pendingEntity
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform" ? 'bg-error/10 border border-error/40 hover:border-error/60'
:class="{ 'rotate-90': !isCollapsed }" : 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
:aria-expanded="!isCollapsed" !isCollapsed ? 'shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'" ]"
@click="toggleCollapse" @click="toggleCollapse"
> >
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" /> <!-- Chevron -->
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span> <div
</button> class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
<div class="flex-1 min-w-0"> :class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }"> >
<IconLucideChevronRight
class="w-3.5 h-3.5 transition-transform duration-200"
:class="[
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Row 1: Name + identifiers -->
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-bold tracking-tight truncate" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
<NuxtLink <NuxtLink
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId" v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
:to="machineId :to="machineId
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } } ? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
: `/piece/${piece.pieceId}`" : `/piece/${piece.pieceId}`"
class="hover:underline hover:text-primary transition-colors" class="hover:text-primary transition-colors"
@click.stop @click.stop
> >
{{ pieceData.name }} {{ pieceData.name }}
</NuxtLink> </NuxtLink>
<template v-else>{{ pieceData.name }}</template> <template v-else>{{ pieceData.name }}</template>
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<button
v-if="piece.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
>
À remplir
</button>
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3> </h3>
<div class="flex flex-wrap gap-2 mt-2"> <span v-if="piece._emptySlot" class="text-[0.65rem] font-bold text-error bg-error/10 px-1.5 py-0.5 rounded">manquant</span>
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm"> <button
Rattachée à {{ piece.parentComponentName }} v-if="piece.pendingEntity"
</span> type="button"
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span> class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span> title="Cliquer pour associer un item"
<template v-if="pieceConstructeursDisplay.length"> @click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
<span >
v-for="constructeur in pieceConstructeursDisplay" À remplir
:key="constructeur.id" </button>
class="badge badge-outline badge-sm" <span v-if="displayQuantity > 1" class="text-[0.65rem] font-bold text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">×{{ displayQuantity }}</span>
> <span v-if="pieceData.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ pieceData.reference }}</span>
{{ constructeur.name }} <span v-if="pieceData.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ pieceData.referenceAuto }}</span>
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5"> <span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span>
({{ supplierReferenceMap.get(constructeur.id) }}) </div>
</span>
</span> <!-- Row 2: Metadata tags -->
</template> <div
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span> v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
class="flex flex-wrap items-center gap-1.5"
>
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
{{ piece.parentComponentName }}
</span>
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="text-[0.65rem] text-base-content/45"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span>
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span <span
v-if="displayProductName" v-for="field in visibleContextFieldTags"
class="badge badge-info badge-sm" :key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
> >
Produit&nbsp;: {{ displayProductName }} {{ field.name }} : {{ field.value }}
</span> </span>
</div> </template>
</div> </div>
</div> </div>
<!-- Delete button -->
<button <button
v-if="showDelete" v-if="showDelete"
type="button" type="button"
class="btn btn-ghost btn-xs text-error shrink-0" class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
title="Supprimer cette pièce" title="Supprimer cette pièce"
@click="$emit('delete')" @click.stop="$emit('delete')"
> >
Supprimer <IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</div> </div>
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4"> <!-- EXPANDED PANEL -->
<div class="p-4 bg-base-100 border border-base-200 rounded-lg"> <div v-show="!isCollapsed && !piece.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control"> <!-- Section: Informations -->
<label class="label"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<span class="label-text text-sm">Quantité</span> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
</label> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
<input </div>
v-model.number="pieceData.quantity" <div class="p-4">
type="number" <!-- Edit mode -->
min="1" <div v-if="isEditMode" class="space-y-3">
step="1" <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
class="input input-bordered input-sm md:input-md w-24" <div class="form-control">
@blur="updatePiece" <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Quantité</span></label>
/> <input
</div> v-model.number="pieceData.quantity"
<div v-else-if="displayQuantity > 1"> type="number"
<span class="font-medium">Quantité:</span> min="1"
<span class="ml-2">{{ displayQuantity }}</span> step="1"
</div> class="input input-bordered input-sm w-full"
<div> @blur="updatePiece"
<span class="font-medium">Référence:</span> />
<input </div>
v-if="isEditMode" <div class="form-control">
:id="`piece-reference-${piece.id}`" <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
v-model="pieceData.reference" <input
type="text" :id="`piece-reference-${piece.id}`"
class="input input-sm input-bordered ml-2" v-model="pieceData.reference"
@blur="updatePiece" type="text"
/> class="input input-bordered input-sm w-full"
<span v-else class="ml-2">{{ @blur="updatePiece"
pieceData.reference || "Non définie" />
}}</span> </div>
</div> <div class="form-control">
<div v-if="pieceData.referenceAuto"> <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
<span class="font-medium">Référence auto:</span> <input
<span class="ml-2">{{ pieceData.referenceAuto }}</span> :id="`piece-prix-${piece.id}`"
</div> v-model="pieceData.prix"
<div> type="number"
<span class="font-medium">Fournisseur:</span> step="0.01"
<div v-if="!isEditMode" class="ml-2"> class="input input-bordered input-sm w-full"
<div v-if="pieceConstructeursDisplay.length" class="space-y-1"> @blur="updatePiece"
<div />
v-for="constructeur in pieceConstructeursDisplay" </div>
:key="constructeur.id" </div>
class="flex flex-col" <div class="form-control">
> <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
<span class="font-medium"> <ConstructeurSelect
{{ constructeur.name }} class="w-full"
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60"> :model-value="pieceConstructeurIds"
Réf. {{ supplierReferenceMap.get(constructeur.id) }} :initial-options="pieceConstructeursDisplay"
</span> placeholder="Sélectionner un ou plusieurs fournisseurs..."
</span> @update:model-value="handleConstructeurChange"
<span />
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-base-content/50"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div> </div>
</div> </div>
<span v-else class="font-medium"> <!-- Read-only mode -->
Non défini <div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
</span> <div v-if="displayQuantity > 1">
</div> <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
<ConstructeurSelect <p class="text-sm text-base-content font-medium">{{ displayQuantity }}</p>
v-else </div>
class="w-full" <div>
:model-value="pieceConstructeurIds" <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
:initial-options="pieceConstructeursDisplay" <p class="text-sm" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
placeholder="Sélectionner un ou plusieurs fournisseurs..." </div>
@update:model-value="handleConstructeurChange" <div v-if="pieceData.referenceAuto">
/> <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
</div> <p class="text-sm text-base-content font-mono">{{ pieceData.referenceAuto }}</p>
<div> </div>
<span class="font-medium">Prix:</span> <div>
<input <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
v-if="isEditMode" <p class="text-sm" :class="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.prix}` : '—' }}</p>
:id="`piece-prix-${piece.id}`" </div>
v-model="pieceData.prix" <div>
type="number" <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
step="0.01" <div v-if="pieceConstructeursDisplay.length" class="space-y-1">
class="input input-sm input-bordered ml-2" <p
@blur="updatePiece" v-for="constructeur in pieceConstructeursDisplay"
/> :key="constructeur.id"
<span v-else class="ml-2">{{ class="text-sm text-base-content"
pieceData.prix ? `${pieceData.prix}` : "Non défini" >
}}</span> {{ constructeur.name }}
</div> <span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
<div> Réf. {{ supplierReferenceMap.get(constructeur.id) }}
<span class="font-medium">Produit catalogue:</span> </span>
<div v-if="isEditMode" class="mt-2 space-y-2"> <span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
<ProductSelect {{ formatConstructeurContact(constructeur) }}
:model-value="pieceData.productId" </span>
placeholder="Associer un produit…" </p>
helper-text="Optionnel : reliez cette pièce à un produit catalogue." </div>
@update:modelValue="handleProductChange" <p v-else class="text-sm text-base-content/30"></p>
/> </div>
<div
v-if="selectedProduct"
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div> </div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div> </div>
<div class="ml-2"> </div>
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content"> <!-- Section: Produit catalogue -->
{{ displayProductName || 'Produit catalogue' }} <div v-if="isEditMode || displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
</p> <div class="px-4 py-2 bg-info/10 border-b border-info/20">
<p <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
v-for="info in productInfoRows" </div>
:key="info.label" <div class="p-4">
class="text-xs text-base-content/70" <!-- Edit mode -->
<div v-if="isEditMode" class="space-y-3">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-lg border border-base-200/60 bg-base-200/20 p-3 space-y-1.5"
> >
<span class="font-semibold">{{ info.label }} :</span> <p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</p>
<span class="ml-1">{{ info.value }}</span> <div class="flex flex-wrap gap-x-4 gap-y-1">
</p> <p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
<NuxtLink v-if="selectedProduct.id" :to="`/product/${selectedProduct.id}`" class="link link-primary text-xs">
Ouvrir la fiche produit
</NuxtLink>
</div>
</div>
<!-- Read-only mode -->
<div v-else-if="displayProduct">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1.5">
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
</div>
<NuxtLink
v-if="piece.product?.id || piece.productId"
:to="`/product/${piece.product?.id || piece.productId}`"
class="btn btn-ghost btn-xs shrink-0 gap-1"
>
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
Voir
</NuxtLink>
</div>
<ProductDocumentsInline <ProductDocumentsInline
v-if="productDocuments.length"
class="mt-3 pt-3 border-t border-base-200/50"
:documents="productDocuments" :documents="productDocuments"
@preview="openPreview" @preview="openPreview"
/> />
</div> </div>
<span v-else class="font-medium">
Non défini
</span>
</div> </div>
</div> </div>
</div>
<!-- Section: Champs personnalisés item -->
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:show-header="false"
:with-top-border="false"
:editable="false"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
</div>
</div> </div>
<!-- Champs personnalisés de la pièce --> <!-- Section: Champs personnalisés machine -->
<CustomFieldDisplay <div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
:fields="displayedCustomFields" <div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
:is-edit-mode="isEditMode" <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
title="Champs personnalisés item" </div>
:editable="false" <div class="p-4">
@field-input="handleCustomFieldInput" <CustomFieldDisplay
@field-blur="handleCustomFieldBlur" :fields="mergedContextFields"
/> :is-edit-mode="isEditMode"
:columns="2"
<template v-if="mergedContextFields.length"> :show-header="false"
<div class="divider my-4 text-xs text-base-content/50"> :with-top-border="false"
Champs personnalisés machine :editable="true"
</div> :emit-blur="false"
<CustomFieldDisplay @field-input="queueContextCustomFieldUpdate"
:fields="mergedContextFields" />
:is-edit-mode="isEditMode" </div>
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span
v-if="isEditMode && selectedFiles.length"
class="badge badge-outline"
>
{{ selectedFiles.length }} fichier{{
selectedFiles.length > 1 ? "s" : ""
}}
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
</span>
</div> </div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50"> <!-- Section: Documents -->
Chargement des documents... <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
</p> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<div class="p-4 space-y-3">
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement des documents...</p>
<DocumentUpload <DocumentUpload
v-if="isEditMode" v-if="isEditMode"
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer des fichiers pour cette pièce" title="Déposer des fichiers pour cette pièce"
subtitle="Formats acceptés : PDF, images, documents..." subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded" @files-added="handleFilesAdded"
/> />
<DocumentListInline <DocumentListInline
:documents="pieceDocuments" :documents="pieceDocuments"
:can-delete="isEditMode" :can-delete="isEditMode"
:can-edit="isEditMode" :can-edit="isEditMode"
:delete-disabled="uploadingDocuments" :delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce." empty-text="Aucun document lié à cette pièce."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal" @edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -321,6 +351,8 @@ import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucideExternalLink from '~icons/lucide/external-link'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { import {
@@ -468,6 +500,24 @@ const mergedContextFields = computed(() => {
return mergeDefinitionsWithValues(definitions, values) return mergeDefinitionsWithValues(definitions, values)
}) })
// Context fields shown as tags on the header (consultation mode)
const visibleContextFieldTags = computed(() =>
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
)
const CONTEXT_FIELD_COLORS = [
'bg-secondary/25 text-secondary border border-secondary/35',
'bg-accent/25 text-accent border border-accent/35',
'bg-info/25 text-info border border-info/35',
'bg-success/25 text-success border border-success/35',
'bg-warning/25 text-warning border border-warning/35',
]
const contextFieldBadgeClass = (field) => {
const idx = visibleContextFieldTags.value.indexOf(field)
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
}
const queueContextCustomFieldUpdate = (field, value) => { const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId const linkId = props.piece?.linkId
if (!linkId || !field) return if (!linkId || !field) return

View File

@@ -44,6 +44,7 @@
<option value="piece">Pièce</option> <option value="piece">Pièce</option>
<option value="product">Produit</option> <option value="product">Produit</option>
<option value="composant">Composant</option> <option value="composant">Composant</option>
<option value="machine">Machine</option>
</select> </select>
</div> </div>
@@ -89,13 +90,16 @@
<template #cell-entity="{ row }"> <template #cell-entity="{ row }">
<NuxtLink <NuxtLink
v-if="row.action !== 'delete'" v-if="row.action !== 'delete' && entityEditLink(row) !== '#'"
:to="entityEditLink(row)" :to="entityEditLink(row)"
class="link link-hover link-primary" class="link link-hover link-primary"
> >
{{ row.entityName || 'Sans nom' }} {{ row.entityName || 'Sans nom' }}
</NuxtLink> </NuxtLink>
<span v-else class="text-base-content/50 line-through"> <span v-else-if="row.action === 'delete'" class="text-base-content/50 line-through">
{{ row.entityName || 'Sans nom' }}
</span>
<span v-else>
{{ row.entityName || 'Sans nom' }} {{ row.entityName || 'Sans nom' }}
</span> </span>
<span <span
@@ -195,19 +199,23 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce', piece: 'Pièce',
product: 'Produit', product: 'Produit',
composant: 'Composant', composant: 'Composant',
machine: 'Machine',
document: 'Document',
model_type: 'Modèle',
} }
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
const ENTITY_EDIT_ROUTES: Record<string, string> = { const ENTITY_ROUTES: Record<string, string> = {
piece: '/pieces', piece: '/piece',
product: '/product', product: '/product',
composant: '/component', composant: '/component',
machine: '/machine',
} }
const entityEditLink = (entry: ActivityLogEntry) => { const entityEditLink = (entry: ActivityLogEntry) => {
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? '' const base = ENTITY_ROUTES[entry.entityType] ?? ''
return base ? `${base}/${entry.entityId}/edit` : '#' return base ? `${base}/${entry.entityId}` : '#'
} }
const actionBadgeClass = (action: string) => { const actionBadgeClass = (action: string) => {

View File

@@ -408,6 +408,9 @@
</header> </header>
<template v-if="isEditMode"> <template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" /> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</template> </template>
<template v-else> <template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -468,6 +471,9 @@
Enregistrer les modifications Enregistrer les modifications
</button> </button>
</div> </div>
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div> </div>
</section> </section>
</main> </main>
@@ -511,6 +517,7 @@ const {
constructeurLinks, constructeurLinks,
constructeurIdsFromForm, constructeurIdsFromForm,
customFieldInputs, customFieldInputs,
requiredCustomFieldsFilled,
historyFieldLabels, historyFieldLabels,
canSubmit, canSubmit,
componentTypeList, componentTypeList,
@@ -538,6 +545,8 @@ const {
formatStructurePreview, formatStructurePreview,
} = useComponentEdit(String(route.params.id)) } = useComponentEdit(String(route.params.id))
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const submitEdition = async () => { const submitEdition = async () => {
await _submitEdition() await _submitEdition()
if (!saving.value) { if (!saving.value) {

View File

@@ -223,6 +223,9 @@
</p> </p>
</header> </header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" /> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</div> </div>
<EmptyState <EmptyState
v-else v-else
@@ -242,6 +245,9 @@
Créer le composant Créer le composant
</button> </button>
</div> </div>
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires avant de créer le composant.
</p>
</div> </div>
</section> </section>
</main> </main>
@@ -290,8 +296,11 @@ const {
resolveProductLabel, resolveProductLabel,
resolveSubcomponentLabel, resolveSubcomponentLabel,
submitCreation, submitCreation,
requiredCustomFieldsFilled,
} = useComponentCreate() } = useComponentCreate()
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const entityTabs = computed(() => [ const entityTabs = computed(() => [
{ key: 'general', label: 'Général' }, { key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure' }, { key: 'structure', label: 'Structure' },

View File

@@ -41,7 +41,7 @@
> >
</div> </div>
</div> </div>
<div class="form-control md:w-64"> <div class="form-control md:w-48">
<label class="label"> <label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
</label> </label>
@@ -58,6 +58,24 @@
</option> </option>
</select> </select>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
</label>
<div class="flex items-center gap-2">
<input
v-model="dateFrom"
type="date"
class="input input-bordered input-sm"
>
<span class="text-xs text-base-content/50">à</span>
<input
v-model="dateTo"
type="date"
class="input input-bordered input-sm"
>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -277,6 +295,8 @@ const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false) const showAddMachineModal = ref(false)
const searchTerm = ref('') const searchTerm = ref('')
const selectedSiteFilter = ref('') const selectedSiteFilter = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const collapsedSites = ref([]) const collapsedSites = ref([])
const preselectedSiteId = ref('') const preselectedSiteId = ref('')
@@ -327,6 +347,25 @@ const filteredSites = computed(() => {
filtered = filtered.filter(site => site.id === selectedSiteFilter.value) filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
} }
// Filtrer les machines par date de création
if (dateFrom.value || dateTo.value) {
const from = dateFrom.value ? new Date(dateFrom.value) : null
const to = dateTo.value ? new Date(dateTo.value) : null
if (from) from.setHours(0, 0, 0, 0)
if (to) to.setHours(23, 59, 59, 999)
filtered = filtered.map((site) => {
const filteredMachines = (site.machines || []).filter((machine) => {
if (!machine.createdAt) return false
const created = new Date(machine.createdAt)
if (from && created < from) return false
if (to && created > to) return false
return true
})
return { ...site, machines: filteredMachines }
}).filter(site => site.machines.length > 0)
}
// Filtrer par terme de recherche // Filtrer par terme de recherche
if (searchTerm.value) { if (searchTerm.value) {
filtered = filtered.filter((site) => { filtered = filtered.filter((site) => {

View File

@@ -5,108 +5,93 @@
<h2 class="text-2xl font-bold"> <h2 class="text-2xl font-bold">
Parc Machines Parc Machines
</h2> </h2>
<NuxtLink to="/machines/new" class="btn btn-primary"> <NuxtLink v-if="canEdit" to="/machines/new" class="btn btn-primary">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Ajouter une machine Ajouter une machine
</NuxtLink> </NuxtLink>
</div> </div>
<div class="card bg-base-100 shadow-sm mb-6"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <DataTable
<div class="form-control"> :columns="columns"
<label class="label"> :rows="filteredMachines"
<span class="label-text">Sites</span> :loading="loading"
</label> :sort="currentSort"
<div class="flex flex-wrap gap-3"> :show-counter="true"
<label empty-message="Aucune machine trouvée."
v-for="site in sites" no-results-message="Aucune machine ne correspond à vos filtres."
:key="site.id" @sort="handleSort"
class="flex items-center gap-2 cursor-pointer" >
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchQuery"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence..."
> >
<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>
<div class="form-control">
<label class="label">
<span class="label-text">Recherche</span>
</label> </label>
<input
v-model="searchQuery" <div class="flex flex-col">
type="text" <span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Site</span>
placeholder="Rechercher par nom ou référence..." <select v-model="selectedSiteId" class="select select-bordered select-sm mt-1">
class="input input-bordered" <option value="">Tous les sites</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
</div>
<div class="flex flex-col">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Date de création</span>
<div class="flex items-center gap-2 mt-1">
<input
v-model="dateFrom"
type="date"
class="input input-bordered input-sm"
>
<span class="text-xs text-base-content/50">à</span>
<input
v-model="dateTo"
type="date"
class="input input-bordered input-sm"
>
</div>
</div>
</template>
<template #cell-site="{ row }">
<span
v-if="row.site"
class="badge badge-sm font-bold"
:style="row.site.color ? { backgroundColor: row.site.color + '30', color: row.site.color, borderColor: row.site.color + '50' } : {}"
:class="!row.site.color ? 'badge-ghost' : ''"
> >
</div> {{ row.site.name }}
</div> </span>
</div> <span v-else class="text-base-content/30"></span>
</div> </template>
<div v-if="loading" class="flex justify-center items-center py-12"> <template #cell-createdAt="{ row }">
<span class="loading loading-spinner loading-lg" /> <span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</div> </template>
<EmptyState <template #cell-actions="{ row }">
v-else-if="filteredMachines.length === 0" <div class="flex items-center justify-end gap-2">
:icon="IconLucideFactory" <button v-if="canEdit" class="btn btn-ghost btn-xs" @click="editMachine(row)">
title="Aucune machine trouvée" Modifier
description="Commencez par ajouter votre première machine." </button>
action-label="Ajouter une machine" <button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDeleteMachine(row)">
action-to="/machines/new" Supprimer
/> </button>
<NuxtLink :to="`/machine/${row.id}`" class="btn btn-primary btn-xs">
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> Détails
<div </NuxtLink>
v-for="machine in filteredMachines"
:key="machine.id"
class="card site-card shadow-md hover:shadow-xl transition-shadow cursor-pointer overflow-hidden"
:style="{
borderTop: machine.site?.color ? `4px solid ${machine.site.color}` : '4px solid transparent',
background: machine.site?.color ? `linear-gradient(160deg, ${machine.site.color}30 0%, ${machine.site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
@click="viewMachineDetails(machine)"
>
<div class="card-body flex flex-col">
<div class="flex items-center justify-between mb-2">
<h3 class="card-title text-lg">
{{ machine.name }}
</h3>
</div>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
<span
class="font-bold text-sm px-2.5 py-1 rounded-lg text-base-content"
:style="machine.site?.color ? { backgroundColor: machine.site.color + '30', border: `1px solid ${machine.site.color}40` } : {}"
>{{ machine.site?.name || 'Site inconnu' }}</span>
</div> </div>
</template>
<div v-if="machine.reference" class="flex items-center gap-2"> </DataTable>
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.reference }}</span>
</div>
</div>
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
Modifier
</button>
<button v-if="canEdit" class="btn btn-ghost btn-sm text-error" @click.stop="confirmDeleteMachine(machine)">
Supprimer
</button>
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
Détails
</NuxtLink>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -114,16 +99,16 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useUrlState } from '~/composables/useUrlState' import { useUrlState } from '~/composables/useUrlState'
import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideTag from '~icons/lucide/tag'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines() const { machines, loading, loadMachines, deleteMachine } = useMachines()
@@ -132,34 +117,46 @@ const toast = useToast()
const urlState = useUrlState({ const urlState = useUrlState({
q: { default: '', debounce: 300 }, q: { default: '', debounce: 300 },
sites: { default: '' }, site: { default: '' },
from: { default: '' },
to: { default: '' },
}) })
const searchQuery = urlState.q const searchQuery = urlState.q
const selectedSites = reactive(new Set()) const selectedSiteId = urlState.site
const dateFrom = urlState.from
const dateTo = urlState.to
// Sync URL → selectedSites on load and back/forward const sortKey = usePersistedValue('machines-sort', 'name')
watch(urlState.sites, (val) => { const sortDir = ref('asc')
selectedSites.clear()
if (val) {
for (const id of String(val).split(',')) {
if (id) selectedSites.add(id)
}
}
}, { immediate: true })
// Sync selectedSites → URL const currentSort = computed(() => ({
watch(() => [...selectedSites], (ids) => { field: sortKey.value,
urlState.sites.value = ids.join(',') direction: sortDir.value,
}) }))
const handleSort = (sort) => {
sortKey.value = sort.field
sortDir.value = sort.direction
}
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence', sortable: true },
{ key: 'site', label: 'Site', sortable: true, sortKey: 'siteName' },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const formatDate = formatFrenchDate
// Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => { const enrichedMachines = computed(() => {
return machines.value.map((machine) => { return machines.value.map((machine) => {
const site = sites.value.find(s => s.id === machine.siteId) const site = sites.value.find(s => s.id === machine.siteId)
return { return {
...machine, ...machine,
site: site || null, site: site || null,
siteName: site?.name || '',
} }
}) })
}) })
@@ -167,29 +164,44 @@ const enrichedMachines = computed(() => {
const filteredMachines = computed(() => { const filteredMachines = computed(() => {
let filtered = enrichedMachines.value let filtered = enrichedMachines.value
if (selectedSites.size > 0) { if (selectedSiteId.value) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId)) filtered = filtered.filter(m => m.siteId === selectedSiteId.value)
} }
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase() const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine => filtered = filtered.filter(m =>
machine.name?.toLowerCase().includes(term) m.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term), || m.reference?.toLowerCase().includes(term),
) )
} }
filtered = [...filtered].sort((a, b) => if (dateFrom.value) {
(a.name || '').localeCompare(b.name || '', 'fr') const from = new Date(dateFrom.value)
) from.setHours(0, 0, 0, 0)
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) >= from)
}
if (dateTo.value) {
const to = new Date(dateTo.value)
to.setHours(23, 59, 59, 999)
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) <= to)
}
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
filtered = [...filtered].sort((a, b) => {
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
}
const valA = (key === 'siteName' ? a.siteName : a[key]) || ''
const valB = (key === 'siteName' ? b.siteName : b[key]) || ''
return dir * String(valA).localeCompare(String(valB), 'fr')
})
return filtered return filtered
}) })
const viewMachineDetails = (machine) => {
navigateTo(`/machine/${machine.id}`)
}
const editMachine = (machine) => { const editMachine = (machine) => {
navigateTo(`/machine/${machine.id}?edit=true`) navigateTo(`/machine/${machine.id}?edit=true`)
} }

View File

@@ -127,6 +127,12 @@ php-cs-fixer-allow-risky:
test: test:
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES) $(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
test-front:
cd frontend && npx vitest run $(FILES)
test-front-watch:
cd frontend && npx vitest --watch $(FILES)
test-setup: test-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test $(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test
$(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test $(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test