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 @@

+
+
+
+

Historique

+

+ Qui a changé quoi, et quand. +

+
+ + {{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }} + +
+ +
+
+ +
+ {{ 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 @@

+
+
+
+

Historique

+

+ Qui a changé quoi, et quand. +

+
+ + {{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }} + +
+ +
+
+ +
+ {{ 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 @@

+
+
+
+

Historique

+

+ Qui a changé quoi, et quand. +

+
+ + {{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }} + +
+ +
+
+ +
+ {{ 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 }