refactor(frontend) : extract shared components and reduce file sizes
- Extract CustomFieldInputGrid.vue from 6 duplicated template blocks (~70 lines each) - Extract EntityHistorySection.vue from 3 identical history sections in edit pages - Extract useDragReorder composable from 4 identical drag-and-drop implementations in StructureNodeEditor (~330 lines → ~30) - Extract catalogDisplayUtils.ts (resolvePrimaryDocument, resolveSupplierNames, buildSuppliersDisplay) - Remove redundant computed wrappers (historyEntries, loadingTypes, selectedFiles) - Remove unused imports (fieldKey, historyActionLabel, formatHistoryDate, *HistoryEntry types) - Move Intl.DateTimeFormat to module-level in date.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFiles = computed(() => internalFiles.value)
|
||||
const selectedFiles = internalFiles
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
||||
@@ -725,11 +725,6 @@ const handleProductTypeSelect = (product: ComponentModelProduct & Record<string,
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
}
|
||||
|
||||
const customFieldDragState = ref({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
})
|
||||
|
||||
const reindexCustomFields = () => {
|
||||
if (!Array.isArray(props.node.customFields)) {
|
||||
return
|
||||
@@ -742,59 +737,15 @@ const reindexCustomFields = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const resetCustomFieldDragState = () => {
|
||||
customFieldDragState.value.draggingIndex = null
|
||||
customFieldDragState.value.dropTargetIndex = null
|
||||
}
|
||||
|
||||
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
|
||||
customFieldDragState.value.draggingIndex = index
|
||||
customFieldDragState.value.dropTargetIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onCustomFieldDragEnter = (index: number) => {
|
||||
if (customFieldDragState.value.draggingIndex === null) {
|
||||
return
|
||||
}
|
||||
customFieldDragState.value.dropTargetIndex = index
|
||||
}
|
||||
|
||||
const onCustomFieldDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.customFields)) {
|
||||
resetCustomFieldDragState()
|
||||
return
|
||||
}
|
||||
const from = customFieldDragState.value.draggingIndex
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetCustomFieldDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.customFields, from, to)
|
||||
reindexCustomFields()
|
||||
resetCustomFieldDragState()
|
||||
}
|
||||
|
||||
const onCustomFieldDragEnd = () => {
|
||||
resetCustomFieldDragState()
|
||||
}
|
||||
|
||||
const customFieldReorderClass = (index: number) => {
|
||||
if (customFieldDragState.value.draggingIndex === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
customFieldDragState.value.draggingIndex !== null &&
|
||||
customFieldDragState.value.dropTargetIndex === index &&
|
||||
customFieldDragState.value.draggingIndex !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const customFieldDrag = useDragReorder(
|
||||
() => props.node.customFields,
|
||||
{ onReorder: reindexCustomFields },
|
||||
)
|
||||
const onCustomFieldDragStart = customFieldDrag.onDragStart
|
||||
const onCustomFieldDragEnter = customFieldDrag.onDragEnter
|
||||
const onCustomFieldDrop = customFieldDrag.onDrop
|
||||
const onCustomFieldDragEnd = customFieldDrag.onDragEnd
|
||||
const customFieldReorderClass = customFieldDrag.reorderClass
|
||||
|
||||
const addCustomField = () => {
|
||||
ensureArray('customFields')
|
||||
@@ -867,197 +818,32 @@ const removeSubComponent = (index: number) => {
|
||||
props.node.subcomponents.splice(index, 1)
|
||||
}
|
||||
|
||||
const draggingPieceIndex = ref<number | null>(null)
|
||||
const pieceDropTargetIndex = ref<number | null>(null)
|
||||
const draggingProductIndex = ref<number | null>(null)
|
||||
const productDropTargetIndex = ref<number | null>(null)
|
||||
const draggingSubcomponentIndex = ref<number | null>(null)
|
||||
const subcomponentDropTargetIndex = ref<number | null>(null)
|
||||
const pieceDrag = useDragReorder(() => props.node.pieces)
|
||||
const onPieceDragStart = pieceDrag.onDragStart
|
||||
const onPieceDragEnter = pieceDrag.onDragEnter
|
||||
const onPieceDragOver = pieceDrag.onDragOver
|
||||
const onPieceDrop = pieceDrag.onDrop
|
||||
const onPieceDragEnd = pieceDrag.onDragEnd
|
||||
const pieceReorderClass = pieceDrag.reorderClass
|
||||
|
||||
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
|
||||
if (from === to) {
|
||||
return
|
||||
}
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
return
|
||||
}
|
||||
const updated = list.slice()
|
||||
const [item] = updated.splice(from, 1)
|
||||
if (item === undefined) return
|
||||
updated.splice(to, 0, item)
|
||||
list.splice(0, list.length, ...updated)
|
||||
}
|
||||
const productDrag = useDragReorder(() => props.node.products)
|
||||
const onProductDragStart = productDrag.onDragStart
|
||||
const onProductDragEnter = productDrag.onDragEnter
|
||||
const onProductDragOver = productDrag.onDragOver
|
||||
const onProductDrop = productDrag.onDrop
|
||||
const onProductDragEnd = productDrag.onDragEnd
|
||||
const productReorderClass = productDrag.reorderClass
|
||||
|
||||
const resetPieceDragState = () => {
|
||||
draggingPieceIndex.value = null
|
||||
pieceDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const resetProductDragState = () => {
|
||||
draggingProductIndex.value = null
|
||||
productDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onPieceDragStart = (index: number, event: DragEvent) => {
|
||||
draggingPieceIndex.value = index
|
||||
pieceDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onPieceDragEnter = (index: number) => {
|
||||
if (draggingPieceIndex.value === null) {
|
||||
return
|
||||
}
|
||||
pieceDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onPieceDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onPieceDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.pieces)) {
|
||||
resetPieceDragState()
|
||||
return
|
||||
}
|
||||
const from = draggingPieceIndex.value
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetPieceDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.pieces, from, to)
|
||||
resetPieceDragState()
|
||||
}
|
||||
|
||||
const onPieceDragEnd = () => {
|
||||
resetPieceDragState()
|
||||
}
|
||||
|
||||
const pieceReorderClass = (index: number) => {
|
||||
if (draggingPieceIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingPieceIndex.value !== null &&
|
||||
pieceDropTargetIndex.value === index &&
|
||||
draggingPieceIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const onProductDragStart = (index: number, event: DragEvent) => {
|
||||
draggingProductIndex.value = index
|
||||
productDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onProductDragEnter = (index: number) => {
|
||||
if (draggingProductIndex.value === null) {
|
||||
return
|
||||
}
|
||||
productDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onProductDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onProductDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.products)) {
|
||||
resetProductDragState()
|
||||
return
|
||||
}
|
||||
const from = draggingProductIndex.value
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetProductDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.products, from, to)
|
||||
resetProductDragState()
|
||||
}
|
||||
|
||||
const onProductDragEnd = () => {
|
||||
resetProductDragState()
|
||||
}
|
||||
|
||||
const productReorderClass = (index: number) => {
|
||||
if (draggingProductIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingProductIndex.value !== null &&
|
||||
productDropTargetIndex.value === index &&
|
||||
draggingProductIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resetSubcomponentDragState = () => {
|
||||
draggingSubcomponentIndex.value = null
|
||||
subcomponentDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onSubcomponentDragStart = (index: number, event: DragEvent) => {
|
||||
draggingSubcomponentIndex.value = index
|
||||
subcomponentDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onSubcomponentDragEnter = (index: number) => {
|
||||
if (draggingSubcomponentIndex.value === null) {
|
||||
return
|
||||
}
|
||||
subcomponentDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onSubcomponentDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onSubcomponentDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.subcomponents)) {
|
||||
resetSubcomponentDragState()
|
||||
return
|
||||
}
|
||||
const from = draggingSubcomponentIndex.value
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetSubcomponentDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.subcomponents, from, to)
|
||||
resetSubcomponentDragState()
|
||||
}
|
||||
|
||||
const onSubcomponentDragEnd = () => {
|
||||
resetSubcomponentDragState()
|
||||
}
|
||||
|
||||
const subcomponentReorderClass = (index: number) => {
|
||||
if (draggingSubcomponentIndex.value === index) {
|
||||
return 'ring-2 ring-primary'
|
||||
}
|
||||
if (
|
||||
draggingSubcomponentIndex.value !== null &&
|
||||
subcomponentDropTargetIndex.value === index &&
|
||||
draggingSubcomponentIndex.value !== index
|
||||
) {
|
||||
return 'ring-2 ring-primary/70'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const subcomponentDrag = useDragReorder(
|
||||
() => props.node.subcomponents,
|
||||
{ draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
|
||||
)
|
||||
const onSubcomponentDragStart = subcomponentDrag.onDragStart
|
||||
const onSubcomponentDragEnter = subcomponentDrag.onDragEnter
|
||||
const onSubcomponentDragOver = subcomponentDrag.onDragOver
|
||||
const onSubcomponentDrop = subcomponentDrag.onDrop
|
||||
const onSubcomponentDragEnd = subcomponentDrag.onDragEnd
|
||||
const subcomponentReorderClass = subcomponentDrag.reorderClass
|
||||
|
||||
watch(
|
||||
canManageSubcomponents,
|
||||
|
||||
83
app/components/common/CustomFieldInputGrid.vue
Normal file
83
app/components/common/CustomFieldInputGrid.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
defineProps<{
|
||||
fields: CustomFieldInput[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
97
app/components/common/EntityHistorySection.vue
Normal file
97
app/components/common/EntityHistorySection.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<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">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="entries.length" class="badge badge-outline">
|
||||
{{ entries.length }} entrée{{ entries.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 de l'historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="entries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="diffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in diffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
type HistoryDiffEntry,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
action: string
|
||||
createdAt: string
|
||||
actor?: { label?: string } | null
|
||||
diff?: Record<string, { from?: unknown; to?: unknown }> | null
|
||||
snapshot?: { name?: string } | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entries: HistoryEntry[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
fieldLabels: Record<string, string>
|
||||
}>()
|
||||
|
||||
const diffEntries = (entry: HistoryEntry): HistoryDiffEntry[] =>
|
||||
historyDiffEntries(entry, props.fieldLabels)
|
||||
</script>
|
||||
109
app/composables/useDragReorder.ts
Normal file
109
app/composables/useDragReorder.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface DragReorderHandlers {
|
||||
draggingIndex: Ref<number | null>
|
||||
dropTargetIndex: Ref<number | null>
|
||||
onDragStart: (index: number, event: DragEvent) => void
|
||||
onDragEnter: (index: number) => void
|
||||
onDragOver: (event: DragEvent) => void
|
||||
onDrop: (index: number) => void
|
||||
onDragEnd: () => void
|
||||
reorderClass: (index: number) => string
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
interface DragReorderOptions {
|
||||
draggingClass?: string
|
||||
dropTargetClass?: string
|
||||
onReorder?: () => void
|
||||
}
|
||||
|
||||
function moveItemInPlace<T>(list: T[], from: number, to: number): void {
|
||||
if (from === to) return
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return
|
||||
const updated = list.slice()
|
||||
const [item] = updated.splice(from, 1)
|
||||
if (item === undefined) return
|
||||
updated.splice(to, 0, item)
|
||||
list.splice(0, list.length, ...updated)
|
||||
}
|
||||
|
||||
export function useDragReorder(
|
||||
getList: () => unknown[] | undefined,
|
||||
options: DragReorderOptions = {},
|
||||
): DragReorderHandlers {
|
||||
const {
|
||||
draggingClass = 'border-dashed border-primary',
|
||||
dropTargetClass = 'border-primary border-dashed bg-primary/5',
|
||||
onReorder,
|
||||
} = options
|
||||
|
||||
const draggingIndex = ref<number | null>(null)
|
||||
const dropTargetIndex = ref<number | null>(null)
|
||||
|
||||
const reset = () => {
|
||||
draggingIndex.value = null
|
||||
dropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
draggingIndex.value = index
|
||||
dropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (draggingIndex.value === null) return
|
||||
dropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
const list = getList()
|
||||
if (!Array.isArray(list)) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
const from = draggingIndex.value
|
||||
if (from === null) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(list, from, index)
|
||||
onReorder?.()
|
||||
reset()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
reset()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number): string => {
|
||||
if (draggingIndex.value === index) return draggingClass
|
||||
if (
|
||||
draggingIndex.value !== null
|
||||
&& dropTargetIndex.value === index
|
||||
&& draggingIndex.value !== index
|
||||
) {
|
||||
return dropTargetClass
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return {
|
||||
draggingIndex,
|
||||
dropTargetIndex,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
reorderClass,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -126,8 +126,9 @@ import { useComposants } from '~/composables/useComposants'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
||||
@@ -178,23 +179,6 @@ async function fetchComposants() {
|
||||
})
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||
if (pdf) return pdf
|
||||
const image = withPath.find((doc: any) => isImageDocument(doc))
|
||||
if (image) return image
|
||||
return withPath[0] ?? normalized[0] ?? null
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (component: Record<string, any>) => {
|
||||
const parts = [component?.name, component?.reference].filter(Boolean)
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
const resolveComponentType = (component: Record<string, any>) => {
|
||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||
@@ -212,12 +196,7 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '—'
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
|
||||
}
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||
|
||||
@@ -275,78 +275,7 @@
|
||||
Mettez à jour les valeurs propres à ce composant.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in customFieldInputs"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -449,73 +378,12 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l’historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in historyEntries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<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 }">
|
||||
@@ -559,7 +427,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 { useComponentHistory } from '~/composables/useComponentHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
@@ -567,7 +435,6 @@ import type { ModelType } from '~/services/modelTypes'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
@@ -580,11 +447,6 @@ import {
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries as _historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
structure: ComponentModelStructure | null
|
||||
@@ -622,8 +484,6 @@ const componentDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
@@ -634,8 +494,6 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
|
||||
@@ -241,78 +241,7 @@
|
||||
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in customFieldInputs"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -396,7 +325,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const {
|
||||
@@ -487,7 +416,6 @@ watch(selectedTypeId, (id) => {
|
||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||
})
|
||||
|
||||
const loadingTypes = computed(() => loadingComponentTypes.value)
|
||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||
(componentTypes.value || [])
|
||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||
@@ -1026,8 +954,6 @@ interface CustomFieldInput {
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
|
||||
@@ -100,6 +100,7 @@ import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
@@ -153,16 +154,7 @@ const debouncedSearch = debounce(async () => {
|
||||
await searchConstructeurs(searchTerm.value)
|
||||
}, 300)
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—'
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
const formatPhoneDisplay = (value) => {
|
||||
const formatted = formatPhone(value)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="documentRows"
|
||||
:documents="documents"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="card-body space-y-6">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="documentRows"
|
||||
:rows="documents"
|
||||
:loading="loading"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
@@ -148,7 +148,6 @@ const attachmentFilter = table.filters.filter as Ref<string>
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const documentRows = computed(() => documents.value)
|
||||
const documentsOnPage = computed(() => documents.value.length)
|
||||
const paginationState = table.pagination(total, documentsOnPage)
|
||||
|
||||
|
||||
@@ -149,8 +149,9 @@ import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
|
||||
@@ -203,80 +204,14 @@ async function fetchPieces() {
|
||||
})
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||||
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||
if (pdf) return pdf
|
||||
const image = withPath.find((doc: any) => isImageDocument(doc))
|
||||
if (image) return image
|
||||
return withPath[0] ?? normalized[0] ?? null
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (piece: Record<string, any>) => {
|
||||
const parts = [piece?.name, piece?.reference].filter(Boolean)
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
const resolvePieceType = (piece: Record<string, any>) => {
|
||||
if (piece?.typePiece?.name) return piece.typePiece.name
|
||||
if (piece?.typePieceLabel) return piece.typePieceLabel
|
||||
return '—'
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SUPPLIERS = 3
|
||||
|
||||
const resolvePieceSuppliers = (piece: Record<string, any>) => {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushName = (maybeName: unknown) => {
|
||||
if (typeof maybeName !== 'string') return
|
||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||
if (!normalized.length) return
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
names.push(normalized)
|
||||
}
|
||||
|
||||
const collectConstructeurs = (value: unknown): void => {
|
||||
if (!value) return
|
||||
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||
if (typeof value === 'string') { pushName(value); return }
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, any>
|
||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromLabel = (value: unknown): void => {
|
||||
if (typeof value !== 'string') return
|
||||
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||
}
|
||||
|
||||
collectConstructeurs(piece?.constructeurs)
|
||||
collectConstructeurs(piece?.constructeur)
|
||||
collectConstructeurs(piece?.product?.constructeurs)
|
||||
collectConstructeurs(piece?.product?.constructeur)
|
||||
collectFromLabel(piece?.constructeursLabel)
|
||||
collectFromLabel(piece?.supplierLabel)
|
||||
collectFromLabel(piece?.product?.constructeursLabel)
|
||||
collectFromLabel(piece?.product?.supplierLabel)
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
|
||||
const suppliers = resolvePieceSuppliers(piece)
|
||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||
}
|
||||
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
||||
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -289,12 +224,7 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '—'
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
|
||||
}
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchPieces(), loadPieceTypes()])
|
||||
|
||||
@@ -222,78 +222,7 @@
|
||||
Mettez à jour les valeurs propres à cette pièce.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in customFieldInputs"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -396,73 +325,12 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l’historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in historyEntries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
@@ -502,7 +370,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 { usePieceHistory } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
@@ -512,7 +380,6 @@ import type { ModelType } from '~/services/modelTypes'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
@@ -525,11 +392,6 @@ import {
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries as _historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
@@ -563,8 +425,6 @@ const pieceDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
@@ -575,9 +435,6 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: PieceHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const pieceTypeDetails = ref<any | null>(null)
|
||||
const editionForm = reactive({
|
||||
|
||||
@@ -193,78 +193,7 @@
|
||||
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in customFieldInputs"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -324,7 +253,6 @@ import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inve
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
@@ -338,7 +266,7 @@ interface PieceCatalogType extends ModelType {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
|
||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
|
||||
const { createPiece } = usePieces()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
@@ -385,7 +313,6 @@ watch(selectedTypeId, (id) => {
|
||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||
})
|
||||
|
||||
const loadingTypes = computed(() => loadingPieceTypes.value)
|
||||
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
||||
|
||||
const typeOptionLabel = (type?: PieceCatalogType) =>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
<template #cell-preview="{ row }">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.product)"
|
||||
:document="resolvePrimaryDocument(row.product, true)"
|
||||
:alt="resolvePreviewAlt(row.product)"
|
||||
/>
|
||||
</template>
|
||||
@@ -147,8 +147,8 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
@@ -197,7 +197,7 @@ const productRows = computed(() =>
|
||||
normalizedProducts.value.map(product => ({
|
||||
id: product.id,
|
||||
product,
|
||||
suppliers: buildSuppliersDisplay(product),
|
||||
suppliers: buildProductSuppliersDisplay(product),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -225,72 +225,8 @@ const formatPrice = (value: any) => {
|
||||
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SUPPLIERS = 3
|
||||
|
||||
const resolveProductSuppliers = (product: Record<string, any>) => {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushName = (maybeName: unknown) => {
|
||||
if (typeof maybeName !== 'string') return
|
||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||
if (!normalized.length) return
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
names.push(normalized)
|
||||
}
|
||||
|
||||
const collectConstructeurs = (value: unknown): void => {
|
||||
if (!value) return
|
||||
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||
if (typeof value === 'string') { pushName(value); return }
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, any>
|
||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromLabel = (value: unknown): void => {
|
||||
if (typeof value !== 'string') return
|
||||
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||
}
|
||||
|
||||
collectConstructeurs(product?.constructeurs)
|
||||
collectConstructeurs(product?.constructeur)
|
||||
collectFromLabel(product?.constructeursLabel)
|
||||
collectFromLabel(product?.supplierLabel)
|
||||
collectFromLabel(product?.suppliers)
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
const buildSuppliersDisplay = (product: Record<string, any>) => {
|
||||
const suppliers = resolveProductSuppliers(product)
|
||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (product: Record<string, any>) => {
|
||||
const documents = Array.isArray(product?.documents) ? product.documents : []
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) return normalized[0] ?? null
|
||||
const images = withPath.filter((doc: any) => isImageDocument(doc))
|
||||
if (images.length) return images[0]
|
||||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||
if (pdf) return pdf
|
||||
return withPath[0]
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (product: Record<string, any>) => {
|
||||
const parts = [product?.name, product?.reference].filter(Boolean)
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
|
||||
buildSuppliersDisplay(resolveSupplierNames(product))
|
||||
|
||||
const reload = () => fetchProducts()
|
||||
|
||||
|
||||
@@ -133,78 +133,7 @@
|
||||
Mettez à jour les valeurs propres à ce produit.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in customFieldInputs"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -303,73 +232,12 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l’historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in historyEntries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<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 }">
|
||||
@@ -410,7 +278,7 @@ import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||
import { useProductHistory } from '~/composables/useProductHistory'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
@@ -418,7 +286,6 @@ import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
@@ -431,11 +298,6 @@ import {
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries as _historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const route = useRoute()
|
||||
@@ -469,8 +331,6 @@ const productDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
@@ -479,9 +339,6 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ProductHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ProductModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
|
||||
@@ -119,78 +119,7 @@
|
||||
Renseignez les valeurs propres à ce produit catalogue.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in customFieldInputs"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -262,7 +191,7 @@ interface ProductCatalogType extends ModelType {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
||||
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
|
||||
const { createProduct } = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
@@ -283,7 +212,6 @@ const uploadingDocuments = ref(false)
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
|
||||
const loadingTypes = computed(() => loadingProductTypes.value)
|
||||
const productTypeList = computed<ProductCatalogType[]>(() =>
|
||||
(productTypes.value || []) as ProductCatalogType[],
|
||||
)
|
||||
@@ -354,9 +282,6 @@ const canSubmit = computed(() => Boolean(
|
||||
!submitting.value,
|
||||
))
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldId || field.id || `${field.name}-${index}`
|
||||
|
||||
const clearForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.reference = ''
|
||||
|
||||
87
app/shared/utils/catalogDisplayUtils.ts
Normal file
87
app/shared/utils/catalogDisplayUtils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
/**
|
||||
* Selects the best document for thumbnail preview from an entity's documents array.
|
||||
* Default priority: PDF first, then images. Use `preferImages` to reverse.
|
||||
*/
|
||||
export const resolvePrimaryDocument = (entity: Record<string, any>, preferImages = false): any | null => {
|
||||
const documents = Array.isArray(entity?.documents) ? entity.documents : []
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) return normalized[0] ?? null
|
||||
const first = preferImages ? isImageDocument : isPdfDocument
|
||||
const second = preferImages ? isPdfDocument : isImageDocument
|
||||
const a = withPath.find((doc: any) => first(doc))
|
||||
if (a) return a
|
||||
const b = withPath.find((doc: any) => second(doc))
|
||||
if (b) return b
|
||||
return withPath[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds alt text for a document preview thumbnail.
|
||||
*/
|
||||
export const resolvePreviewAlt = (entity: Record<string, any>): string => {
|
||||
const parts = [entity?.name, entity?.reference].filter(Boolean)
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplier name resolution: extracts unique supplier names from entity relations.
|
||||
*/
|
||||
export const resolveSupplierNames = (entity: Record<string, any>, nestedKey?: string): string[] => {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushName = (maybeName: unknown) => {
|
||||
if (typeof maybeName !== 'string') return
|
||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||
if (!normalized.length) return
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
names.push(normalized)
|
||||
}
|
||||
|
||||
const collectConstructeurs = (value: unknown): void => {
|
||||
if (!value) return
|
||||
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||
if (typeof value === 'string') { pushName(value); return }
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, any>
|
||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromLabel = (value: unknown): void => {
|
||||
if (typeof value !== 'string') return
|
||||
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||
}
|
||||
|
||||
collectConstructeurs(entity?.constructeurs)
|
||||
collectConstructeurs(entity?.constructeur)
|
||||
collectFromLabel(entity?.constructeursLabel)
|
||||
collectFromLabel(entity?.supplierLabel)
|
||||
collectFromLabel(entity?.suppliers)
|
||||
|
||||
if (nestedKey && entity?.[nestedKey]) {
|
||||
const nested = entity[nestedKey]
|
||||
collectConstructeurs(nested?.constructeurs)
|
||||
collectConstructeurs(nested?.constructeur)
|
||||
collectFromLabel(nested?.constructeursLabel)
|
||||
collectFromLabel(nested?.supplierLabel)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SUPPLIERS = 3
|
||||
|
||||
export const buildSuppliersDisplay = (suppliers: string[]) => {
|
||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
|
||||
* Retourne "—" si la valeur est invalide ou absente.
|
||||
*/
|
||||
const frenchDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
@@ -12,9 +18,5 @@ export const formatFrenchDate = (value: Date | string | number | null | undefine
|
||||
return '—'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
return frenchDateFormatter.format(date)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user