feat(versioning) : add entity versioning frontend with restore flow

- useEntityVersions composable (list, preview, restore API calls)
- EntityVersionList component with auto-refresh after save
- VersionRestoreModal with context-aware messages per entity type
- Integrate into machine, composant, piece, product detail pages
- Add restore action label to historyDisplayUtils
- Show structure slots in composant/piece consultation mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-26 14:58:39 +01:00
parent d197d30eb0
commit 767c9a7424
9 changed files with 573 additions and 43 deletions

View File

@@ -316,11 +316,19 @@
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchComponent()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="async () => { await submitEdition(); versionRefreshKey++ }">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
@@ -349,6 +357,7 @@ import { useDocuments } from '~/composables/useDocuments'
const route = useRoute()
const { updateDocument } = useDocuments()
const versionRefreshKey = ref(0)
const {
component,
@@ -389,6 +398,7 @@ const {
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
fetchComponent,
} = useComponentEdit(String(route.params.id))
const editingDocument = ref<any | null>(null)

View File

@@ -207,15 +207,15 @@
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Skeleton slot selections (edit mode only) -->
<!-- Skeleton slot selections -->
<div
v-if="isEditMode && (pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length)"
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Sélections du squelette</h2>
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
<p class="text-xs text-base-content/70">
Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
</p>
</header>
@@ -230,26 +230,32 @@
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
<template v-if="isEditMode">
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
{{ resolvePieceLabel(slot.selectedPieceId) || '— Non sélectionné' }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</div>
</div>
</div>
@@ -266,12 +272,17 @@
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
<template v-if="isEditMode">
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ resolveProductLabel(slot.selectedProductId) || '— Non sélectionné' }}
</div>
</div>
</div>
</div>
@@ -287,12 +298,17 @@
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
<template v-if="isEditMode">
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ resolveSubcomponentLabel(slot.selectedComponentId) || '— Non sélectionné' }}
</div>
</div>
</div>
</div>

View File

@@ -71,10 +71,10 @@
@update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@blur-field="() => { d.updateMachineInfo(); refreshVersions() }"
@set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField"
@custom-fields-saved="d.loadMachineData()"
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
/>
<!-- Documents -->
@@ -146,6 +146,17 @@
:field-labels="historyFieldLabels"
/>
<!-- Versions -->
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
@@ -201,6 +212,7 @@ import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vu
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
import EntityHistorySection from '~/components/common/EntityHistorySection.vue'
import EntityVersionList from '~/components/common/EntityVersionList.vue'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute()
@@ -212,6 +224,8 @@ if (!machineId) {
}
const d = useMachineDetailData(machineId)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const {
history,

View File

@@ -181,17 +181,17 @@
</div>
</div>
<!-- Product requirements (edit mode only) -->
<!-- Product requirements -->
<div
v-if="isEditMode && structureProducts.length"
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
Produits liés
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
@@ -204,7 +204,7 @@
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
@@ -224,6 +224,20 @@
/>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ entry.label }}</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ productSelectionLabels[index] || '— Non sélectionné' }}
</div>
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
@@ -329,6 +343,14 @@
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
@@ -369,6 +391,7 @@ const { getConstructeurById } = useConstructeurs()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const {
piece,
@@ -410,12 +433,22 @@ const submitEdition = async () => {
if (!saving.value) {
await fetchPiece()
isEditMode.value = false
versionRefreshKey.value++
}
}
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
// Resolve product names for read-only display from piece data
const productSelectionLabels = computed(() => {
if (!piece.value) return []
const p = piece.value as any
// piece.product contains {id, name} for the legacy single product
if (p.product?.name) return [p.product.name]
return productSelections.value.map((id: string | null) => id || null)
})
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(

View File

@@ -189,6 +189,14 @@
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="loadProduct()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
@@ -243,6 +251,7 @@ import {
} from '~/shared/utils/customFieldFormUtils'
const { canEdit } = usePermissions()
const versionRefreshKey = ref(0)
const route = useRoute()
const router = useRouter()
const toast = useToast()
@@ -510,6 +519,7 @@ const submitEdition = async () => {
return
}
toast.showSuccess('Produit mis à jour avec succès')
versionRefreshKey.value++
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')