diff --git a/app/composables/useComponentHistory.ts b/app/composables/useComponentHistory.ts
new file mode 100644
index 0000000..314c1ba
--- /dev/null
+++ b/app/composables/useComponentHistory.ts
@@ -0,0 +1,67 @@
+import { ref } from 'vue'
+import { useApi } from '~/composables/useApi'
+
+export type ComponentHistoryActor = {
+ id: string
+ label: string
+}
+
+export type ComponentHistoryEntry = {
+ id: string
+ action: 'create' | 'update' | 'delete' | string
+ createdAt: string
+ actor: ComponentHistoryActor | null
+ diff: Record | null
+ snapshot: Record | null
+}
+
+const extractItems = (payload: any): ComponentHistoryEntry[] => {
+ if (Array.isArray(payload?.items)) {
+ return payload.items
+ }
+ if (Array.isArray(payload?.member)) {
+ return payload.member
+ }
+ if (Array.isArray(payload?.['hydra:member'])) {
+ return payload['hydra:member']
+ }
+ return []
+}
+
+export function useComponentHistory () {
+ const { get } = useApi()
+
+ const history = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+
+ const loadHistory = async (componentId: string) => {
+ loading.value = true
+ error.value = null
+ try {
+ const result = await get(`/composants/${componentId}/history`)
+ if (!result.success) {
+ error.value = result.error ?? 'Impossible de charger l’historique.'
+ history.value = []
+ return result
+ }
+ history.value = extractItems(result.data) as ComponentHistoryEntry[]
+ return { success: true, data: history.value }
+ } catch (err: any) {
+ const message = err?.message ?? 'Erreur inconnue'
+ error.value = message
+ history.value = []
+ return { success: false, error: message }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return {
+ history,
+ loading,
+ error,
+ loadHistory,
+ }
+}
+
diff --git a/app/composables/usePieceHistory.ts b/app/composables/usePieceHistory.ts
new file mode 100644
index 0000000..33fd9ae
--- /dev/null
+++ b/app/composables/usePieceHistory.ts
@@ -0,0 +1,67 @@
+import { ref } from 'vue'
+import { useApi } from '~/composables/useApi'
+
+export type PieceHistoryActor = {
+ id: string
+ label: string
+}
+
+export type PieceHistoryEntry = {
+ id: string
+ action: 'create' | 'update' | 'delete' | string
+ createdAt: string
+ actor: PieceHistoryActor | null
+ diff: Record | null
+ snapshot: Record | null
+}
+
+const extractItems = (payload: any): PieceHistoryEntry[] => {
+ if (Array.isArray(payload?.items)) {
+ return payload.items
+ }
+ if (Array.isArray(payload?.member)) {
+ return payload.member
+ }
+ if (Array.isArray(payload?.['hydra:member'])) {
+ return payload['hydra:member']
+ }
+ return []
+}
+
+export function usePieceHistory () {
+ const { get } = useApi()
+
+ const history = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+
+ const loadHistory = async (pieceId: string) => {
+ loading.value = true
+ error.value = null
+ try {
+ const result = await get(`/pieces/${pieceId}/history`)
+ if (!result.success) {
+ error.value = result.error ?? 'Impossible de charger l’historique.'
+ history.value = []
+ return result
+ }
+ history.value = extractItems(result.data) as PieceHistoryEntry[]
+ return { success: true, data: history.value }
+ } catch (err: any) {
+ const message = err?.message ?? 'Erreur inconnue'
+ error.value = message
+ history.value = []
+ return { success: false, error: message }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return {
+ history,
+ loading,
+ error,
+ loadHistory,
+ }
+}
+
diff --git a/app/composables/useProductHistory.ts b/app/composables/useProductHistory.ts
new file mode 100644
index 0000000..8923cf0
--- /dev/null
+++ b/app/composables/useProductHistory.ts
@@ -0,0 +1,67 @@
+import { ref } from 'vue'
+import { useApi } from '~/composables/useApi'
+
+export type ProductHistoryActor = {
+ id: string
+ label: string
+}
+
+export type ProductHistoryEntry = {
+ id: string
+ action: 'create' | 'update' | 'delete' | string
+ createdAt: string
+ actor: ProductHistoryActor | null
+ diff: Record | null
+ snapshot: Record | null
+}
+
+const extractItems = (payload: any): ProductHistoryEntry[] => {
+ if (Array.isArray(payload?.items)) {
+ return payload.items
+ }
+ if (Array.isArray(payload?.member)) {
+ return payload.member
+ }
+ if (Array.isArray(payload?.['hydra:member'])) {
+ return payload['hydra:member']
+ }
+ return []
+}
+
+export function useProductHistory () {
+ const { get } = useApi()
+
+ const history = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+
+ const loadHistory = async (productId: string) => {
+ loading.value = true
+ error.value = null
+ try {
+ const result = await get(`/products/${productId}/history`)
+ if (!result.success) {
+ error.value = result.error ?? 'Impossible de charger l’historique.'
+ history.value = []
+ return result
+ }
+ history.value = extractItems(result.data) as ProductHistoryEntry[]
+ return { success: true, data: history.value }
+ } catch (err: any) {
+ const message = err?.message ?? 'Erreur inconnue'
+ error.value = message
+ history.value = []
+ return { success: false, error: message }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return {
+ history,
+ loading,
+ error,
+ loadHistory,
+ }
+}
+
diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue
index d550d27..4e7701f 100644
--- a/app/pages/component/[id]/edit.vue
+++ b/app/pages/component/[id]/edit.vue
@@ -434,6 +434,74 @@
+
+
+
+
+
+ Chargement de l’historique…
+
+
+
+ {{ historyError }}
+
+
+
+ Aucun changement enregistré pour le moment.
+
+
+
+ -
+
+
+ {{ historyActionLabel(entry.action) }}
+
+ {{ formatHistoryDate(entry.createdAt) }}
+
+
+ Par {{ entry.actor?.label || 'Inconnu' }}
+
+
+
+ -
+ {{ diffEntry.label }}
+
+ {{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
+
+
+
+
+
+ {{ entry.snapshot.name }}
+
+
+
+
+
Annuler
@@ -466,6 +534,7 @@ import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
+import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
@@ -503,6 +572,12 @@ const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
+const {
+ history,
+ loading: historyLoading,
+ error: historyError,
+ loadHistory,
+} = useComponentHistory()
const component = ref(null)
const loading = ref(true)
@@ -513,6 +588,88 @@ const loadingDocuments = ref(false)
const componentDocuments = ref([])
const previewDocument = ref(null)
const previewVisible = ref(false)
+
+const historyEntries = computed(() => history.value)
+
+const historyFieldLabels: Record = {
+ name: 'Nom',
+ reference: 'Référence',
+ prix: 'Prix',
+ structure: 'Structure',
+ typeComposant: 'Catégorie',
+ product: 'Produit lié',
+ constructeurIds: 'Fournisseurs',
+}
+
+const historyActionLabel = (action: string) => {
+ if (action === 'create') {
+ return 'Création'
+ }
+ if (action === 'delete') {
+ return 'Suppression'
+ }
+ return 'Modification'
+}
+
+const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+})
+
+const formatHistoryDate = (value: string) => {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return value
+ }
+ return historyDateFormatter.format(date)
+}
+
+const formatHistoryValue = (value: unknown): string => {
+ if (value === null || value === undefined || value === '') {
+ return '—'
+ }
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return '—'
+ }
+ return value.map((item) => formatHistoryValue(item)).join(', ')
+ }
+ if (typeof value === 'object') {
+ const maybeRecord = value as Record
+ const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
+ const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
+ if (name && id) {
+ return `${name} (#${id})`
+ }
+ if (name) {
+ return name
+ }
+ if (id) {
+ return `#${id}`
+ }
+ try {
+ return JSON.stringify(value)
+ } catch (error) {
+ return String(value)
+ }
+ }
+ return String(value)
+}
+
+const historyDiffEntries = (entry: ComponentHistoryEntry) => {
+ const diff = entry.diff ?? {}
+ return Object.entries(diff).map(([field, change]) => {
+ const label = historyFieldLabels[field] ?? field
+ const fromLabel = formatHistoryValue(change?.from)
+ const toLabel = formatHistoryValue(change?.to)
+ return {
+ field,
+ label,
+ fromLabel,
+ toLabel,
+ }
+ })
+}
const selectedTypeId = ref('')
const editionForm = reactive({
name: '' as string,
@@ -756,6 +913,7 @@ const fetchComponent = async () => {
component.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
+ await loadHistory(result.data.id)
} else {
component.value = null
componentDocuments.value = []
diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue
index 44ddbe6..61919cd 100644
--- a/app/pages/pieces/[id]/edit.vue
+++ b/app/pages/pieces/[id]/edit.vue
@@ -381,6 +381,74 @@
+
+
+
+
+
+ Chargement de l’historique…
+
+
+
+ {{ historyError }}
+
+
+
+ Aucun changement enregistré pour le moment.
+
+
+
+ -
+
+
+ {{ historyActionLabel(entry.action) }}
+
+ {{ formatHistoryDate(entry.createdAt) }}
+
+
+ Par {{ entry.actor?.label || 'Inconnu' }}
+
+
+
+ -
+ {{ diffEntry.label }}
+
+ {{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
+
+
+
+
+
+ {{ entry.snapshot.name }}
+
+
+
+
+
Annuler
@@ -409,6 +477,7 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
+import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
@@ -444,6 +513,12 @@ const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEn
const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
+const {
+ history,
+ loading: historyLoading,
+ error: historyError,
+ loadHistory,
+} = usePieceHistory()
const piece = ref(null)
const loading = ref(true)
@@ -455,6 +530,88 @@ const pieceDocuments = ref([])
const previewDocument = ref(null)
const previewVisible = ref(false)
+const historyEntries = computed(() => history.value)
+
+const historyFieldLabels: Record = {
+ name: 'Nom',
+ reference: 'Référence',
+ prix: 'Prix',
+ typePiece: 'Catégorie',
+ product: 'Produit lié',
+ productIds: 'Produits liés',
+ constructeurIds: 'Fournisseurs',
+}
+
+const historyActionLabel = (action: string) => {
+ if (action === 'create') {
+ return 'Création'
+ }
+ if (action === 'delete') {
+ return 'Suppression'
+ }
+ return 'Modification'
+}
+
+const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+})
+
+const formatHistoryDate = (value: string) => {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return value
+ }
+ return historyDateFormatter.format(date)
+}
+
+const formatHistoryValue = (value: unknown): string => {
+ if (value === null || value === undefined || value === '') {
+ return '—'
+ }
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return '—'
+ }
+ return value.map((item) => formatHistoryValue(item)).join(', ')
+ }
+ if (typeof value === 'object') {
+ const maybeRecord = value as Record
+ const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
+ const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
+ if (name && id) {
+ return `${name} (#${id})`
+ }
+ if (name) {
+ return name
+ }
+ if (id) {
+ return `#${id}`
+ }
+ try {
+ return JSON.stringify(value)
+ } catch (error) {
+ return String(value)
+ }
+ }
+ return String(value)
+}
+
+const historyDiffEntries = (entry: PieceHistoryEntry) => {
+ const diff = entry.diff ?? {}
+ return Object.entries(diff).map(([field, change]) => {
+ const label = historyFieldLabels[field] ?? field
+ const fromLabel = formatHistoryValue(change?.from)
+ const toLabel = formatHistoryValue(change?.to)
+ return {
+ field,
+ label,
+ fromLabel,
+ toLabel,
+ }
+ })
+}
+
const selectedTypeId = ref('')
const pieceTypeDetails = ref(null)
const editionForm = reactive({
@@ -742,6 +899,7 @@ const fetchPiece = async () => {
refreshCustomFieldInputs(undefined, customValues.data)
}
await loadPieceTypeDetails(result.data)
+ await loadHistory(result.data.id)
} else {
piece.value = null
pieceDocuments.value = []
diff --git a/app/pages/product/[id]/edit.vue b/app/pages/product/[id]/edit.vue
index 7c2fc2b..c811798 100644
--- a/app/pages/product/[id]/edit.vue
+++ b/app/pages/product/[id]/edit.vue
@@ -301,6 +301,74 @@
+
+
+
+
+
+ Chargement de l’historique…
+
+
+
+ {{ historyError }}
+
+
+
+ Aucun changement enregistré pour le moment.
+
+
+
+ -
+
+
+ {{ historyActionLabel(entry.action) }}
+
+ {{ formatHistoryDate(entry.createdAt) }}
+
+
+ Par {{ entry.actor?.label || 'Inconnu' }}
+
+
+
+ -
+ {{ diffEntry.label }}
+
+ {{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
+
+
+
+
+
+ {{ entry.snapshot.name }}
+
+
+
+
+
Annuler
@@ -329,6 +397,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
+import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
@@ -359,6 +428,12 @@ const {
deleteDocument: deleteProductDocument,
} = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
+const {
+ history,
+ loading: historyLoading,
+ error: historyError,
+ loadHistory,
+} = useProductHistory()
const product = ref(null)
const productType = ref(null)
@@ -373,6 +448,86 @@ const productDocuments = ref([])
const previewDocument = ref(null)
const previewVisible = ref(false)
+const historyEntries = computed(() => history.value)
+
+const historyFieldLabels: Record = {
+ name: 'Nom',
+ reference: 'Référence',
+ supplierPrice: 'Prix fournisseur',
+ typeProduct: 'Catégorie',
+ constructeurIds: 'Fournisseurs',
+}
+
+const historyActionLabel = (action: string) => {
+ if (action === 'create') {
+ return 'Création'
+ }
+ if (action === 'delete') {
+ return 'Suppression'
+ }
+ return 'Modification'
+}
+
+const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+})
+
+const formatHistoryDate = (value: string) => {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return value
+ }
+ return historyDateFormatter.format(date)
+}
+
+const formatHistoryValue = (value: unknown): string => {
+ if (value === null || value === undefined || value === '') {
+ return '—'
+ }
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return '—'
+ }
+ return value.map((item) => formatHistoryValue(item)).join(', ')
+ }
+ if (typeof value === 'object') {
+ const maybeRecord = value as Record
+ const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
+ const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
+ if (name && id) {
+ return `${name} (#${id})`
+ }
+ if (name) {
+ return name
+ }
+ if (id) {
+ return `#${id}`
+ }
+ try {
+ return JSON.stringify(value)
+ } catch (error) {
+ return String(value)
+ }
+ }
+ return String(value)
+}
+
+const historyDiffEntries = (entry: ProductHistoryEntry) => {
+ const diff = entry.diff ?? {}
+ return Object.entries(diff).map(([field, change]) => {
+ const label = historyFieldLabels[field] ?? field
+ const fromLabel = formatHistoryValue(change?.from)
+ const toLabel = formatHistoryValue(change?.to)
+ return {
+ field,
+ label,
+ fromLabel,
+ toLabel,
+ }
+ })
+}
+
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
@@ -509,6 +664,7 @@ const loadProduct = async () => {
}
await hydrateForm()
await refreshDocuments()
+ await loadHistory(result.data.id)
} else {
product.value = null
}