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:
151
app/components/common/EntityVersionList.vue
Normal file
151
app/components/common/EntityVersionList.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Versions</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Historique des versions avec possibilite de restauration.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="versions.length" class="badge badge-outline">
|
||||
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement des versions...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
|
||||
Aucune version enregistree.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in versions"
|
||||
:key="entry.version"
|
||||
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
|
||||
<span
|
||||
v-if="entry.version === currentVersion"
|
||||
class="badge badge-primary badge-sm"
|
||||
>
|
||||
actuelle
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.action === 'restore'"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
restauration
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||
<span>{{ actionLabel(entry.action) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(entry.createdAt) }}</span>
|
||||
<span v-if="entry.actor">· {{ entry.actor.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canRestore && entry.version !== currentVersion"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="restoring"
|
||||
@click="handleRestore(entry.version)"
|
||||
>
|
||||
Restaurer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<VersionRestoreModal
|
||||
:visible="modalVisible"
|
||||
:preview="previewData"
|
||||
:restoring="restoring"
|
||||
:field-labels="fieldLabels"
|
||||
:entity-type="entityType"
|
||||
@close="modalVisible = false"
|
||||
@confirm="confirmRestore"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, toRef } from 'vue'
|
||||
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
|
||||
import VersionRestoreModal from './VersionRestoreModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
entityId: string
|
||||
fieldLabels: Record<string, string>
|
||||
/** Increment this value to force a refresh of the versions list */
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
restored: []
|
||||
}>()
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const canRestore = computed(() => canEdit.value)
|
||||
|
||||
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
|
||||
entityType: props.entityType,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
|
||||
const currentVersion = computed(() => {
|
||||
if (versions.value.length === 0) return null
|
||||
return versions.value[0]?.version ?? null
|
||||
})
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const previewData = ref<RestorePreview | null>(null)
|
||||
const restoring = ref(false)
|
||||
const targetVersion = ref<number | null>(null)
|
||||
|
||||
const actionLabel = (action: string) => historyActionLabel(action)
|
||||
const formatDate = (date: string) => formatHistoryDate(date)
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
targetVersion.value = version
|
||||
previewData.value = null
|
||||
modalVisible.value = true
|
||||
previewData.value = await fetchPreview(version)
|
||||
}
|
||||
|
||||
const confirmRestore = async () => {
|
||||
if (!targetVersion.value) return
|
||||
restoring.value = true
|
||||
const result = await restore(targetVersion.value)
|
||||
restoring.value = false
|
||||
if (result?.success) {
|
||||
modalVisible.value = false
|
||||
await fetchVersions()
|
||||
emit('restored')
|
||||
}
|
||||
else {
|
||||
error.value = 'La restauration a echoue.'
|
||||
modalVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVersions()
|
||||
})
|
||||
|
||||
// Auto-refresh when parent signals a data change
|
||||
watch(toRef(props, 'refreshKey'), () => {
|
||||
fetchVersions()
|
||||
})
|
||||
</script>
|
||||
197
app/components/common/VersionRestoreModal.vue
Normal file
197
app/components/common/VersionRestoreModal.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
|
||||
<div class="modal-box max-w-lg">
|
||||
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
|
||||
|
||||
<div v-if="!preview" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Restore mode explanation -->
|
||||
<div
|
||||
class="alert text-sm"
|
||||
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- FULL MODE -->
|
||||
<template v-if="preview.restoreMode === 'full'">
|
||||
<span class="font-semibold">Restauration complete</span>
|
||||
|
||||
<!-- Machine: always full, no category -->
|
||||
<template v-if="entityType === 'machine'">
|
||||
<span>Tous les elements de la machine seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix</li>
|
||||
<li>Site</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-else-if="entityType === 'composant'">
|
||||
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Structure : pieces, sous-composants et produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- PARTIAL MODE (never for machines) -->
|
||||
<template v-else>
|
||||
<span class="font-semibold">Restauration partielle</span>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-if="entityType === 'composant'">
|
||||
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Produits lies actuels</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff -->
|
||||
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
|
||||
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<li
|
||||
v-for="(change, field) in preview.diff"
|
||||
:key="field"
|
||||
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
|
||||
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
|
||||
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-sm text-base-content/60">
|
||||
Aucune difference detectee — l'entite est deja dans l'etat de cette version.
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="preview.warnings.length" class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="(warning, i) in preview.warnings"
|
||||
:key="i"
|
||||
class="alert alert-warning py-2 text-xs"
|
||||
>
|
||||
{{ warning.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
|
||||
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
|
||||
Confirmer la restauration
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RestorePreview } from '~/composables/useEntityVersions'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
preview: RestorePreview | null
|
||||
restoring: boolean
|
||||
fieldLabels: Record<string, string>
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const formatFieldLabel = (field: string): string => {
|
||||
if (field.startsWith('customField:')) {
|
||||
return `Champ perso : ${field.replace('customField:', '')}`
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user