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>
|
||||||
98
app/composables/useEntityVersions.ts
Normal file
98
app/composables/useEntityVersions.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { ref, toValue } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import type { MaybeRef } from 'vue'
|
||||||
|
|
||||||
|
export interface VersionEntry {
|
||||||
|
version: number
|
||||||
|
action: 'create' | 'update' | 'restore' | string
|
||||||
|
createdAt: string
|
||||||
|
actor: { id: string; label: string } | null
|
||||||
|
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestorePreview {
|
||||||
|
version: number
|
||||||
|
restoreMode: 'full' | 'partial'
|
||||||
|
diff: Record<string, { current: unknown; restored: unknown }>
|
||||||
|
warnings: Array<{
|
||||||
|
field: string
|
||||||
|
message: string
|
||||||
|
missingEntityId: string | null
|
||||||
|
missingEntityName: string | null
|
||||||
|
}>
|
||||||
|
snapshot: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreResult {
|
||||||
|
success: boolean
|
||||||
|
newVersion: number
|
||||||
|
restoredFromVersion: number
|
||||||
|
restoreMode: 'full' | 'partial'
|
||||||
|
warnings: RestorePreview['warnings']
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||||
|
machine: '/machines',
|
||||||
|
composant: '/composants',
|
||||||
|
piece: '/pieces',
|
||||||
|
product: '/products',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
entityType: MaybeRef<string>
|
||||||
|
entityId: MaybeRef<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntityVersions(deps: Deps) {
|
||||||
|
const { get, post } = useApi()
|
||||||
|
|
||||||
|
const versions = ref<VersionEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const getPath = () => {
|
||||||
|
const type = toValue(deps.entityType)
|
||||||
|
const id = toValue(deps.entityId)
|
||||||
|
const base = ENTITY_ENDPOINTS[type]
|
||||||
|
return `${base}/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchVersions = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await get(`${getPath()}/versions`)
|
||||||
|
if (!result.success) {
|
||||||
|
error.value = result.error ?? 'Impossible de charger les versions.'
|
||||||
|
versions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
versions.value = result.data?.items ?? []
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
error.value = err?.message ?? 'Erreur inconnue'
|
||||||
|
versions.value = []
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
|
||||||
|
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const restore = async (version: number): Promise<RestoreResult | null> => {
|
||||||
|
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
return { versions, loading, error, fetchVersions, fetchPreview, restore }
|
||||||
|
}
|
||||||
@@ -316,11 +316,19 @@
|
|||||||
:field-labels="historyFieldLabels"
|
: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">
|
<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 }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
</NuxtLink>
|
</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" />
|
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
@@ -349,6 +357,7 @@ import { useDocuments } from '~/composables/useDocuments'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { updateDocument } = useDocuments()
|
const { updateDocument } = useDocuments()
|
||||||
|
const versionRefreshKey = ref(0)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
component,
|
component,
|
||||||
@@ -389,6 +398,7 @@ const {
|
|||||||
resolveProductLabel,
|
resolveProductLabel,
|
||||||
resolveSubcomponentLabel,
|
resolveSubcomponentLabel,
|
||||||
formatStructurePreview,
|
formatStructurePreview,
|
||||||
|
fetchComponent,
|
||||||
} = useComponentEdit(String(route.params.id))
|
} = useComponentEdit(String(route.params.id))
|
||||||
|
|
||||||
const editingDocument = ref<any | null>(null)
|
const editingDocument = ref<any | null>(null)
|
||||||
|
|||||||
@@ -207,15 +207,15 @@
|
|||||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Skeleton slot selections (edit mode only) -->
|
<!-- Skeleton slot selections -->
|
||||||
<div
|
<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"
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
||||||
>
|
>
|
||||||
<header class="space-y-1">
|
<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">
|
<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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -230,26 +230,32 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-start gap-2">
|
<template v-if="isEditMode">
|
||||||
<div class="flex-1">
|
<div class="flex items-start gap-2">
|
||||||
<PieceSelect
|
<div class="flex-1">
|
||||||
:model-value="slot.selectedPieceId"
|
<PieceSelect
|
||||||
:disabled="!canEdit || saving"
|
:model-value="slot.selectedPieceId"
|
||||||
:type-piece-id="slot.typePieceId"
|
:disabled="!canEdit || saving"
|
||||||
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
|
:type-piece-id="slot.typePieceId"
|
||||||
/>
|
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
|
||||||
</div>
|
/>
|
||||||
<div class="w-20 shrink-0">
|
</div>
|
||||||
<input
|
<div class="w-20 shrink-0">
|
||||||
type="number"
|
<input
|
||||||
:value="slot.quantity"
|
type="number"
|
||||||
min="1"
|
:value="slot.quantity"
|
||||||
class="input input-bordered input-sm w-full text-center"
|
min="1"
|
||||||
:disabled="!canEdit || saving"
|
class="input input-bordered input-sm w-full text-center"
|
||||||
title="Quantité"
|
:disabled="!canEdit || saving"
|
||||||
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
title="Quantité"
|
||||||
>
|
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,12 +272,17 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<ProductSelect
|
<template v-if="isEditMode">
|
||||||
:model-value="slot.selectedProductId"
|
<ProductSelect
|
||||||
:disabled="!canEdit || saving"
|
:model-value="slot.selectedProductId"
|
||||||
:type-product-id="slot.typeProductId"
|
:disabled="!canEdit || saving"
|
||||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,12 +298,17 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<ComposantSelect
|
<template v-if="isEditMode">
|
||||||
:model-value="slot.selectedComponentId"
|
<ComposantSelect
|
||||||
:disabled="!canEdit || saving"
|
:model-value="slot.selectedComponentId"
|
||||||
:type-composant-id="slot.typeComposantId"
|
:disabled="!canEdit || saving"
|
||||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,10 +71,10 @@
|
|||||||
@update:machine-reference="d.machineReference.value = $event"
|
@update:machine-reference="d.machineReference.value = $event"
|
||||||
@update:machine-site-id="d.machineSiteId.value = $event"
|
@update:machine-site-id="d.machineSiteId.value = $event"
|
||||||
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
||||||
@blur-field="d.updateMachineInfo"
|
@blur-field="() => { d.updateMachineInfo(); refreshVersions() }"
|
||||||
@set-custom-field-value="d.setMachineCustomFieldValue"
|
@set-custom-field-value="d.setMachineCustomFieldValue"
|
||||||
@update-custom-field="d.updateMachineCustomField"
|
@update-custom-field="d.updateMachineCustomField"
|
||||||
@custom-fields-saved="d.loadMachineData()"
|
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Documents -->
|
<!-- Documents -->
|
||||||
@@ -146,6 +146,17 @@
|
|||||||
:field-labels="historyFieldLabels"
|
:field-labels="historyFieldLabels"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Versions -->
|
||||||
|
<EntityVersionList
|
||||||
|
ref="versionListRef"
|
||||||
|
entity-type="machine"
|
||||||
|
:entity-id="String(machineId)"
|
||||||
|
:field-labels="historyFieldLabels"
|
||||||
|
:refresh-key="versionRefreshKey"
|
||||||
|
@restored="d.loadMachineData()"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Comments -->
|
<!-- Comments -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<CommentSection
|
<CommentSection
|
||||||
@@ -201,6 +212,7 @@ import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vu
|
|||||||
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
|
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
|
||||||
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
|
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
|
||||||
import EntityHistorySection from '~/components/common/EntityHistorySection.vue'
|
import EntityHistorySection from '~/components/common/EntityHistorySection.vue'
|
||||||
|
import EntityVersionList from '~/components/common/EntityVersionList.vue'
|
||||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -212,6 +224,8 @@ if (!machineId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const d = useMachineDetailData(machineId)
|
const d = useMachineDetailData(machineId)
|
||||||
|
const versionRefreshKey = ref(0)
|
||||||
|
const refreshVersions = () => { versionRefreshKey.value++ }
|
||||||
|
|
||||||
const {
|
const {
|
||||||
history,
|
history,
|
||||||
|
|||||||
@@ -181,17 +181,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product requirements (edit mode only) -->
|
<!-- Product requirements -->
|
||||||
<div
|
<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"
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||||
>
|
>
|
||||||
<header class="space-y-1">
|
<header class="space-y-1">
|
||||||
<h2 class="font-semibold text-base-content">
|
<h2 class="font-semibold text-base-content">
|
||||||
Produit requis par le squelette
|
Produits liés
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-base-content/70">
|
<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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<ul class="space-y-2 text-sm text-base-content/80">
|
<ul class="space-y-2 text-sm text-base-content/80">
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<span>{{ description }}</span>
|
<span>{{ description }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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
|
<div
|
||||||
v-for="entry in productRequirementEntries"
|
v-for="entry in productRequirementEntries"
|
||||||
:key="entry.key"
|
:key="entry.key"
|
||||||
@@ -224,6 +224,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Skeleton preview (edit mode only) -->
|
<!-- Skeleton preview (edit mode only) -->
|
||||||
@@ -329,6 +343,14 @@
|
|||||||
:field-labels="historyFieldLabels"
|
: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) -->
|
<!-- Save buttons (edit mode only) -->
|
||||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<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">
|
<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 { updateDocument } = useDocuments()
|
||||||
|
|
||||||
const isEditMode = ref(false)
|
const isEditMode = ref(false)
|
||||||
|
const versionRefreshKey = ref(0)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
piece,
|
piece,
|
||||||
@@ -410,12 +433,22 @@ const submitEdition = async () => {
|
|||||||
if (!saving.value) {
|
if (!saving.value) {
|
||||||
await fetchPiece()
|
await fetchPiece()
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
|
versionRefreshKey.value++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const editingDocument = ref<any | null>(null)
|
const editingDocument = ref<any | null>(null)
|
||||||
const editModalVisible = ref(false)
|
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(() => {
|
const visibleCustomFields = computed(() => {
|
||||||
if (isEditMode.value) return customFieldInputs.value
|
if (isEditMode.value) return customFieldInputs.value
|
||||||
return customFieldInputs.value.filter(
|
return customFieldInputs.value.filter(
|
||||||
|
|||||||
@@ -189,6 +189,14 @@
|
|||||||
:field-labels="historyFieldLabels"
|
: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">
|
<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 }">
|
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -243,6 +251,7 @@ import {
|
|||||||
} from '~/shared/utils/customFieldFormUtils'
|
} from '~/shared/utils/customFieldFormUtils'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
|
const versionRefreshKey = ref(0)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -510,6 +519,7 @@ const submitEdition = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast.showSuccess('Produit mis à jour avec succès')
|
toast.showSuccess('Produit mis à jour avec succès')
|
||||||
|
versionRefreshKey.value++
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
export const historyActionLabel = (action: string): string => {
|
export const historyActionLabel = (action: string): string => {
|
||||||
if (action === 'create') return 'Création'
|
if (action === 'create') return 'Création'
|
||||||
if (action === 'delete') return 'Suppression'
|
if (action === 'delete') return 'Suppression'
|
||||||
|
if (action === 'restore') return 'Restauration'
|
||||||
return 'Modification'
|
return 'Modification'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user