Compare commits
4 Commits
d197d30eb0
...
c82c21c0cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c82c21c0cd | ||
|
|
a339e722a6 | ||
|
|
a7415964a7 | ||
|
|
767c9a7424 |
@@ -42,6 +42,7 @@
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
||||
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||
<template v-if="pieceConstructeursDisplay.length">
|
||||
<span
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
@@ -106,6 +107,10 @@
|
||||
pieceData.reference || "Non définie"
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="pieceData.referenceAuto">
|
||||
<span class="font-medium">Référence auto:</span>
|
||||
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Fournisseur:</span>
|
||||
<div v-if="!isEditMode" class="ml-2">
|
||||
@@ -301,6 +306,7 @@ const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||
const pieceData = reactive({
|
||||
name: props.piece.name || '',
|
||||
reference: props.piece.reference || '',
|
||||
referenceAuto: props.piece.referenceAuto || null,
|
||||
prix: props.piece.prix || '',
|
||||
productId: props.piece.product?.id || props.piece.productId || null,
|
||||
quantity: props.piece.quantity ?? 1,
|
||||
|
||||
170
app/components/common/EntityVersionList.vue
Normal file
170
app/components/common/EntityVersionList.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<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 v-if="entry.diff && Object.keys(entry.diff).length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(change, field) in entry.diff"
|
||||
:key="field"
|
||||
class="badge badge-ghost badge-xs"
|
||||
>
|
||||
{{ formatDiffEntry(String(field), change) }}
|
||||
</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 formatDiffEntry = (field: string, change: { from: unknown; to: unknown }): string => {
|
||||
const label = props.fieldLabels[field] || field
|
||||
// Link changes (addedComponent, removedPiece, etc.) have {id, name} as value
|
||||
const val = change.to ?? change.from
|
||||
if (val && typeof val === 'object' && 'name' in (val as Record<string, unknown>)) {
|
||||
return `${label}: ${(val as Record<string, unknown>).name}`
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
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>
|
||||
198
app/components/common/VersionRestoreModal.vue
Normal file
198
app/components/common/VersionRestoreModal.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<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>Composants, pieces et produits lies</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>
|
||||
@@ -1,19 +1,8 @@
|
||||
<template>
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Définitions des champs personnalisés
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="saving"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs" />
|
||||
Enregistrer les champs
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Définitions des champs personnalisés
|
||||
</h3>
|
||||
|
||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
|
||||
@@ -117,7 +106,6 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'add-field': []
|
||||
'remove-field': [index: number]
|
||||
}>()
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('blur-field')"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineName }}
|
||||
@@ -28,7 +27,7 @@
|
||||
v-if="isEditMode"
|
||||
:value="machineSiteId"
|
||||
class="select select-bordered"
|
||||
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"
|
||||
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner un site</option>
|
||||
<option
|
||||
@@ -54,7 +53,6 @@
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('blur-field')"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineReference }}
|
||||
@@ -115,7 +113,6 @@
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
@@ -124,7 +121,6 @@
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
@@ -132,7 +128,6 @@
|
||||
class="select select-bordered select-sm"
|
||||
:required="field.required"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
@@ -149,7 +144,6 @@
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
>
|
||||
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
@@ -160,7 +154,6 @@
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<div v-else class="text-xs text-error">
|
||||
Type de champ non pris en charge
|
||||
@@ -184,7 +177,6 @@
|
||||
:on-drag-enter="fieldDefs.onDragEnter"
|
||||
:on-drop="fieldDefs.onDrop"
|
||||
:on-drag-end="fieldDefs.onDragEnd"
|
||||
@save="fieldDefs.saveDefinitions()"
|
||||
@add-field="fieldDefs.addField()"
|
||||
@remove-field="fieldDefs.removeField($event)"
|
||||
/>
|
||||
@@ -224,9 +216,7 @@ const emit = defineEmits<{
|
||||
'update:machine-reference': [value: string]
|
||||
'update:machine-site-id': [value: string]
|
||||
'update:constructeur-ids': [ids: unknown]
|
||||
'blur-field': []
|
||||
'set-custom-field-value': [field: any, value: unknown]
|
||||
'update-custom-field': [field: any]
|
||||
'custom-fields-saved': []
|
||||
}>()
|
||||
|
||||
@@ -239,4 +229,8 @@ const fieldDefs = useMachineCustomFieldDefs({
|
||||
watch(() => props.machineCustomFieldDefs, (newDefs) => {
|
||||
fieldDefs.reinit(newDefs)
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -108,6 +108,13 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<ReferenceFormulaBuilder
|
||||
v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
|
||||
v-model="form.referenceFormula"
|
||||
:custom-fields="formulaBuilderCustomFields"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
|
||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
Annuler
|
||||
@@ -177,14 +184,33 @@ const componentSubcomponentMaxDepth = computed(() =>
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly === true)
|
||||
|
||||
const form = reactive<ModelTypePayload>({
|
||||
const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
||||
name: '',
|
||||
code: '',
|
||||
category: props.initialCategory,
|
||||
notes: '',
|
||||
structure: undefined,
|
||||
referenceFormula: null,
|
||||
})
|
||||
|
||||
const formulaBuilderCustomFields = computed(() => {
|
||||
if (form.category === 'PIECE') {
|
||||
const fields = pieceStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
if (form.category === 'COMPONENT') {
|
||||
const fields = componentStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||
if (!formula) return []
|
||||
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
|
||||
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
|
||||
}
|
||||
|
||||
const errors = reactive<{ name?: string }>({})
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -248,6 +274,9 @@ const resetForm = () => {
|
||||
|
||||
errors.name = undefined
|
||||
|
||||
const incomingAny = incoming as Record<string, unknown>
|
||||
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
|
||||
|
||||
resetStructures(incoming.structure, form.category)
|
||||
}
|
||||
|
||||
@@ -286,20 +315,28 @@ const handleSubmit = () => {
|
||||
}
|
||||
|
||||
if (form.category === 'COMPONENT') {
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'COMPONENT',
|
||||
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
|
||||
})
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.category === 'PIECE') {
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'PIECE',
|
||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||
})
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<header>
|
||||
<h3 class="text-lg font-semibold text-base-content">Génération de référence automatique</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
Cliquez sur un champ pour l'insérer dans la formule. Vous pouvez aussi taper du texte libre (séparateurs, préfixes…).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="rounded-lg border border-base-300 p-4 space-y-4">
|
||||
<div v-if="fieldNames.length" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="name in fieldNames"
|
||||
:key="name"
|
||||
type="button"
|
||||
class="btn btn-xs btn-outline btn-primary font-mono"
|
||||
:disabled="disabled"
|
||||
@click="insertField(name)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm text-base-content/50 italic">
|
||||
Aucun champ personnalisé défini dans la structure.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="label" for="reference-formula">
|
||||
<span class="label-text">Formule</span>
|
||||
</label>
|
||||
<input
|
||||
id="reference-formula"
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
class="input input-bordered w-full font-mono"
|
||||
placeholder="Ex: SNU {serie}-{diametre}/{type}"
|
||||
:disabled="disabled"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Laissez vide si ce type n'utilise pas de référence automatique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="modelValue" class="rounded bg-base-200 px-3 py-2 text-sm">
|
||||
<span class="text-base-content/70">Aperçu :</span>
|
||||
<span class="ml-1 font-mono font-semibold">{{ preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
interface CustomField {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null | undefined
|
||||
customFields: CustomField[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const fieldNames = computed(() =>
|
||||
props.customFields.map(f => f.name).filter((n): n is string => Boolean(n)),
|
||||
)
|
||||
|
||||
const previewExamples: Record<string, string> = {
|
||||
text: 'VALEUR',
|
||||
number: '123',
|
||||
select: 'OPTION',
|
||||
boolean: 'OUI',
|
||||
date: '2026-01-01',
|
||||
}
|
||||
|
||||
const preview = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
const fieldMap = new Map<string, string>()
|
||||
for (const f of props.customFields) {
|
||||
if (f.name) {
|
||||
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
|
||||
}
|
||||
}
|
||||
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
|
||||
})
|
||||
|
||||
const insertField = (fieldName: string) => {
|
||||
const placeholder = `{${fieldName}}`
|
||||
const input = inputRef.value
|
||||
const current = props.modelValue ?? ''
|
||||
if (!input) {
|
||||
emit('update:modelValue', current + placeholder)
|
||||
return
|
||||
}
|
||||
const start = input.selectionStart ?? current.length
|
||||
const end = input.selectionEnd ?? start
|
||||
const updated = current.slice(0, start) + placeholder + current.slice(end)
|
||||
emit('update:modelValue', updated)
|
||||
nextTick(() => {
|
||||
const newPos = start + placeholder.length
|
||||
input.focus()
|
||||
input.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
}
|
||||
</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 }
|
||||
}
|
||||
@@ -376,6 +376,58 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
}
|
||||
}
|
||||
|
||||
const saveAllMachineCustomFields = async () => {
|
||||
if (!machine.value) return
|
||||
|
||||
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||
const fieldsToSave = fields.filter(
|
||||
(field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
|
||||
)
|
||||
|
||||
for (const field of fieldsToSave) {
|
||||
const { id: customFieldId, customFieldValueId } = field
|
||||
|
||||
try {
|
||||
if (customFieldValueId) {
|
||||
await updateCustomFieldValueApi(customFieldValueId as string, {
|
||||
value: field.value ?? '',
|
||||
} as any)
|
||||
} else if (customFieldId) {
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId as string,
|
||||
'machine',
|
||||
machine.value.id as string,
|
||||
field.value ?? '',
|
||||
)
|
||||
if (result.success) {
|
||||
const createdValue = result.data as AnyRecord
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
if (!createdValue.customField) {
|
||||
createdValue.customField = {
|
||||
id: customFieldId,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
}
|
||||
}
|
||||
const existingValues = Array.isArray(machine.value.customFieldValues)
|
||||
? (machine.value.customFieldValues as AnyRecord[]).filter(
|
||||
(item) => item.id !== createdValue.id,
|
||||
)
|
||||
: []
|
||||
machine.value.customFieldValues = [...existingValues, createdValue]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
machineCustomFields,
|
||||
@@ -392,5 +444,6 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
saveAllMachineCustomFields,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
const machine = ref<AnyRecord | null>(null)
|
||||
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
|
||||
const printAreaRef = ref<HTMLElement | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
// Machine fields
|
||||
const machineName = ref('')
|
||||
@@ -108,6 +109,12 @@ export function useMachineDetailData(machineId: string) {
|
||||
|
||||
// UI state
|
||||
const isEditMode = ref(false)
|
||||
const canSubmit = computed(() => {
|
||||
if (!machine.value) return false
|
||||
if (saving.value) return false
|
||||
if (!machineName.value.trim()) return false
|
||||
return true
|
||||
})
|
||||
const debug = ref(false)
|
||||
|
||||
const componentsCollapsed = ref(true)
|
||||
@@ -146,6 +153,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
saveAllMachineCustomFields,
|
||||
} = useMachineDetailCustomFields({
|
||||
machine,
|
||||
isEditMode,
|
||||
@@ -302,6 +310,37 @@ export function useMachineDetailData(machineId: string) {
|
||||
pieceCollapseToggleToken.value += 1
|
||||
}
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!machine.value || saving.value) return
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// 1. Save machine info (name, reference, site, constructeurs)
|
||||
await updateMachineInfo()
|
||||
|
||||
// 2. Save all custom field values
|
||||
await saveAllMachineCustomFields()
|
||||
|
||||
// 3. Reload machine data to get fresh state
|
||||
await loadMachineData()
|
||||
|
||||
// 4. Exit edit mode
|
||||
isEditMode.value = false
|
||||
toast.showSuccess('Machine mise à jour avec succès')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde:', error)
|
||||
toast.showError('Erreur lors de la sauvegarde de la machine')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEdition = () => {
|
||||
initMachineFields()
|
||||
syncMachineCustomFields()
|
||||
isEditMode.value = false
|
||||
}
|
||||
|
||||
// Print wrappers
|
||||
const ensurePrintSelectionEntries = () =>
|
||||
_ensurePrintEntries(components.value, machinePieces.value)
|
||||
@@ -451,6 +490,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
updateMachineInfo, updateComponent, updatePieceFromComponent,
|
||||
updatePieceInfo, handleMachineConstructeurChange, editComponent, editPiece,
|
||||
toggleEditMode, toggleAllComponents, collapseAllComponents, toggleAllPieces,
|
||||
saving, canSubmit, submitEdition, cancelEdition,
|
||||
|
||||
// Print
|
||||
printModalOpen, printSelection, ensurePrintSelectionEntries,
|
||||
|
||||
@@ -208,9 +208,8 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMachineConstructeurChange = async (value: unknown) => {
|
||||
const handleMachineConstructeurChange = (value: unknown) => {
|
||||
machineConstructeurIds.value = uniqueConstructeurIds(value)
|
||||
await updateMachineInfo()
|
||||
}
|
||||
|
||||
const editComponent = () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Piece {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
referenceAuto?: string | null
|
||||
description?: string | null
|
||||
typePieceId?: string | null
|
||||
typePiece?: { id: string; name?: string } | null
|
||||
|
||||
@@ -72,6 +72,28 @@ const badgeClass = (type: ChangeType) => {
|
||||
};
|
||||
|
||||
const releases: Release[] = [
|
||||
{
|
||||
version: "v1.9.5",
|
||||
date: "2026-03-31",
|
||||
changes: [
|
||||
{
|
||||
type: "feat",
|
||||
text: "Référence automatique des pièces et composants : génération d'une référence technique à partir d'une formule configurable sur la catégorie, recalculée automatiquement à chaque modification des champs personnalisés",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Formula builder interactif : sélection des champs disponibles par clic (chips) avec insertion à la position du curseur, aperçu live avec valeurs d'exemple, et calcul automatique des champs requis",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Versioning des entités : numéro de version incrémenté automatiquement à chaque modification, avec historique des versions et possibilité de restaurer une version antérieure",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Bouton de sauvegarde unique sur la fiche machine : remplacement des sauvegardes automatiques par un bouton explicite, avec affichage des versions sur les liens composants/pièces/produits",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v1.9.4",
|
||||
date: "2026-03-25",
|
||||
|
||||
@@ -114,6 +114,8 @@ const loadCategory = async () => {
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
|
||||
referenceFormula: response.referenceFormula ?? null,
|
||||
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
|
||||
<!-- Machine Info Card -->
|
||||
<MachineInfoCard
|
||||
ref="machineInfoCardRef"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:machine-name="d.machineName.value"
|
||||
:machine-reference="d.machineReference.value"
|
||||
@@ -71,10 +72,8 @@
|
||||
@update:machine-reference="d.machineReference.value = $event"
|
||||
@update:machine-site-id="d.machineSiteId.value = $event"
|
||||
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
||||
@blur-field="d.updateMachineInfo"
|
||||
@set-custom-field-value="d.setMachineCustomFieldValue"
|
||||
@update-custom-field="d.updateMachineCustomField"
|
||||
@custom-fields-saved="d.loadMachineData()"
|
||||
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
|
||||
/>
|
||||
|
||||
<!-- Documents -->
|
||||
@@ -97,7 +96,7 @@
|
||||
:products="d.machineDirectProducts.value"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
@add-product="openAddModal('product')"
|
||||
@remove-product="d.removeProductLink"
|
||||
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
|
||||
/>
|
||||
|
||||
<!-- Components Section -->
|
||||
@@ -112,7 +111,7 @@
|
||||
@edit-piece="d.updatePieceFromComponent"
|
||||
@custom-field-update="d.updatePieceCustomField"
|
||||
@add-component="openAddModal('component')"
|
||||
@remove-component="d.removeComponentLink"
|
||||
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
|
||||
/>
|
||||
|
||||
<!-- Machine Pieces Section -->
|
||||
@@ -126,7 +125,7 @@
|
||||
@edit-piece="d.editPiece"
|
||||
@custom-field-update="d.updatePieceCustomField"
|
||||
@add-piece="openAddModal('piece')"
|
||||
@remove-piece="d.removePieceLink"
|
||||
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
|
||||
@toggle-collapse="d.toggleAllPieces"
|
||||
/>
|
||||
|
||||
@@ -138,6 +137,27 @@
|
||||
@confirm="handleAddEntity"
|
||||
/>
|
||||
|
||||
<!-- Save / Cancel buttons -->
|
||||
<div v-if="d.isEditMode.value" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
:class="{ 'btn-disabled': d.saving.value }"
|
||||
@click="d.cancelEdition()"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!d.canSubmit.value"
|
||||
@click="submitMachineEdition"
|
||||
>
|
||||
<span v-if="d.saving.value" class="loading loading-spinner loading-sm mr-2" />
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Historique -->
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
@@ -146,6 +166,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 +232,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 +244,9 @@ if (!machineId) {
|
||||
}
|
||||
|
||||
const d = useMachineDetailData(machineId)
|
||||
const machineInfoCardRef = ref(null)
|
||||
const versionRefreshKey = ref(0)
|
||||
const refreshVersions = () => { versionRefreshKey.value++ }
|
||||
|
||||
const {
|
||||
history,
|
||||
@@ -226,6 +261,15 @@ const historyFieldLabels = {
|
||||
prix: 'Prix',
|
||||
site: 'Site',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
addedComponent: 'Composant ajouté',
|
||||
removedComponent: 'Composant supprimé',
|
||||
addedPiece: 'Pièce ajoutée',
|
||||
removedPiece: 'Pièce supprimée',
|
||||
addedProduct: 'Produit ajouté',
|
||||
removedProduct: 'Produit supprimé',
|
||||
componentLinks: 'Composants liés',
|
||||
pieceLinks: 'Pièces liées',
|
||||
productLinks: 'Produits liés',
|
||||
}
|
||||
|
||||
const addModalOpen = ref(false)
|
||||
@@ -244,12 +288,21 @@ const handleAddEntity = async (entityId) => {
|
||||
} else {
|
||||
await d.addProductLink(entityId)
|
||||
}
|
||||
refreshVersions()
|
||||
}
|
||||
|
||||
const machineViewTitle = computed(() => {
|
||||
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
|
||||
})
|
||||
|
||||
const submitMachineEdition = async () => {
|
||||
if (machineInfoCardRef.value?.saveFieldDefinitions) {
|
||||
await machineInfoCardRef.value.saveFieldDefinitions()
|
||||
}
|
||||
await d.submitEdition()
|
||||
refreshVersions()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
d.loadMachineData()
|
||||
d.loadInitialData()
|
||||
|
||||
@@ -113,6 +113,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Référence auto (read-only, shown only if computed) -->
|
||||
<div v-if="piece.referenceAuto" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence auto</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
|
||||
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
|
||||
<span class="badge badge-sm badge-ghost">auto</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
||||
<div
|
||||
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
|
||||
@@ -181,17 +192,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 +215,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 +235,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 +354,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 +402,7 @@ const { getConstructeurById } = useConstructeurs()
|
||||
const { updateDocument } = useDocuments()
|
||||
|
||||
const isEditMode = ref(false)
|
||||
const versionRefreshKey = ref(0)
|
||||
|
||||
const {
|
||||
piece,
|
||||
@@ -410,12 +444,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(
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
{{ row.piece.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-referenceAuto="{ row }">
|
||||
{{ row.piece.referenceAuto || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.piece.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
||||
@@ -174,6 +178,7 @@ const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'referenceAuto', label: 'Réf. auto' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
|
||||
@@ -114,6 +114,19 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="piece?.referenceAuto" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence auto</span>
|
||||
</label>
|
||||
<input
|
||||
:value="piece.referenceAuto"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md bg-base-200"
|
||||
disabled
|
||||
title="Générée automatiquement à partir du type et des champs personnalisés"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseur</span>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -23,11 +23,15 @@ export interface BaseModelTypePayload {
|
||||
export interface ComponentModelTypePayload extends BaseModelTypePayload {
|
||||
category: 'COMPONENT';
|
||||
structure?: ComponentModelStructure | null;
|
||||
referenceFormula?: string | null;
|
||||
requiredFieldsForReference?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PieceModelTypePayload extends BaseModelTypePayload {
|
||||
category: 'PIECE';
|
||||
structure?: PieceModelStructure | null;
|
||||
referenceFormula?: string | null;
|
||||
requiredFieldsForReference?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ProductModelTypePayload extends BaseModelTypePayload {
|
||||
@@ -46,6 +50,8 @@ export interface ModelType extends BaseModelTypePayload {
|
||||
updatedAt: string;
|
||||
category: ModelCategory;
|
||||
structure: ModelTypeStructure;
|
||||
referenceFormula?: string | null;
|
||||
requiredFieldsForReference?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ModelTypeListParams {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
export const historyActionLabel = (action: string): string => {
|
||||
if (action === 'create') return 'Création'
|
||||
if (action === 'delete') return 'Suppression'
|
||||
if (action === 'restore') return 'Restauration'
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user