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(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
|
|||||||
@@ -725,11 +725,6 @@ const handleProductTypeSelect = (product: ComponentModelProduct & Record<string,
|
|||||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const customFieldDragState = ref({
|
|
||||||
draggingIndex: null as number | null,
|
|
||||||
dropTargetIndex: null as number | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const reindexCustomFields = () => {
|
const reindexCustomFields = () => {
|
||||||
if (!Array.isArray(props.node.customFields)) {
|
if (!Array.isArray(props.node.customFields)) {
|
||||||
return
|
return
|
||||||
@@ -742,59 +737,15 @@ const reindexCustomFields = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetCustomFieldDragState = () => {
|
const customFieldDrag = useDragReorder(
|
||||||
customFieldDragState.value.draggingIndex = null
|
() => props.node.customFields,
|
||||||
customFieldDragState.value.dropTargetIndex = null
|
{ onReorder: reindexCustomFields },
|
||||||
}
|
)
|
||||||
|
const onCustomFieldDragStart = customFieldDrag.onDragStart
|
||||||
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
|
const onCustomFieldDragEnter = customFieldDrag.onDragEnter
|
||||||
customFieldDragState.value.draggingIndex = index
|
const onCustomFieldDrop = customFieldDrag.onDrop
|
||||||
customFieldDragState.value.dropTargetIndex = index
|
const onCustomFieldDragEnd = customFieldDrag.onDragEnd
|
||||||
if (event.dataTransfer) {
|
const customFieldReorderClass = customFieldDrag.reorderClass
|
||||||
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 addCustomField = () => {
|
const addCustomField = () => {
|
||||||
ensureArray('customFields')
|
ensureArray('customFields')
|
||||||
@@ -867,197 +818,32 @@ const removeSubComponent = (index: number) => {
|
|||||||
props.node.subcomponents.splice(index, 1)
|
props.node.subcomponents.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const draggingPieceIndex = ref<number | null>(null)
|
const pieceDrag = useDragReorder(() => props.node.pieces)
|
||||||
const pieceDropTargetIndex = ref<number | null>(null)
|
const onPieceDragStart = pieceDrag.onDragStart
|
||||||
const draggingProductIndex = ref<number | null>(null)
|
const onPieceDragEnter = pieceDrag.onDragEnter
|
||||||
const productDropTargetIndex = ref<number | null>(null)
|
const onPieceDragOver = pieceDrag.onDragOver
|
||||||
const draggingSubcomponentIndex = ref<number | null>(null)
|
const onPieceDrop = pieceDrag.onDrop
|
||||||
const subcomponentDropTargetIndex = ref<number | null>(null)
|
const onPieceDragEnd = pieceDrag.onDragEnd
|
||||||
|
const pieceReorderClass = pieceDrag.reorderClass
|
||||||
|
|
||||||
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
|
const productDrag = useDragReorder(() => props.node.products)
|
||||||
if (from === to) {
|
const onProductDragStart = productDrag.onDragStart
|
||||||
return
|
const onProductDragEnter = productDrag.onDragEnter
|
||||||
}
|
const onProductDragOver = productDrag.onDragOver
|
||||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
const onProductDrop = productDrag.onDrop
|
||||||
return
|
const onProductDragEnd = productDrag.onDragEnd
|
||||||
}
|
const productReorderClass = productDrag.reorderClass
|
||||||
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 resetPieceDragState = () => {
|
const subcomponentDrag = useDragReorder(
|
||||||
draggingPieceIndex.value = null
|
() => props.node.subcomponents,
|
||||||
pieceDropTargetIndex.value = null
|
{ draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
|
||||||
}
|
)
|
||||||
|
const onSubcomponentDragStart = subcomponentDrag.onDragStart
|
||||||
const resetProductDragState = () => {
|
const onSubcomponentDragEnter = subcomponentDrag.onDragEnter
|
||||||
draggingProductIndex.value = null
|
const onSubcomponentDragOver = subcomponentDrag.onDragOver
|
||||||
productDropTargetIndex.value = null
|
const onSubcomponentDrop = subcomponentDrag.onDrop
|
||||||
}
|
const onSubcomponentDragEnd = subcomponentDrag.onDragEnd
|
||||||
|
const subcomponentReorderClass = subcomponentDrag.reorderClass
|
||||||
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 ''
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
canManageSubcomponents,
|
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 { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
|
||||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||||
|
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
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>) => {
|
const resolveComponentType = (component: Record<string, any>) => {
|
||||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||||
@@ -212,12 +196,7 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
|
|||||||
fetchComposants()
|
fetchComposants()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = formatFrenchDate
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||||
|
|||||||
@@ -275,78 +275,7 @@
|
|||||||
Mettez à jour les valeurs propres à ce composant.
|
Mettez à jour les valeurs propres à ce composant.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
@@ -449,73 +378,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<EntityHistorySection
|
||||||
<header class="flex items-center justify-between gap-3">
|
:entries="history"
|
||||||
<div>
|
:loading="historyLoading"
|
||||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
:error="historyError"
|
||||||
<p class="text-xs text-base-content/70">
|
:field-labels="historyFieldLabels"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
@@ -559,7 +427,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
@@ -567,7 +435,6 @@ import type { ModelType } from '~/services/modelTypes'
|
|||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
buildCustomFieldInputs,
|
buildCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
@@ -580,11 +447,6 @@ import {
|
|||||||
documentThumbnailClass,
|
documentThumbnailClass,
|
||||||
downloadDocument,
|
downloadDocument,
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
import {
|
|
||||||
historyActionLabel,
|
|
||||||
formatHistoryDate,
|
|
||||||
historyDiffEntries as _historyDiffEntries,
|
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
|
||||||
|
|
||||||
interface ComponentCatalogType extends ModelType {
|
interface ComponentCatalogType extends ModelType {
|
||||||
structure: ComponentModelStructure | null
|
structure: ComponentModelStructure | null
|
||||||
@@ -622,8 +484,6 @@ const componentDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
|
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
const historyFieldLabels: Record<string, string> = {
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
reference: 'Référence',
|
reference: 'Référence',
|
||||||
@@ -634,8 +494,6 @@ const historyFieldLabels: Record<string, string> = {
|
|||||||
constructeurIds: 'Fournisseurs',
|
constructeurIds: 'Fournisseurs',
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
|
|
||||||
_historyDiffEntries(entry, historyFieldLabels)
|
|
||||||
const selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
|
|||||||
@@ -241,78 +241,7 @@
|
|||||||
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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 router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
|
|
||||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const {
|
const {
|
||||||
@@ -487,7 +416,6 @@ watch(selectedTypeId, (id) => {
|
|||||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadingTypes = computed(() => loadingComponentTypes.value)
|
|
||||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||||
(componentTypes.value || [])
|
(componentTypes.value || [])
|
||||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||||
@@ -1026,8 +954,6 @@ interface CustomFieldInput {
|
|||||||
orderIndex: number
|
orderIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
|
||||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
|
||||||
|
|
||||||
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
|
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
|
||||||
if (!structure || typeof structure !== 'object') {
|
if (!structure || typeof structure !== 'object') {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import { useConstructeurs } from '~/composables/useConstructeurs'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedValue } from '~/composables/usePersistedValue'
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
@@ -153,16 +154,7 @@ const debouncedSearch = debounce(async () => {
|
|||||||
await searchConstructeurs(searchTerm.value)
|
await searchConstructeurs(searchTerm.value)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = formatFrenchDate
|
||||||
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 formatPhoneDisplay = (value) => {
|
const formatPhoneDisplay = (value) => {
|
||||||
const formatted = formatPhone(value)
|
const formatted = formatPhone(value)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
:documents="documentRows"
|
:documents="documents"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="documentRows"
|
:rows="documents"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:sort="table.sort.value"
|
:sort="table.sort.value"
|
||||||
:pagination="paginationState"
|
:pagination="paginationState"
|
||||||
@@ -148,7 +148,6 @@ const attachmentFilter = table.filters.filter as Ref<string>
|
|||||||
const previewDocument = ref<any>(null)
|
const previewDocument = ref<any>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const documentRows = computed(() => documents.value)
|
|
||||||
const documentsOnPage = computed(() => documents.value.length)
|
const documentsOnPage = computed(() => documents.value.length)
|
||||||
const paginationState = table.pagination(total, documentsOnPage)
|
const paginationState = table.pagination(total, documentsOnPage)
|
||||||
|
|
||||||
|
|||||||
@@ -149,8 +149,9 @@ import { usePieces } from '~/composables/usePieces'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
|
||||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
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 { canEdit } = usePermissions()
|
||||||
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
|
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>) => {
|
const resolvePieceType = (piece: Record<string, any>) => {
|
||||||
if (piece?.typePiece?.name) return piece.typePiece.name
|
if (piece?.typePiece?.name) return piece.typePiece.name
|
||||||
if (piece?.typePieceLabel) return piece.typePieceLabel
|
if (piece?.typePieceLabel) return piece.typePieceLabel
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE_SUPPLIERS = 3
|
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
||||||
|
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||||
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 { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
@@ -289,12 +224,7 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
|
|||||||
fetchPieces()
|
fetchPieces()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = formatFrenchDate
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchPieces(), loadPieceTypes()])
|
await Promise.all([fetchPieces(), loadPieceTypes()])
|
||||||
|
|||||||
@@ -222,78 +222,7 @@
|
|||||||
Mettez à jour les valeurs propres à cette pièce.
|
Mettez à jour les valeurs propres à cette pièce.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
@@ -396,73 +325,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<EntityHistorySection
|
||||||
<header class="flex items-center justify-between gap-3">
|
:entries="history"
|
||||||
<div>
|
:loading="historyLoading"
|
||||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
:error="historyError"
|
||||||
<p class="text-xs text-base-content/70">
|
:field-labels="historyFieldLabels"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<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 { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
import { usePieceHistory } from '~/composables/usePieceHistory'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
@@ -512,7 +380,6 @@ import type { ModelType } from '~/services/modelTypes'
|
|||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
buildCustomFieldInputs,
|
buildCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
@@ -525,11 +392,6 @@ import {
|
|||||||
documentThumbnailClass,
|
documentThumbnailClass,
|
||||||
downloadDocument,
|
downloadDocument,
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
import {
|
|
||||||
historyActionLabel,
|
|
||||||
formatHistoryDate,
|
|
||||||
historyDiffEntries as _historyDiffEntries,
|
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
|
||||||
|
|
||||||
interface PieceCatalogType extends ModelType {
|
interface PieceCatalogType extends ModelType {
|
||||||
structure: PieceModelStructure | null
|
structure: PieceModelStructure | null
|
||||||
@@ -563,8 +425,6 @@ const pieceDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
|
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
const historyFieldLabels: Record<string, string> = {
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
reference: 'Référence',
|
reference: 'Référence',
|
||||||
@@ -575,9 +435,6 @@ const historyFieldLabels: Record<string, string> = {
|
|||||||
constructeurIds: 'Fournisseurs',
|
constructeurIds: 'Fournisseurs',
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyDiffEntries = (entry: PieceHistoryEntry) =>
|
|
||||||
_historyDiffEntries(entry, historyFieldLabels)
|
|
||||||
|
|
||||||
const selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
const pieceTypeDetails = ref<any | null>(null)
|
const pieceTypeDetails = ref<any | null>(null)
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
|
|||||||
@@ -193,78 +193,7 @@
|
|||||||
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
|
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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 { ModelType } from '~/services/modelTypes'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
normalizeCustomFieldInputs,
|
normalizeCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
@@ -338,7 +266,7 @@ interface PieceCatalogType extends ModelType {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
|
||||||
const { createPiece } = usePieces()
|
const { createPiece } = usePieces()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
@@ -385,7 +313,6 @@ watch(selectedTypeId, (id) => {
|
|||||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadingTypes = computed(() => loadingPieceTypes.value)
|
|
||||||
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
||||||
|
|
||||||
const typeOptionLabel = (type?: PieceCatalogType) =>
|
const typeOptionLabel = (type?: PieceCatalogType) =>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
<template #cell-preview="{ row }">
|
<template #cell-preview="{ row }">
|
||||||
<DocumentThumbnail
|
<DocumentThumbnail
|
||||||
:document="resolvePrimaryDocument(row.product)"
|
:document="resolvePrimaryDocument(row.product, true)"
|
||||||
:alt="resolvePreviewAlt(row.product)"
|
:alt="resolvePreviewAlt(row.product)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -147,8 +147,8 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
|
||||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||||
|
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ const productRows = computed(() =>
|
|||||||
normalizedProducts.value.map(product => ({
|
normalizedProducts.value.map(product => ({
|
||||||
id: product.id,
|
id: product.id,
|
||||||
product,
|
product,
|
||||||
suppliers: buildSuppliersDisplay(product),
|
suppliers: buildProductSuppliersDisplay(product),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,72 +225,8 @@ const formatPrice = (value: any) => {
|
|||||||
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE_SUPPLIERS = 3
|
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
|
||||||
|
buildSuppliersDisplay(resolveSupplierNames(product))
|
||||||
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 reload = () => fetchProducts()
|
const reload = () => fetchProducts()
|
||||||
|
|
||||||
|
|||||||
@@ -133,78 +133,7 @@
|
|||||||
Mettez à jour les valeurs propres à ce produit.
|
Mettez à jour les valeurs propres à ce produit.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
@@ -303,73 +232,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<EntityHistorySection
|
||||||
<header class="flex items-center justify-between gap-3">
|
:entries="history"
|
||||||
<div>
|
:loading="historyLoading"
|
||||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
:error="historyError"
|
||||||
<p class="text-xs text-base-content/70">
|
:field-labels="historyFieldLabels"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
@@ -410,7 +278,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
import { useProductHistory } from '~/composables/useProductHistory'
|
||||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
@@ -418,7 +286,6 @@ import type { ProductModelStructure } from '~/shared/types/inventory'
|
|||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
buildCustomFieldInputs,
|
buildCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
@@ -431,11 +298,6 @@ import {
|
|||||||
documentThumbnailClass,
|
documentThumbnailClass,
|
||||||
downloadDocument,
|
downloadDocument,
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
import {
|
|
||||||
historyActionLabel,
|
|
||||||
formatHistoryDate,
|
|
||||||
historyDiffEntries as _historyDiffEntries,
|
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -469,8 +331,6 @@ const productDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
|
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
const historyFieldLabels: Record<string, string> = {
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
reference: 'Référence',
|
reference: 'Référence',
|
||||||
@@ -479,9 +339,6 @@ const historyFieldLabels: Record<string, string> = {
|
|||||||
constructeurIds: 'Fournisseurs',
|
constructeurIds: 'Fournisseurs',
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyDiffEntries = (entry: ProductHistoryEntry) =>
|
|
||||||
_historyDiffEntries(entry, historyFieldLabels)
|
|
||||||
|
|
||||||
const refreshCustomFieldInputs = (
|
const refreshCustomFieldInputs = (
|
||||||
structureOverride?: ProductModelStructure | null,
|
structureOverride?: ProductModelStructure | null,
|
||||||
valuesOverride?: any[] | null,
|
valuesOverride?: any[] | null,
|
||||||
|
|||||||
@@ -119,78 +119,7 @@
|
|||||||
Renseignez les valeurs propres à ce produit catalogue.
|
Renseignez les valeurs propres à ce produit catalogue.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
|
||||||
const { createProduct } = useProducts()
|
const { createProduct } = useProducts()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue } = useCustomFields()
|
||||||
@@ -283,7 +212,6 @@ const uploadingDocuments = ref(false)
|
|||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
|
|
||||||
const loadingTypes = computed(() => loadingProductTypes.value)
|
|
||||||
const productTypeList = computed<ProductCatalogType[]>(() =>
|
const productTypeList = computed<ProductCatalogType[]>(() =>
|
||||||
(productTypes.value || []) as ProductCatalogType[],
|
(productTypes.value || []) as ProductCatalogType[],
|
||||||
)
|
)
|
||||||
@@ -354,9 +282,6 @@ const canSubmit = computed(() => Boolean(
|
|||||||
!submitting.value,
|
!submitting.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
|
||||||
field.customFieldId || field.id || `${field.name}-${index}`
|
|
||||||
|
|
||||||
const clearForm = () => {
|
const clearForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.reference = ''
|
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).
|
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
|
||||||
* Retourne "—" si la valeur est invalide ou absente.
|
* 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 => {
|
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '—'
|
return '—'
|
||||||
@@ -12,9 +18,5 @@ export const formatFrenchDate = (value: Date | string | number | null | undefine
|
|||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
return frenchDateFormatter.format(date)
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
}).format(date)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user