10 Commits

Author SHA1 Message Date
Matthieu 34af59d054 feat: show product thumbnails in catalogue list
Display the primary product document (image/pdf) as the leading column in the catalogue table for quicker visual identification.
2025-11-05 15:38:44 +01:00
Matthieu d860f24e69 feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
2025-11-05 15:35:02 +01:00
Matthieu 3af6c50892 feat: retire la colonne catégorie des catalogues 2025-10-31 10:04:40 +01:00
Matthieu dc2bc6c70a feat: afficher fournisseur dans les libellés front 2025-10-31 10:02:27 +01:00
Matthieu ef9a8b5b7b fix: format plain french numbers with dot grouping 2025-10-30 17:35:44 +01:00
Matthieu 53dab13489 feat: standardize contact formatting 2025-10-30 11:35:20 +01:00
Matthieu f59255e684 fix: de-duplicate constructeur ids before machine update 2025-10-30 11:34:58 +01:00
Matthieu 76cd3fac98 feat: improve piece structure editor UX 2025-10-30 11:34:19 +01:00
Matthieu 4c714b3647 feat: drag & drop des champs personnalisés 2025-10-28 18:08:14 +01:00
Matthieu b752fba69a feat: gérer les constructeurs multiples 2025-10-28 16:37:10 +01:00
56 changed files with 7795 additions and 589 deletions
+118 -2
View File
@@ -114,6 +114,61 @@
</ul> </ul>
</Transition> </Transition>
</li> </li>
<li class="mt-1 border-t border-base-200 pt-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
:class="
isActive('/product-category') || isActive('/product-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('products-mobile')"
@keydown.enter.prevent="toggleDropdown('products-mobile')"
@keydown.space.prevent="toggleDropdown('products-mobile')"
:aria-expanded="openDropdown === 'products-mobile'"
>
<span>Produits</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'products-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === 'products-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
>
<li>
<NuxtLink
to="/product-catalog"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/product-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des produits
</NuxtLink>
</li>
<li>
<NuxtLink
to="/product-category"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/product-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de produit
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li class="mt-1 border-t border-base-200 pt-2"> <li class="mt-1 border-t border-base-200 pt-2">
<button <button
type="button" type="button"
@@ -233,7 +288,7 @@
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content hover:bg-primary/10 hover:text-primary'
" "
> >
Constructeurs Fournisseurs
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
@@ -356,6 +411,67 @@
</ul> </ul>
</Transition> </Transition>
</li> </li>
<li
class="relative"
@mouseenter="setDropdown('products-desktop')"
@mouseleave="scheduleDropdownClose('products-desktop')"
@focusin="setDropdown('products-desktop')"
@focusout="scheduleDropdownClose('products-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
:class="
isActive('/product-category') || isActive('/product-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('products-desktop')"
@keydown.enter.prevent="toggleDropdown('products-desktop')"
@keydown.space.prevent="toggleDropdown('products-desktop')"
:aria-expanded="openDropdown === 'products-desktop'"
>
Produits
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === 'products-desktop'"
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
>
<li>
<NuxtLink
to="/product-category"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/product-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de produit
</NuxtLink>
</li>
<li>
<NuxtLink
to="/product-catalog"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/product-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des produits
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li <li
class="relative" class="relative"
@mouseenter="setDropdown('component-desktop')" @mouseenter="setDropdown('component-desktop')"
@@ -488,7 +604,7 @@
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content hover:bg-primary/10 hover:text-primary'
" "
> >
Constructeurs Fournisseurs
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
+320 -19
View File
@@ -32,8 +32,22 @@
Défini dans le catalogue Défini dans le catalogue
</span> </span>
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span> <span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
<span v-if="component.constructeur" class="badge badge-outline badge-sm">{{ component.constructeur?.name }}</span> <template v-if="componentConstructeursDisplay.length">
<span
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="badge badge-outline badge-sm"
>
{{ constructeur.name }}
</span>
</template>
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span> <span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
<span <span
v-if="component.typeMachineComponentRequirement" v-if="component.typeMachineComponentRequirement"
class="badge badge-outline badge-sm" class="badge badge-outline badge-sm"
@@ -90,19 +104,117 @@
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">Constructeur</span></label> <label class="label"><span class="label-text font-medium">Fournisseur</span></label>
<ConstructeurSelect <ConstructeurSelect
v-if="isEditMode" v-if="isEditMode"
class="w-full" class="w-full"
:model-value="component.constructeurId || component.constructeur?.id || null" :model-value="componentConstructeurIds"
@update:model-value="handleConstructeurChange" @update:model-value="handleConstructeurChange"
/> />
<div v-else class="input input-bordered input-sm bg-base-200"> <div v-else class="input input-bordered input-sm bg-base-200">
<div class="flex flex-col"> <div v-if="componentConstructeursDisplay.length" class="space-y-1">
<span class="font-medium">{{ component.constructeur?.name || 'Non défini' }}</span> <div
<span class="text-xs text-gray-500"> v-for="constructeur in componentConstructeursDisplay"
{{ [component.constructeur?.email, component.constructeur?.phone].filter(Boolean).join(' • ') }} :key="constructeur.id"
class="flex flex-col"
>
<span class="font-medium">{{ constructeur.name }}</span>
<span
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-gray-500"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">Non défini</span>
</div>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Produit catalogue</span>
</label>
<div class="input input-bordered input-sm bg-base-200 min-h-[2.75rem] flex flex-col justify-center space-y-1">
<template v-if="displayProduct">
<span class="font-semibold text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</span> </span>
<span
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
{{ info.label }} : {{ info.value }}
</span>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}/edit`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</template>
<span v-else class="font-medium">Non défini</span>
</div>
<div
v-if="productDocuments.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h4 class="font-medium text-base-content">
Documents du produit
</h4>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -331,6 +443,12 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useConstructeurs } from '~/composables/useConstructeurs'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
const props = defineProps({ const props = defineProps({
component: { component: {
@@ -406,6 +524,132 @@ const childComponents = computed(() => {
return Array.isArray(list) ? list : [] return Array.isArray(list) ? list : []
}) })
const { constructeurs } = useConstructeurs()
const buildProductDisplay = (product) => {
if (!product || typeof product !== 'object') {
return null
}
const suppliers = Array.isArray(product.constructeurs)
? product.constructeurs
.map((constructeur) => constructeur?.name)
.filter((name) => typeof name === 'string' && name.trim().length > 0)
.join(', ')
: product.supplierLabel || null
const priceValue =
product.supplierPrice ??
product.price ??
product.priceLabel ??
product.priceDisplay ??
null
let price = null
if (priceValue !== null && priceValue !== undefined) {
const parsed = Number(priceValue)
if (!Number.isNaN(parsed)) {
price = currencyFormatter.format(parsed)
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
price = priceValue
}
}
return {
name:
product.name ||
product.label ||
product.reference ||
product.productName ||
null,
reference: product.reference || null,
category: product.typeProduct?.name || product.category || null,
suppliers,
price,
}
}
const displayProduct = computed(() => {
const explicit = props.component.product || null
const normalized = buildProductDisplay(explicit)
if (normalized) {
return normalized
}
const fallback = props.component.__productDisplay
if (fallback) {
return {
name: fallback.name || null,
reference: fallback.reference || null,
category: fallback.category || null,
suppliers: fallback.suppliers || null,
price: fallback.price || null,
}
}
return null
})
const displayProductName = computed(() => {
if (displayProduct.value?.name) {
return displayProduct.value.name
}
return (
props.component.product?.name ||
props.component.productName ||
props.component.productLabel ||
null
)
})
const displayProductCategory = computed(() => displayProduct.value?.category || null)
const displayProductReference = computed(() => displayProduct.value?.reference || null)
const displayProductSuppliers = computed(() => displayProduct.value?.suppliers || null)
const displayProductPrice = computed(() => displayProduct.value?.price || null)
const productInfoRows = computed(() => {
if (!displayProduct.value) {
return []
}
const rows = []
if (displayProductReference.value) {
rows.push({ label: 'Référence', value: displayProductReference.value })
}
if (displayProductPrice.value) {
rows.push({ label: 'Prix indicatif', value: displayProductPrice.value })
}
if (displayProductSuppliers.value) {
rows.push({ label: 'Fournisseur(s)', value: displayProductSuppliers.value })
}
if (displayProductCategory.value) {
rows.push({ label: 'Catégorie', value: displayProductCategory.value })
}
return rows
})
const productDocuments = computed(() => {
const product = props.component.product
return Array.isArray(product?.documents) ? product.documents : []
})
const componentConstructeurIds = computed(() =>
uniqueConstructeurIds(
props.component,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
props.component.constructeur ? [props.component.constructeur] : [],
),
)
const componentConstructeursDisplay = computed(() =>
resolveConstructeurs(
componentConstructeurIds.value,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
props.component.constructeur ? [props.component.constructeur] : [],
constructeurs.value,
),
)
const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur)
const extractStructureCustomFields = (structure) => { const extractStructureCustomFields = (structure) => {
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {
return [] return []
@@ -415,16 +659,39 @@ const extractStructureCustomFields = (structure) => {
} }
function fieldKeyFromNameAndType(name, type) { function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name.trim() : '' const normalizedName =
const normalizedType = typeof type === 'string' ? type : '' typeof name === 'string' ? name.trim().toLowerCase() : ''
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : ''
return normalizedName ? `${normalizedName}::${normalizedType}` : null return normalizedName ? `${normalizedName}::${normalizedType}` : null
} }
function resolveOrderIndex(field) {
if (!field || typeof field !== 'object') {
return 0
}
if (typeof field.orderIndex === 'number') {
return field.orderIndex
}
if (
field.customField &&
typeof field.customField.orderIndex === 'number'
) {
return field.customField.orderIndex
}
return 0
}
function deduplicateFieldDefinitions(definitions) { function deduplicateFieldDefinitions(definitions) {
const result = [] const result = []
const seen = new Set() const seen = new Set()
;(Array.isArray(definitions) ? definitions : []).forEach((field) => { const orderedDefinitions = (Array.isArray(definitions)
? definitions.slice()
: []
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedDefinitions.forEach((field) => {
if (!field || typeof field !== 'object') { if (!field || typeof field !== 'object') {
return return
} }
@@ -444,6 +711,7 @@ function deduplicateFieldDefinitions(definitions) {
if (key) { if (key) {
seen.add(key) seen.add(key)
} }
field.orderIndex = resolveOrderIndex(field)
result.push(field) result.push(field)
}) })
@@ -484,10 +752,16 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
if (!matchedValue) { if (!matchedValue) {
return { return {
...field, ...field,
value: field?.value ?? '' value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
} }
} }
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
)
return { return {
...field, ...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null, customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
@@ -497,7 +771,8 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
fieldId ?? fieldId ??
null, null,
customField: matchedValue.customField ?? field.customField ?? null, customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '' value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
} }
}) })
@@ -537,23 +812,30 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
required: entry.customField?.required ?? false, required: entry.customField?.required ?? false,
options: entry.customField?.options ?? [], options: entry.customField?.options ?? [],
value: entry.value ?? '', value: entry.value ?? '',
customField: entry.customField ?? null customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
}) })
} }
}) })
return merged return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
} }
function dedupeMergedFields(fields) { function dedupeMergedFields(fields) {
if (!Array.isArray(fields) || fields.length <= 1) { if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields) ? fields : [] return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: []
} }
const seen = new Map() const seen = new Map()
const result = [] const result = []
fields.forEach((field) => { const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') { if (!field || typeof field !== 'object') {
return return
} }
@@ -571,12 +853,14 @@ function dedupeMergedFields(fields) {
const key = fieldId || nameKey const key = fieldId || nameKey
if (!key) { if (!key) {
field.orderIndex = resolveOrderIndex(field)
result.push(field) result.push(field)
return return
} }
const existing = seen.get(key) const existing = seen.get(key)
if (!existing) { if (!existing) {
field.orderIndex = resolveOrderIndex(field)
seen.set(key, field) seen.set(key, field)
result.push(field) result.push(field)
return return
@@ -594,11 +878,15 @@ function dedupeMergedFields(fields) {
if (!existingHasValue && incomingHasValue) { if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field) Object.assign(existing, field)
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
)
seen.set(key, existing) seen.set(key, existing)
} }
}) })
return result return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
} }
const componentDefinitionSources = computed(() => { const componentDefinitionSources = computed(() => {
@@ -686,7 +974,17 @@ watch(
) )
const handleConstructeurChange = async (value) => { const handleConstructeurChange = async (value) => {
props.component.constructeurId = value const ids = uniqueConstructeurIds(value)
props.component.constructeurIds = [...ids]
props.component.constructeurId = null
props.component.constructeur = null
props.component.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
)
await updateComponent() await updateComponent()
} }
@@ -723,7 +1021,10 @@ const toggleCollapse = () => {
} }
const updateComponent = () => { const updateComponent = () => {
emit('update', props.component) emit('update', {
...props.component,
constructeurIds: componentConstructeurIds.value,
})
} }
function resolveFieldKey(field, index) { function resolveFieldKey(field, index) {
@@ -5,6 +5,7 @@
:depth="0" :depth="0"
:component-types="availableComponentTypes" :component-types="availableComponentTypes"
:piece-types="availablePieceTypes" :piece-types="availablePieceTypes"
:product-types="availableProductTypes"
:lock-type="lockRootType" :lock-type="lockRootType"
:locked-type-label="displayedRootTypeLabel" :locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents" :allow-subcomponents="allowSubcomponents"
@@ -24,6 +25,7 @@ import {
} from '~/shared/modelUtils' } from '~/shared/modelUtils'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ComponentModelStructure } from '~/shared/types/inventory'
defineOptions({ name: 'ComponentModelStructureEditor' }) defineOptions({ name: 'ComponentModelStructureEditor' })
@@ -62,9 +64,11 @@ const previousLockedLabel = ref(props.rootTypeLabel || '')
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const availablePieceTypes = computed(() => pieceTypes.value ?? []) const availablePieceTypes = computed(() => pieceTypes.value ?? [])
const availableComponentTypes = computed(() => componentTypes.value ?? []) const availableComponentTypes = computed(() => componentTypes.value ?? [])
const availableProductTypes = computed(() => productTypes.value ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false) const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() => const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity, typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
@@ -187,9 +191,10 @@ const syncFromProps = (value: any) => {
return return
} }
const hydrated = hydrateStructureForEditor(value) const hydrated = hydrateStructureForEditor(value)
localStructure.customFields = hydrated.customFields localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces localStructure.pieces = hydrated.pieces
localStructure.subcomponents = hydrated.subcomponents localStructure.products = hydrated.products
localStructure.subcomponents = hydrated.subcomponents
localStructure.typeComposantId = hydrated.typeComposantId localStructure.typeComposantId = hydrated.typeComposantId
localStructure.typeComposantLabel = hydrated.typeComposantLabel localStructure.typeComposantLabel = hydrated.typeComposantLabel
localStructure.modelId = hydrated.modelId localStructure.modelId = hydrated.modelId
@@ -243,6 +248,9 @@ onMounted(async () => {
if (!availableComponentTypes.value.length) { if (!availableComponentTypes.value.length) {
loaders.push(loadComponentTypes()) loaders.push(loadComponentTypes())
} }
if (!availableProductTypes.value.length) {
loaders.push(loadProductTypes())
}
if (loaders.length) { if (loaders.length) {
await Promise.allSettled(loaders) await Promise.allSettled(loaders)
} }
@@ -66,6 +66,44 @@
</div> </div>
</section> </section>
<section v-if="assignment.products.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Produits requis par le squelette' : 'Produits associés à ce sous-composant' }}
</h4>
<p class="text-xs text-base-content/70">
Sélectionnez les produits catalogue à lier sur chaque position définie.
</p>
</header>
<div
v-for="productAssignment in assignment.products"
:key="productAssignment.path"
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describeProductRequirement(productAssignment.definition) }}
</p>
<p v-if="!getProductOptions(productAssignment.definition).length" class="text-[11px] text-error">
Aucun produit disponible pour cette catégorie.
</p>
</div>
<SearchSelect
:model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment.definition)"
:loading="productsLoading"
size="xs"
placeholder="Rechercher un produit..."
:empty-text="getProductOptions(productAssignment.definition).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel"
:option-description="productOptionDescription"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.subcomponents.length" class="space-y-4"> <section v-if="assignment.subcomponents.length" class="space-y-4">
<header class="space-y-1"> <header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content"> <h4 class="text-sm font-semibold text-base-content">
@@ -81,9 +119,11 @@
:key="subAssignment.path" :key="subAssignment.path"
:assignment="subAssignment" :assignment="subAssignment"
:pieces="pieces" :pieces="pieces"
:products="products"
:components="components" :components="components"
:components-loading="componentsLoading" :components-loading="componentsLoading"
:pieces-loading="piecesLoading" :pieces-loading="piecesLoading"
:products-loading="productsLoading"
:depth="depth + 1" :depth="depth + 1"
/> />
</section> </section>
@@ -95,6 +135,7 @@ import { computed, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue'; import SearchSelect from '~/components/common/SearchSelect.vue';
import type { import type {
ComponentModelPiece, ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode, ComponentModelStructureNode,
} from '~/shared/types/inventory'; } from '~/shared/types/inventory';
@@ -122,17 +163,36 @@ interface PieceOption {
} | null; } | null;
} }
interface ProductOption {
id: string;
name?: string | null;
reference?: string | null;
typeProductId?: string | null;
typeProduct?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
export interface StructurePieceAssignment { export interface StructurePieceAssignment {
path: string; path: string;
definition: ComponentModelPiece; definition: ComponentModelPiece;
selectedPieceId: string; selectedPieceId: string;
} }
export interface StructureProductAssignment {
path: string;
definition: ComponentModelProduct;
selectedProductId: string;
}
export interface StructureAssignmentNode { export interface StructureAssignmentNode {
path: string; path: string;
definition: ComponentModelStructureNode; definition: ComponentModelStructureNode;
selectedComponentId: string; selectedComponentId: string;
pieces: StructurePieceAssignment[]; pieces: StructurePieceAssignment[];
products: StructureProductAssignment[];
subcomponents: StructureAssignmentNode[]; subcomponents: StructureAssignmentNode[];
} }
@@ -140,17 +200,21 @@ const props = withDefaults(
defineProps<{ defineProps<{
assignment: StructureAssignmentNode; assignment: StructureAssignmentNode;
pieces: PieceOption[] | null; pieces: PieceOption[] | null;
products: ProductOption[] | null;
components: ComponentOption[] | null; components: ComponentOption[] | null;
depth?: number; depth?: number;
componentsLoading?: boolean; componentsLoading?: boolean;
piecesLoading?: boolean; piecesLoading?: boolean;
productsLoading?: boolean;
}>(), }>(),
{ {
depth: 0, depth: 0,
pieces: () => [], pieces: () => [],
products: () => [],
components: () => [], components: () => [],
componentsLoading: false, componentsLoading: false,
piecesLoading: false, piecesLoading: false,
productsLoading: false,
}, },
); );
@@ -269,6 +333,102 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
return parts.length ? parts.join(' • ') : 'Pièce du squelette'; return parts.length ? parts.join(' • ') : 'Pièce du squelette';
}; };
const getProductOptions = (definition: ComponentModelProduct) => {
const requiredTypeId =
definition.typeProductId ||
(definition as any).typeProduct?.id ||
definition.familyCode ||
null;
return (props.products || []).filter((product) => {
if (!product || typeof product !== 'object') {
return false;
}
if (!requiredTypeId) {
return true;
}
if (definition.typeProductId || (definition as any).typeProduct?.id) {
return (
product.typeProductId === requiredTypeId ||
product.typeProduct?.id === requiredTypeId
);
}
if (definition.familyCode) {
return (
product.typeProduct?.code === requiredTypeId ||
product.typeProductId === requiredTypeId
);
}
return false;
});
};
const productOptionLabel = (product?: ProductOption | null) => {
if (!product) {
return 'Produit';
}
return product.name || product.reference || 'Produit';
};
const productOptionDescription = (product?: ProductOption | null) => {
if (!product) {
return '';
}
const parts: string[] = [];
const typeLabel =
product.typeProduct?.name || product.typeProduct?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (product.reference) {
parts.push(`Ref. ${product.reference}`);
}
return parts.join(' • ');
};
const describeProductRequirement = (definition: ComponentModelProduct) => {
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed);
}
};
const options = getProductOptions(definition);
const fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role);
addPart(
definition.typeProductLabel ||
(definition as any).typeProduct?.name ||
fallbackType?.name,
);
const family =
definition.familyCode ||
(definition as any).typeProduct?.code ||
fallbackType?.code ||
null;
if (family) {
addPart(`Famille ${family}`);
}
if (parts.length === 0) {
addPart(fallbackType?.name);
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`);
}
}
if (parts.length === 0 && definition.typeProductId) {
addPart(`#${definition.typeProductId}`);
}
return parts.length ? parts.join(' • ') : 'Produit du squelette';
};
const requirementLabel = computed(() => { const requirementLabel = computed(() => {
const definition = props.assignment.definition || {}; const definition = props.assignment.definition || {};
const alias = definition.alias || definition.typeComposantLabel; const alias = definition.alias || definition.typeComposantLabel;
@@ -377,4 +537,20 @@ watch(
}, },
{ deep: true, immediate: true }, { deep: true, immediate: true },
); );
watch(
() => [props.products, props.assignment.products],
() => {
for (const productAssignment of props.assignment.products) {
const options = getProductOptions(productAssignment.definition);
if (
productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = '';
}
}
},
{ deep: true, immediate: true },
);
</script> </script>
+201 -84
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-2 constructeur-select"> <div class="space-y-2 constructeur-select">
<label v-if="label" class="label"><span class="label-text">{{ label }}</span></label> <label v-if="label" class="label"><span class="label-text">{{ label }}</span></label>
<div class="flex items-center gap-2"> <div class="flex items-start gap-2">
<div class="relative flex-1"> <div class="relative flex-1">
<input <input
v-model="searchTerm" v-model="searchTerm"
@@ -26,20 +26,24 @@
v-if="options.length === 0" v-if="options.length === 0"
class="px-3 py-2 text-xs text-gray-500" class="px-3 py-2 text-xs text-gray-500"
> >
Aucun constructeur trouvé Aucun fournisseur trouvé
</div> </div>
<button <button
v-for="option in options" v-for="option in options"
:key="option.id" :key="option.id"
type="button" type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none" class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="selectOption(option)" :class="{ 'bg-base-200': isSelected(option.id) }"
@click="toggleOption(option)"
> >
<div class="flex flex-col"> <div class="flex items-center justify-between gap-3">
<span class="font-medium">{{ option.name }}</span> <div class="flex flex-col">
<span class="text-xs text-gray-500"> <span class="font-medium">{{ option.name }}</span>
{{ [option.email, option.phone].filter(Boolean).join(' • ') || '—' }} <span class="text-xs text-gray-500">
</span> {{ formatConstructeurContact(option) || '—' }}
</span>
</div>
<IconLucideCheck v-if="isSelected(option.id)" class="w-4 h-4 text-primary" aria-hidden="true" />
</div> </div>
</button> </button>
</div> </div>
@@ -49,16 +53,31 @@
</button> </button>
</div> </div>
<div v-if="selectedConstructeur" class="text-xs text-gray-500"> <div class="flex flex-wrap gap-2 min-h-[1.5rem]">
<span class="font-medium">{{ selectedConstructeur.name }}</span> <span v-if="!selectedConstructeurs.length" class="text-sm text-gray-500">
<span v-if="selectedConstructeur.email"> {{ selectedConstructeur.email }}</span> Aucun fournisseur sélectionné
<span v-if="selectedConstructeur.phone"> {{ selectedConstructeur.phone }}</span> </span>
<span
v-for="constructeur in selectedConstructeurs"
:key="constructeur.id"
class="badge badge-outline gap-1"
>
<span>{{ constructeur.name }}</span>
<button
type="button"
class="btn btn-ghost btn-xs p-0"
aria-label="Retirer le fournisseur"
@click="removeConstructeur(constructeur.id)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</span>
</div> </div>
<dialog class="modal" :class="{ 'modal-open': openCreateModal }"> <dialog class="modal" :class="{ 'modal-open': openCreateModal }">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg mb-4"> <h3 class="font-bold text-lg mb-4">
Nouveau constructeur Nouveau fournisseur
</h3> </h3>
<form @submit.prevent="handleCreate"> <form @submit.prevent="handleCreate">
<div class="form-control mb-3"> <div class="form-control mb-3">
@@ -69,7 +88,7 @@
v-model="createForm.email" v-model="createForm.email"
class="mb-3" class="mb-3"
label="Email" label="Email"
placeholder="ex: contact@constructeur.com" placeholder="ex: contact@fournisseur.com"
autocomplete="email" autocomplete="email"
/> />
<FieldPhone <FieldPhone
@@ -94,89 +113,131 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import type { PropType } from 'vue'
import FieldEmail from '~/components/form/FieldEmail.vue' import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue' import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down' import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import {
type ConstructeurSummary,
formatConstructeurContact,
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: Array as PropType<string[]>,
default: null default: () => [],
}, },
label: { label: {
type: String, type: String,
default: '' default: '',
}, },
placeholder: { placeholder: {
type: String, type: String,
default: 'Sélectionner ou créer un constructeur...' default: 'Sélectionner ou créer un fournisseur...',
} },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const { constructeurs, searchConstructeurs, createConstructeur } = useConstructeurs() const { constructeurs, searchConstructeurs, createConstructeur } = useConstructeurs()
const searchTerm = ref('') const searchTerm = ref('')
const openDropdown = ref(false) const openDropdown = ref(false)
const openCreateModal = ref(false) const openCreateModal = ref(false)
const creating = ref(false) const creating = ref(false)
const options = ref([]) const options = ref<ConstructeurSummary[]>([])
let searchTimeout = null const selectedIds = ref<string[]>([])
let searchTimeout: ReturnType<typeof setTimeout> | null = null
let lastSearchTerm = '' let lastSearchTerm = ''
const applyOptions = (items = []) => { const uniqueOptions = (items: ConstructeurSummary[] = []) => {
const selectedId = props.modelValue const seen = new Map<string, ConstructeurSummary>()
const cloned = [...items] items.forEach((item) => {
const limited = cloned.slice(0, 10) if (item && typeof item === 'object' && typeof item.id === 'string') {
seen.set(item.id, item)
if (selectedId && !limited.some(item => item.id === selectedId)) {
const selected = cloned.find(item => item.id === selectedId)
if (selected) {
if (limited.length >= 10) { limited.pop() }
limited.unshift(selected)
} }
} })
return Array.from(seen.values())
}
options.value = limited const applyOptions = (items: ConstructeurSummary[] = []) => {
const normalized = uniqueOptions(items)
const limited = normalized.slice(0, 10)
selectedIds.value.forEach((id) => {
if (!limited.some((item) => item.id === id)) {
const match =
normalized.find((item) => item.id === id) ||
constructeurs.value.find((item) => item.id === id)
if (match) {
if (limited.length >= 10) {
limited.pop()
}
limited.unshift(match)
}
}
})
options.value = uniqueOptions(limited)
} }
const createForm = ref({ const createForm = ref({
name: '', name: '',
email: '', email: '',
phone: '' phone: '',
}) })
const selectedConstructeur = computed(() => const optionLookup = computed(() => {
constructeurs.value.find(item => item.id === props.modelValue) || null const map = new Map<string, ConstructeurSummary>()
) constructeurs.value.forEach((item: ConstructeurSummary) => {
map.set(item.id, item)
})
options.value.forEach((item) => {
map.set(item.id, item)
})
return map
})
watch( const selectedConstructeurs = computed<ConstructeurSummary[]>(() => {
() => props.modelValue, if (!selectedIds.value.length) {
(newValue) => { return []
if (newValue && !selectedConstructeur.value) { }
// ensure current selection is loaded
ensureOptionsLoaded(true)
}
if (newValue) {
const match = constructeurs.value.find(item => item.id === newValue)
if (match) {
searchTerm.value = match.name
}
}
},
{ immediate: true }
)
async function ensureOptionsLoaded (force = false) { return selectedIds.value
.map((id) => optionLookup.value.get(id))
.filter((item): item is ConstructeurSummary => Boolean(item))
})
const isSelected = (id: string) => selectedIds.value.includes(id)
const emitSelection = (ids: string[]) => {
const normalized = uniqueConstructeurIds(ids)
selectedIds.value = normalized
emit('update:modelValue', normalized)
}
const ensureOptionsLoaded = async (force = false) => {
if (!force && !searchTerm.value && constructeurs.value.length) { if (!force && !searchTerm.value && constructeurs.value.length) {
applyOptions(constructeurs.value) applyOptions(constructeurs.value as ConstructeurSummary[])
return return
} }
if (!force && searchTerm.value === lastSearchTerm && options.value.length) { return }
if (options.value.length && !force) { return } if (!force && searchTerm.value === lastSearchTerm && options.value.length) {
return
}
if (options.value.length && !force) {
return
}
const result = await searchConstructeurs(searchTerm.value) const result = await searchConstructeurs(searchTerm.value)
if (result.success) { if (result.success) {
applyOptions(result.data || []) applyOptions(result.data || [])
@@ -186,14 +247,18 @@ async function ensureOptionsLoaded (force = false) {
const onSearch = () => { const onSearch = () => {
openDropdown.value = true openDropdown.value = true
clearTimeout(searchTimeout) if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(async () => { searchTimeout = setTimeout(async () => {
if (!searchTerm.value && constructeurs.value.length) { if (!searchTerm.value && constructeurs.value.length) {
applyOptions(constructeurs.value) applyOptions(constructeurs.value as ConstructeurSummary[])
lastSearchTerm = '' lastSearchTerm = ''
return return
} }
if (searchTerm.value === lastSearchTerm) { return } if (searchTerm.value === lastSearchTerm) {
return
}
const result = await searchConstructeurs(searchTerm.value) const result = await searchConstructeurs(searchTerm.value)
if (result.success) { if (result.success) {
applyOptions(result.data || []) applyOptions(result.data || [])
@@ -202,10 +267,18 @@ const onSearch = () => {
}, 250) }, 250)
} }
const selectOption = (option) => { const toggleOption = (option: ConstructeurSummary) => {
emit('update:modelValue', option.id) const ids = new Set(selectedIds.value)
openDropdown.value = false if (ids.has(option.id)) {
searchTerm.value = option.name ids.delete(option.id)
} else {
ids.add(option.id)
}
emitSelection(Array.from(ids))
}
const removeConstructeur = (id: string) => {
emitSelection(selectedIds.value.filter((item) => item !== id))
} }
const closeCreateModal = () => { const closeCreateModal = () => {
@@ -216,31 +289,24 @@ const closeCreateModal = () => {
const handleCreate = async () => { const handleCreate = async () => {
creating.value = true creating.value = true
const payload = { ...createForm.value } const payload = { ...createForm.value }
if (!payload.phone) { delete payload.phone } if (!payload.phone) {
if (!payload.email) { delete payload.email } delete payload.phone
}
if (!payload.email) {
delete payload.email
}
const result = await createConstructeur(payload) const result = await createConstructeur(payload)
creating.value = false creating.value = false
if (result.success) { if (result.success) {
emit('update:modelValue', result.data.id) emitSelection([...selectedIds.value, result.data.id])
searchTerm.value = result.data.name searchTerm.value = ''
closeCreateModal() closeCreateModal()
await ensureOptionsLoaded(true) await ensureOptionsLoaded(true)
} }
} }
watch( const clickHandler = (event: Event) => {
constructeurs, const element = event.target as HTMLElement | null
(list) => {
applyOptions(list || [])
if (!searchTerm.value) {
lastSearchTerm = ''
}
},
{ immediate: true }
)
const clickHandler = (event) => {
const element = event.target
if (element && element.closest) { if (element && element.closest) {
if ( if (
element.closest('.menu') || element.closest('.menu') ||
@@ -254,6 +320,39 @@ const clickHandler = (event) => {
openDropdown.value = false openDropdown.value = false
} }
watch(
() => props.modelValue,
(newValue) => {
selectedIds.value = uniqueConstructeurIds(newValue)
},
{ immediate: true },
)
watch(
selectedIds,
async (ids) => {
if (!ids.length) {
return
}
const missing = ids.some((id) => !optionLookup.value.get(id))
if (missing) {
await ensureOptionsLoaded(true)
}
},
{ immediate: true },
)
watch(
constructeurs,
(list) => {
applyOptions((list as ConstructeurSummary[]) || [])
if (!searchTerm.value) {
lastSearchTerm = ''
}
},
{ immediate: true },
)
onMounted(() => { onMounted(() => {
window.addEventListener('click', clickHandler) window.addEventListener('click', clickHandler)
ensureOptionsLoaded() ensureOptionsLoaded()
@@ -261,6 +360,24 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', clickHandler) window.removeEventListener('click', clickHandler)
clearTimeout(searchTimeout) if (searchTimeout) {
clearTimeout(searchTimeout)
}
}) })
watch(
selectedIds,
(ids) => {
// ensure options contain newly selected ids
const resolved = resolveConstructeurs(
ids,
constructeurs.value as ConstructeurSummary[],
options.value,
)
if (resolved.length) {
applyOptions([...resolved, ...options.value])
}
},
{ immediate: true },
)
</script> </script>
+13 -2
View File
@@ -5,7 +5,7 @@
</h4> </h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div <div
v-for="field in customFields" v-for="field in sortedCustomFields"
:key="field.id" :key="field.id"
class="form-control" class="form-control"
> >
@@ -81,7 +81,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch, computed } from 'vue'
const props = defineProps({ const props = defineProps({
customFields: { customFields: {
@@ -101,6 +101,17 @@ const props = defineProps({
const emit = defineEmits(['update']) const emit = defineEmits(['update'])
const sortedCustomFields = computed(() => {
if (!Array.isArray(props.customFields)) {
return []
}
return [...props.customFields].sort((a, b) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
})
// Valeurs des champs personnalisés // Valeurs des champs personnalisés
const fieldValues = reactive({}) const fieldValues = reactive({})
@@ -32,7 +32,7 @@
<div> <div>
<p class="font-medium">Informations générales</p> <p class="font-medium">Informations générales</p>
<p class="text-xs text-base-content/60"> <p class="text-xs text-base-content/60">
Nom, site et constructeur de la machine. Nom, site et fournisseur de la machine.
</p> </p>
</div> </div>
</label> </label>
+485 -23
View File
@@ -48,6 +48,12 @@
> >
Rattachée à {{ piece.parentComponentName }} Rattachée à {{ piece.parentComponentName }}
</span> </span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div> </div>
</div> </div>
@@ -67,23 +73,34 @@
}}</span> }}</span>
</div> </div>
<div> <div>
<span class="font-medium">Constructeur:</span> <span class="font-medium">Fournisseur:</span>
<span v-if="!isEditMode" class="ml-2"> <div v-if="!isEditMode" class="ml-2">
<span class="font-medium">{{ <div v-if="pieceConstructeursDisplay.length" class="space-y-1">
piece.constructeur?.name || "Non défini" <div
}}</span> v-for="constructeur in pieceConstructeursDisplay"
<span v-if="piece.constructeur" class="block text-xs text-gray-500"> :key="constructeur.id"
{{ class="flex flex-col"
[piece.constructeur?.email, piece.constructeur?.phone] >
.filter(Boolean) <span class="font-medium">
.join(" ") {{ constructeur.name }}
}} </span>
<span
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-gray-500"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">
Non défini
</span> </span>
</span> </div>
<ConstructeurSelect <ConstructeurSelect
v-else v-else
class="w-full" class="w-full"
:model-value="piece.constructeurId || piece.constructeur?.id || null" :model-value="pieceConstructeurIds"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange" @update:model-value="handleConstructeurChange"
/> />
</div> </div>
@@ -102,6 +119,120 @@
pieceData.prix ? `${pieceData.prix}` : "Non défini" pieceData.prix ? `${pieceData.prix}` : "Non défini"
}}</span> }}</span>
</div> </div>
<div>
<span class="font-medium">Produit catalogue:</span>
<div v-if="isEditMode" class="mt-2 space-y-2">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}/edit`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div>
<div class="ml-2">
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
<span class="font-semibold">{{ info.label }} :</span>
<span class="ml-1">{{ info.value }}</span>
</p>
<div
v-if="productDocuments.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h5 class="font-medium text-base-content">Documents du produit</h5>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-5 w-5"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
</div>
</div> </div>
<!-- Champs personnalisés de la pièce --> <!-- Champs personnalisés de la pièce -->
@@ -353,6 +484,8 @@
<script setup> <script setup>
import { reactive, onMounted, watch, ref, computed } from "vue"; import { reactive, onMounted, watch, ref, computed } from "vue";
import ConstructeurSelect from "./ConstructeurSelect.vue"; import ConstructeurSelect from "./ConstructeurSelect.vue";
import ProductSelect from "~/components/ProductSelect.vue";
import { useConstructeurs } from "~/composables/useConstructeurs";
import { useCustomFields } from "~/composables/useCustomFields"; import { useCustomFields } from "~/composables/useCustomFields";
import { useToast } from "~/composables/useToast"; import { useToast } from "~/composables/useToast";
import { useDocuments } from "~/composables/useDocuments"; import { useDocuments } from "~/composables/useDocuments";
@@ -361,6 +494,12 @@ import { canPreviewDocument, isImageDocument, isPdfDocument } from "~/utils/docu
import DocumentUpload from "~/components/DocumentUpload.vue"; import DocumentUpload from "~/components/DocumentUpload.vue";
import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue"; import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue";
import IconLucidePackage from "~icons/lucide/package"; import IconLucidePackage from "~icons/lucide/package";
import { useProducts } from "~/composables/useProducts";
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
} from "~/shared/constructeurUtils";
const props = defineProps({ const props = defineProps({
piece: { piece: {
@@ -384,6 +523,7 @@ const pieceData = reactive({
name: props.piece.name || "", name: props.piece.name || "",
reference: props.piece.reference || "", reference: props.piece.reference || "",
prix: props.piece.prix || "", prix: props.piece.prix || "",
productId: props.piece.product?.id || props.piece.productId || null,
}); });
const selectedFiles = ref([]); const selectedFiles = ref([]);
@@ -439,16 +579,36 @@ const extractStructureCustomFields = (structure) => {
return Array.isArray(customFields) ? customFields : []; return Array.isArray(customFields) ? customFields : [];
}; };
function fieldKeyFromNameAndType(name, type) { function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name : ''; const normalizedName =
const normalizedType = typeof type === 'string' ? type : ''; typeof name === 'string' ? name.trim().toLowerCase() : '';
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : '';
return normalizedName ? `${normalizedName}::${normalizedType}` : null; return normalizedName ? `${normalizedName}::${normalizedType}` : null;
} }
function resolveOrderIndex(field) {
if (!field || typeof field !== 'object') {
return 0;
}
if (typeof field.orderIndex === 'number') {
return field.orderIndex;
}
if (field.customField && typeof field.customField.orderIndex === 'number') {
return field.customField.orderIndex;
}
return 0;
}
function deduplicateFieldDefinitions(definitions) { function deduplicateFieldDefinitions(definitions) {
const result = []; const result = [];
const seen = new Set(); const seen = new Set();
(Array.isArray(definitions) ? definitions : []).forEach((field) => { const orderedDefinitions = (Array.isArray(definitions)
? definitions.slice()
: []
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedDefinitions.forEach((field) => {
if (!field || typeof field !== 'object') { if (!field || typeof field !== 'object') {
return; return;
} }
@@ -465,6 +625,7 @@ function deduplicateFieldDefinitions(definitions) {
if (key) { if (key) {
seen.add(key); seen.add(key);
} }
field.orderIndex = resolveOrderIndex(field);
result.push(field); result.push(field);
}); });
@@ -512,9 +673,15 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
return { return {
...field, ...field,
value: field?.value ?? '', value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
}; };
} }
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
);
return { return {
...field, ...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null, customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
@@ -525,6 +692,7 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
null, null,
customField: matchedValue.customField ?? field.customField ?? null, customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '', value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
}; };
}); });
@@ -571,22 +739,31 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
options: entry.customField?.options ?? [], options: entry.customField?.options ?? [],
value: entry.value ?? '', value: entry.value ?? '',
customField: entry.customField ?? null, customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
}); });
} }
}); });
return merged; return merged.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
} }
function dedupeMergedFields(fields) { function dedupeMergedFields(fields) {
if (!Array.isArray(fields) || fields.length <= 1) { if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields) ? fields : []; return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: [];
} }
const seen = new Map(); const seen = new Map();
const result = []; const result = [];
fields.forEach((field) => { const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') { if (!field || typeof field !== 'object') {
return; return;
} }
@@ -615,12 +792,14 @@ function dedupeMergedFields(fields) {
const key = fieldId || nameKey; const key = fieldId || nameKey;
if (!key) { if (!key) {
field.orderIndex = resolveOrderIndex(field);
result.push(field); result.push(field);
return; return;
} }
const existing = seen.get(key); const existing = seen.get(key);
if (!existing) { if (!existing) {
field.orderIndex = resolveOrderIndex(field);
seen.set(key, field); seen.set(key, field);
result.push(field); result.push(field);
return; return;
@@ -637,11 +816,17 @@ function dedupeMergedFields(fields) {
if (!existingHasValue && incomingHasValue) { if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field); Object.assign(existing, field);
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
);
seen.set(key, existing); seen.set(key, existing);
} }
}); });
return result; return result.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
} }
const pieceDefinitionSources = computed(() => { const pieceDefinitionSources = computed(() => {
@@ -716,8 +901,271 @@ const candidateCustomFields = computed(() => {
return Array.from(map.values()); return Array.from(map.values());
}); });
const { constructeurs } = useConstructeurs();
const { products, loadProducts, getProduct } = useProducts();
const pieceConstructeurIds = computed(() =>
uniqueConstructeurIds(
props.piece,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
props.piece.constructeur ? [props.piece.constructeur] : [],
),
);
const pieceConstructeursDisplay = computed(() =>
resolveConstructeurs(
pieceConstructeurIds.value,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
props.piece.constructeur ? [props.piece.constructeur] : [],
constructeurs.value,
),
);
const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur);
const currencyFormatter = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
currencyDisplay: "narrowSymbol",
});
const selectedProduct = computed(() => {
const id = pieceData.productId;
if (!id) {
return null;
}
const list = Array.isArray(products.value) ? products.value : [];
const cached = list.find((product) => product && product.id === id) || null;
if (cached) {
return cached;
}
const current = props.piece.product;
if (current && current.id === id) {
return current;
}
return null;
});
const productConstructeurs = computed(() => {
const product = selectedProduct.value;
if (!product) {
return [];
}
const list = Array.isArray(product.constructeurs) ? product.constructeurs : [];
return list.filter((item) => item && typeof item === "object");
});
const productConstructeurNames = computed(() => {
const list = productConstructeurs.value;
if (!list.length) {
return "";
}
return list
.map((constructeur) => constructeur?.name)
.filter((name) => typeof name === "string" && name.trim().length > 0)
.join(", ");
});
const productSupplierPrice = computed(() => {
const product = selectedProduct.value;
if (!product || product.supplierPrice === undefined || product.supplierPrice === null) {
return null;
}
const number = Number(product.supplierPrice);
if (Number.isNaN(number)) {
return null;
}
return currencyFormatter.format(number);
});
const displayProduct = computed(() => selectedProduct.value || props.piece.__productDisplay || null);
const displayProductName = computed(() => {
if (!displayProduct.value) {
return null
}
const product = displayProduct.value
return (
product.name ||
product.label ||
product.reference ||
null
)
})
const displayProductCategory = computed(() =>
displayProduct.value
? displayProduct.value.typeProduct?.name ||
displayProduct.value.category ||
null
: null,
);
const displayProductReference = computed(() =>
displayProduct.value ? displayProduct.value.reference || null : null,
);
const displayProductSuppliers = computed(() => {
if (selectedProduct.value) {
return productConstructeurNames.value;
}
if (displayProduct.value) {
return (
displayProduct.value.suppliers ||
displayProduct.value.supplierLabel ||
null
);
}
return null;
});
const displayProductPrice = computed(() => {
if (selectedProduct.value) {
return productSupplierPrice.value;
}
if (displayProduct.value) {
const price =
displayProduct.value.price ||
displayProduct.value.priceLabel ||
displayProduct.value.priceDisplay ||
displayProduct.value.priceLabel;
return price || null;
}
return null;
});
const productInfoRows = computed(() => {
if (!displayProduct.value) {
return [];
}
const rows = [];
if (displayProductReference.value) {
rows.push({ label: "Référence", value: displayProductReference.value });
}
if (displayProductPrice.value) {
rows.push({ label: "Prix indicatif", value: displayProductPrice.value });
}
if (displayProductSuppliers.value) {
rows.push({ label: "Fournisseur(s)", value: displayProductSuppliers.value });
}
if (displayProductCategory.value) {
rows.push({ label: "Catégorie", value: displayProductCategory.value });
}
return rows;
});
const productDocuments = computed(() => {
const product =
selectedProduct.value ||
props.piece.product ||
null;
return Array.isArray(product?.documents) ? product.documents : [];
});
const ensureProductLoaded = async (id) => {
if (!id) {
return null;
}
const list = Array.isArray(products.value) ? products.value : [];
const cached = list.find((product) => product && product.id === id);
if (cached) {
return cached;
}
const result = await getProduct(id, { force: true });
if (result.success && result.data) {
return result.data;
}
return null;
};
onMounted(() => {
loadProducts().catch(() => {});
if (pieceData.productId) {
ensureProductLoaded(pieceData.productId);
}
});
watch(
() => props.piece.product?.id || props.piece.productId || null,
async (id, prevId) => {
if (pieceData.productId === id) {
if (id && !selectedProduct.value) {
const resolved = await ensureProductLoaded(id);
if (resolved) {
props.piece.product = resolved;
}
}
if (!id) {
props.piece.product = null;
}
return;
}
pieceData.productId = id;
if (id) {
const resolved = await ensureProductLoaded(id);
if (resolved) {
props.piece.product = resolved;
const supplierPrice = resolved.supplierPrice;
if (
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
supplierPrice !== null &&
supplierPrice !== undefined
) {
const number = Number(supplierPrice);
if (!Number.isNaN(number)) {
pieceData.prix = String(number);
}
}
}
} else {
props.piece.product = null;
}
},
{ immediate: true }
);
const handleProductChange = async (value) => {
const nextId = value || null;
pieceData.productId = nextId;
props.piece.productId = nextId;
if (!nextId) {
props.piece.product = null;
updatePiece();
return;
}
const resolved = await ensureProductLoaded(nextId);
if (resolved) {
props.piece.product = resolved;
const supplierPrice = resolved.supplierPrice;
if (
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
supplierPrice !== null &&
supplierPrice !== undefined
) {
const number = Number(supplierPrice);
if (!Number.isNaN(number)) {
pieceData.prix = String(number);
}
}
}
updatePiece();
};
const handleConstructeurChange = (value) => { const handleConstructeurChange = (value) => {
props.piece.constructeurId = value; const ids = uniqueConstructeurIds(value);
props.piece.constructeurIds = [...ids];
props.piece.constructeurId = null;
props.piece.constructeur = null;
props.piece.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
);
updatePiece(); updatePiece();
}; };
@@ -967,11 +1415,25 @@ const setCustomFieldValue = (fieldValueId, value, field) => {
const updatePiece = () => { const updatePiece = () => {
const prixValue = pieceData.prix; const prixValue = pieceData.prix;
let parsedPrice = null;
if (
prixValue !== null &&
prixValue !== undefined &&
String(prixValue).trim().length > 0
) {
const numeric = Number(prixValue);
if (!Number.isNaN(numeric)) {
parsedPrice = numeric;
}
}
const product = selectedProduct.value ? { ...selectedProduct.value } : null;
emit("update", { emit("update", {
...props.piece, ...props.piece,
...pieceData, ...pieceData,
prix: prixValue && prixValue !== "" ? parseFloat(prixValue) : null, prix: parsedPrice,
constructeurId: props.piece.constructeurId || null, productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
}); });
}; };
+467 -135
View File
@@ -1,7 +1,69 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-6">
<section class="space-y-3"> <section class="space-y-3">
<div class="flex items-center justify-between"> <header class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold">
Produits inclus par défaut
</h3>
<p class="text-xs text-base-content/70">
Ces produits safficheront lors de la création dune pièce basée sur cette catégorie.
</p>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header>
<p v-if="!products.length" class="text-xs text-gray-500">
Aucun produit défini.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(product, index) in products"
:key="product.uid"
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Famille de produit</span>
</label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypeOptions"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</li>
</ul>
</section>
<section class="space-y-3">
<header class="flex items-center justify-between">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
Champs personnalisés Champs personnalisés
</h3> </h3>
@@ -9,173 +71,348 @@
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" /> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter Ajouter
</button> </button>
</div> </header>
<p v-if="!localFields.length" class="text-xs text-gray-500"> <p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé n'a encore été défini. Aucun champ personnalisé n'a encore été défini.
</p> </p>
<div v-else class="space-y-2"> <ul v-else class="space-y-2" role="list">
<div <li
v-for="(field, index) in localFields" v-for="(field, index) in fields"
:key="`custom-field-${index}`" :key="field.uid"
class="border border-base-200 rounded-md p-3 space-y-2" class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
:class="reorderClass(index)"
draggable="true"
@dragstart="onDragStart(index, $event)"
@dragenter="onDragEnter(index)"
@dragover.prevent="onDragEnter(index)"
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start gap-3">
<div class="flex-1 space-y-2"> <button
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> type="button"
<input class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
v-model="field.name" title="Réordonner"
type="text" draggable="false"
class="input input-bordered input-xs" >
placeholder="Nom du champ" <IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
> </button>
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs"> <div class="flex-1 space-y-2">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
Obligatoire <input
</div> v-model="field.name"
type="text"
<textarea class="input input-bordered input-xs"
v-if="field.type === 'select'" placeholder="Nom du champ"
v-model="field.optionsText" >
class="textarea textarea-bordered textarea-xs h-20" <select v-model="field.type" class="select select-bordered select-xs">
placeholder="Option 1&#10;Option 2" <option value="text">
/> Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div> </div>
<button
type="button" <div class="flex items-center gap-2 text-xs">
class="btn btn-error btn-xs btn-square" <input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
@click="removeField(index)" Obligatoire
> </div>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> <textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
/>
</div> </div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div> </div>
</div> </li>
</ul>
</section> </section>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { computed, reactive, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import type {
PieceModelCustomField,
PieceModelCustomFieldType,
PieceModelProduct,
PieceModelStructure,
PieceModelStructureEditorField,
} from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
import { useProductTypes } from '~/composables/useProductTypes'
const props = defineProps({ defineOptions({ name: 'PieceModelStructureEditor' })
modelValue: {
type: Object,
default: () => ({ customFields: [] })
}
})
const emit = defineEmits(['update:modelValue']) type EditorField = PieceModelStructureEditorField & { uid: string }
type EditorProduct = {
uid: string
typeProductId: string
typeProductLabel: string
familyCode: string
}
const ensureArray = value => (Array.isArray(value) ? value : []) const props = defineProps<{
modelValue?: PieceModelStructure | null
}>()
const clone = (input, fallback = {}) => { const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void
}>()
const { productTypes, loadProductTypes } = useProductTypes()
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
Array.isArray(value) ? value : []
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const safeClone = <T,>(value: T, fallback: T): T => {
try { try {
return JSON.parse(JSON.stringify(input ?? fallback)) return JSON.parse(JSON.stringify(value ?? fallback)) as T
} catch (error) { } catch {
return JSON.parse(JSON.stringify(fallback)) return JSON.parse(JSON.stringify(fallback)) as T
} }
} }
const extractRest = (structure = {}) => { const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {
return {} return {}
} }
return Object.fromEntries( const entries = Object.entries(structure).filter(
Object.entries(structure).filter(([key]) => key !== 'customFields') ([key]) => key !== 'customFields' && key !== 'products',
) )
return safeClone(Object.fromEntries(entries), {})
} }
const toEditorField = (input = {}) => ({ let uidCounter = 0
name: typeof input.name === 'string' ? input.name : '', const createUid = (scope: 'field' | 'product'): string => {
type: typeof input.type === 'string' && input.type ? input.type : 'text', if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
required: Boolean(input.required), return crypto.randomUUID()
optionsText: Array.isArray(input.options)
? input.options.join('\n')
: typeof input.optionsText === 'string'
? input.optionsText
: ''
})
const hydrateFields = (structure = {}) => ensureArray(structure.customFields).map(toEditorField)
const localState = reactive({
fields: hydrateFields(props.modelValue)
})
const extraState = reactive({
rest: clone(extractRest(props.modelValue))
})
const localFields = computed({
get: () => localState.fields,
set: (value) => {
localState.fields = ensureArray(value).map(toEditorField)
} }
uidCounter += 1
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
}
const toEditorField = (
input: Partial<PieceModelStructureEditorField> | null | undefined,
index: number,
): EditorField => {
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
const optionsText = normalizeLineEndings(
typeof input?.optionsText === 'string'
? input.optionsText
: Array.isArray(input?.options)
? input.options.join('\n')
: '',
)
return {
uid: createUid('field'),
name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required),
optionsText,
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
}
}
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
const source = ensureArray(structure?.customFields)
return source
.map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
}
const toEditorProduct = (
input: Partial<PieceModelProduct> | null | undefined,
): EditorProduct => ({
uid: createUid('product'),
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
typeProductLabel:
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
}) })
const normalizeFields = (fields) => { const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
return ensureArray(fields) const source = Array.isArray(structure?.products) ? structure?.products : []
.map((field) => { return source.map((product) => toEditorProduct(product))
const name = typeof field.name === 'string' ? field.name.trim() : '' }
const productTypeOptions = computed(() => productTypes.value ?? [])
const productTypeMap = computed(() => {
const map = new Map<string, any>()
productTypeOptions.value.forEach((type: any) => {
if (type?.id) {
map.set(type.id, type)
}
})
return map
})
const formatProductTypeOption = (type: any) => {
if (!type) {
return ''
}
const parts: string[] = []
if (type.code) {
parts.push(type.code)
}
if (type.name) {
parts.push(type.name)
}
return parts.length ? parts.join(' • ') : type.id || ''
}
const updateProductTypeMetadata = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
}
const handleProductTypeSelect = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
if (option?.code) {
product.familyCode = option.code
}
}
const createEmptyProduct = (): EditorProduct => ({
uid: createUid('product'),
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
const addProduct = () => {
products.value.push(createEmptyProduct())
}
const removeProduct = (index: number) => {
products.value = products.value.filter((_, idx) => idx !== index)
}
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({
...field,
orderIndex: index,
}))
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
if (!typeProductId && !familyCode) {
return null
}
const payload: PieceModelProduct = {}
if (typeProductId) {
payload.typeProductId = typeProductId
}
if (familyCode) {
payload.familyCode = familyCode
}
if (product.typeProductLabel) {
payload.typeProductLabel = product.typeProductLabel
}
return payload
}
const buildPayload = (
fieldsSource: EditorField[],
productsSource: EditorProduct[],
restSource: Record<string, unknown>,
): PieceModelStructure => {
const normalizedFields = fieldsSource
.map<PieceModelCustomField | null>((field, index) => {
const name = field.name.trim()
if (!name) { if (!name) {
return null return null
} }
const type = field.type || 'text' const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required) const required = Boolean(field.required)
let options const payload: PieceModelCustomField = {
if (type === 'select') { name,
const raw = typeof field.optionsText === 'string' ? field.optionsText : '' type,
const parsed = raw required,
.split(/\r?\n/) orderIndex: index,
.map(option => option.trim())
.filter(option => option.length > 0)
options = parsed.length > 0 ? parsed : undefined
} }
const normalized = { name, type, required } if (type === 'select') {
if (options) { const options = normalizeLineEndings(field.optionsText)
normalized.options = options .split('\n')
.map((option) => option.trim())
.filter((option) => option.length > 0)
if (options.length > 0) {
payload.options = options
}
} }
return normalized
return payload
}) })
.filter(Boolean) .filter((field): field is PieceModelCustomField => Boolean(field))
const normalizedProducts = productsSource
.map((product) => normalizeProductEntry(product))
.filter((product): product is PieceModelProduct => Boolean(product))
const draft: PieceModelStructure = {
...safeClone(restSource, {}),
products: normalizedProducts,
customFields: normalizedFields,
}
return normalizePieceStructureForSave(draft)
} }
let lastEmitted = JSON.stringify({ const serializeStructure = (structure?: PieceModelStructure | null): string => {
...clone(extraState.rest, {}), return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
customFields: normalizeFields(props.modelValue?.customFields) }
})
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => { const emitUpdate = () => {
const customFields = normalizeFields(localFields.value) const payload = buildPayload(fields.value, products.value, restState.value)
const payload = {
...clone(extraState.rest, {}),
customFields
}
const serialized = JSON.stringify(payload) const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) { if (serialized !== lastEmitted) {
lastEmitted = serialized lastEmitted = serialized
@@ -183,26 +420,121 @@ const emitUpdate = () => {
} }
} }
watch(fields, emitUpdate, { deep: true })
watch(products, emitUpdate, { deep: true })
watch(productTypeOptions, () => {
products.value.forEach((product) => updateProductTypeMetadata(product))
})
watch( watch(
() => props.modelValue, () => props.modelValue,
(value) => { (value) => {
localFields.value = hydrateFields(value) const incomingSerialized = serializeStructure(value)
extraState.rest = clone(extractRest(value), {}) if (incomingSerialized === lastEmitted) {
lastEmitted = JSON.stringify({ return
...clone(extraState.rest, {}), }
customFields: normalizeFields(value?.customFields) restState.value = extractRest(value)
}) fields.value = hydrateFields(value)
products.value = hydrateProducts(value)
products.value.forEach((product) => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
}, },
{ deep: true } { deep: true },
) )
watch(localFields, emitUpdate, { deep: true }) onMounted(async () => {
if (!productTypeOptions.value.length) {
await loadProductTypes()
}
products.value.forEach((product) => updateProductTypeMetadata(product))
})
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const reorderFields = (from: number, to: number) => {
if (from === to) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) {
return
}
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
if (dragState.draggingIndex === null) {
resetDragState()
return
}
reorderFields(dragState.draggingIndex, index)
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number) => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null &&
dragState.dropTargetIndex === index &&
dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex,
})
const addField = () => { const addField = () => {
localFields.value = [...localFields.value, toEditorField()] const next = fields.value.slice()
next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
} }
const removeField = (index) => { const removeField = (index: number) => {
localFields.value = localFields.value.filter((_, i) => i !== index) const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
} }
</script> </script>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue"
:options="productOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
option-label="name"
:disabled="disabled"
@update:modelValue="updateValue"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useProducts } from '~/composables/useProducts'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typeProductId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner un produit…',
emptyText: 'Aucun produit disponible',
helperText: '',
disabled: false,
typeProductId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { products, loading, loadProducts } = useProducts()
const productOptions = computed(() => {
const baseOptions = Array.isArray(products.value) ? products.value : []
if (!props.typeProductId) {
return baseOptions
}
const allowedTypeId = String(props.typeProductId)
return baseOptions.filter((product) => {
const typeId =
product?.typeProductId ||
product?.typeProduct?.id ||
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => {
if (productOptions.value.length === 0) {
loadProducts().catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
})
watch(
() => props.modelValue,
(value) => {
if (typeof value === 'string') {
const exists = productOptions.value.some((product) => product.id === value)
if (!exists && productOptions.value.length === 0 && !loading.value) {
loadProducts().catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatDescription = (option: any) => {
const parts: string[] = []
if (option?.reference) {
parts.push(option.reference)
}
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
const price = Number(option.supplierPrice)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>
+346 -3
View File
@@ -79,9 +79,27 @@
<div <div
v-for="(field, index) in node.customFields" v-for="(field, index) in node.customFields"
:key="`field-${index}`" :key="`field-${index}`"
class="border border-base-200 rounded-md p-3 space-y-2" class="border border-base-200 rounded-md p-3 space-y-2 transition-colors"
:class="customFieldReorderClass(index)"
draggable="true"
@dragstart="onCustomFieldDragStart(index, $event)"
@dragenter="onCustomFieldDragEnter(index)"
@dragover.prevent
@drop="onCustomFieldDrop(index)"
@dragend="onCustomFieldDragEnd"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
title="Réorganiser"
draggable="true"
@dragstart.stop="onCustomFieldDragStart(index, $event)"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input <input
@@ -121,6 +139,69 @@
</div> </div>
</section> </section>
<section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(node.products?.length)" class="text-xs text-gray-500">
Aucun produit défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(product, index) in node.products"
:key="`product-${index}`"
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
:class="productReorderClass(index)"
@dragenter="onProductDragEnter(index)"
@dragover="onProductDragOver"
@drop="onProductDrop(index)"
>
<button
type="button"
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onProductDragStart(index, $event)"
@dragend="onProductDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Famille de produit</span></label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypes"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</section>
<section v-if="isRoot" class="space-y-3"> <section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<h4 :class="headingClass"> <h4 :class="headingClass">
@@ -233,6 +314,7 @@
:depth="depth + 1" :depth="depth + 1"
:component-types="componentTypes" :component-types="componentTypes"
:piece-types="pieceTypes" :piece-types="pieceTypes"
:product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents" :allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth" :max-subcomponent-depth="maxSubcomponentDepth"
@remove="removeSubComponent(index)" @remove="removeSubComponent(index)"
@@ -250,7 +332,7 @@ import { computed, ref, watch } from 'vue'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import IconLucideGripVertical from '~icons/lucide/grip-vertical' import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import type { ComponentModelPiece, ComponentModelStructureNode } from '~/shared/types/inventory' import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
defineOptions({ name: 'StructureNodeEditor' }) defineOptions({ name: 'StructureNodeEditor' })
@@ -263,6 +345,7 @@ type ModelTypeOption = {
type EditableStructureNode = ComponentModelStructureNode & { type EditableStructureNode = ComponentModelStructureNode & {
customFields?: any[] customFields?: any[]
pieces?: ComponentModelPiece[] pieces?: ComponentModelPiece[]
products?: ComponentModelProduct[]
} }
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -270,6 +353,7 @@ const props = withDefaults(defineProps<{
depth?: number depth?: number
componentTypes?: ModelTypeOption[] componentTypes?: ModelTypeOption[]
pieceTypes?: ModelTypeOption[] pieceTypes?: ModelTypeOption[]
productTypes?: ModelTypeOption[]
isRoot?: boolean isRoot?: boolean
lockType?: boolean lockType?: boolean
lockedTypeLabel?: string lockedTypeLabel?: string
@@ -279,6 +363,7 @@ const props = withDefaults(defineProps<{
depth: 0, depth: 0,
componentTypes: () => [], componentTypes: () => [],
pieceTypes: () => [], pieceTypes: () => [],
productTypes: () => [],
isRoot: false, isRoot: false,
lockType: false, lockType: false,
lockedTypeLabel: '', lockedTypeLabel: '',
@@ -290,6 +375,7 @@ const emit = defineEmits(['remove'])
const componentTypes = computed(() => props.componentTypes ?? []) const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? []) const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false) const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() => const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity, typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
@@ -354,6 +440,16 @@ const pieceTypeMap = computed(() => {
return map return map
}) })
const productTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
productTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const getComponentTypeLabel = (id?: string) => { const getComponentTypeLabel = (id?: string) => {
if (!id) return '' if (!id) return ''
return formatModelTypeOption(componentTypeMap.value.get(id)) return formatModelTypeOption(componentTypeMap.value.get(id))
@@ -364,16 +460,26 @@ const getPieceTypeLabel = (id?: string) => {
return formatModelTypeOption(pieceTypeMap.value.get(id)) return formatModelTypeOption(pieceTypeMap.value.get(id))
} }
const getProductTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(productTypeMap.value.get(id))
}
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) => const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type) formatModelTypeOption(type)
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) => const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type) formatModelTypeOption(type)
const ensureArray = (key: 'customFields' | 'pieces' | 'subcomponents') => { const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) { if (!Array.isArray((props.node as any)[key])) {
if (key === 'subcomponents') { if (key === 'subcomponents') {
props.node.subcomponents = [] props.node.subcomponents = []
} else if (key === 'products') {
props.node.products = []
} else { } else {
(props.node as any)[key] = [] (props.node as any)[key] = []
} }
@@ -475,6 +581,37 @@ const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>)
} }
} }
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) return
if (product.typeProductId) {
const option = productTypeMap.value.get(product.typeProductId)
if (option) {
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
return
}
}
if (product.typeProductLabel) {
const normalized = product.typeProductLabel.trim().toLowerCase()
if (normalized) {
const match = productTypes.value.find((type) => {
const formatted = formatProductTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
product.typeProductId = match.id
product.typeProductLabel = formatProductTypeOption(match)
product.familyCode = match.code ?? product.familyCode ?? ''
return
}
}
}
}
const syncPieceLabels = (pieces?: any[]) => { const syncPieceLabels = (pieces?: any[]) => {
if (!Array.isArray(pieces)) { if (!Array.isArray(pieces)) {
return return
@@ -484,6 +621,15 @@ const syncPieceLabels = (pieces?: any[]) => {
}) })
} }
const syncProductLabels = (products?: any[]) => {
if (!Array.isArray(products)) {
return
}
products.forEach((product) => {
updateProductTypeLabel(product)
})
}
const handleComponentTypeSelect = (component: any) => { const handleComponentTypeSelect = (component: any) => {
syncComponentType(component) syncComponentType(component)
} }
@@ -506,20 +652,116 @@ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>)
piece.typePieceLabel = formatPieceTypeOption(option) piece.typePieceLabel = formatPieceTypeOption(option)
} }
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) {
return
}
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
if (!id) {
product.typeProductLabel = ''
return
}
const option = productTypeMap.value.get(id)
if (!option) {
product.typeProductId = ''
product.typeProductLabel = ''
return
}
product.typeProductLabel = formatProductTypeOption(option)
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
}
props.node.customFields.forEach((field: any, index: number) => {
if (!field || typeof field !== 'object') {
return
}
field.orderIndex = index
})
}
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 addCustomField = () => { const addCustomField = () => {
ensureArray('customFields') ensureArray('customFields')
const nextIndex = Array.isArray(props.node.customFields)
? props.node.customFields.length
: 0
props.node.customFields.push({ props.node.customFields.push({
name: '', name: '',
type: 'text', type: 'text',
required: false, required: false,
optionsText: '', optionsText: '',
options: [], options: [],
orderIndex: nextIndex,
}) })
reindexCustomFields()
} }
const removeCustomField = (index: number) => { const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1) props.node.customFields.splice(index, 1)
reindexCustomFields()
} }
const addPiece = () => { const addPiece = () => {
@@ -538,6 +780,20 @@ const removePiece = (index: number) => {
props.node.pieces.splice(index, 1) props.node.pieces.splice(index, 1)
} }
const addProduct = () => {
ensureArray('products')
props.node.products.push({
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
}
const removeProduct = (index: number) => {
if (!Array.isArray(props.node.products)) return
props.node.products.splice(index, 1)
}
const addSubComponent = () => { const addSubComponent = () => {
if (!canManageSubcomponents.value) { if (!canManageSubcomponents.value) {
return return
@@ -560,6 +816,8 @@ const removeSubComponent = (index: number) => {
const draggingPieceIndex = ref<number | null>(null) const draggingPieceIndex = ref<number | null>(null)
const pieceDropTargetIndex = 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 draggingSubcomponentIndex = ref<number | null>(null)
const subcomponentDropTargetIndex = ref<number | null>(null) const subcomponentDropTargetIndex = ref<number | null>(null)
@@ -581,6 +839,11 @@ const resetPieceDragState = () => {
pieceDropTargetIndex.value = null pieceDropTargetIndex.value = null
} }
const resetProductDragState = () => {
draggingProductIndex.value = null
productDropTargetIndex.value = null
}
const onPieceDragStart = (index: number, event: DragEvent) => { const onPieceDragStart = (index: number, event: DragEvent) => {
draggingPieceIndex.value = index draggingPieceIndex.value = index
pieceDropTargetIndex.value = index pieceDropTargetIndex.value = index
@@ -633,6 +896,58 @@ const pieceReorderClass = (index: number) => {
return '' 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 = () => { const resetSubcomponentDragState = () => {
draggingSubcomponentIndex.value = null draggingSubcomponentIndex.value = null
subcomponentDropTargetIndex.value = null subcomponentDropTargetIndex.value = null
@@ -723,6 +1038,34 @@ watch(
{ deep: true } { deep: true }
) )
watch(productTypes, () => {
syncProductLabels(props.node?.products)
}, { deep: true, immediate: true })
watch(
() => props.node.products,
(value) => {
syncProductLabels(value)
},
{ deep: true }
)
watch(
() => props.node.customFields,
(value) => {
if (!Array.isArray(value)) {
return
}
value.sort((a: any, b: any) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
reindexCustomFields()
},
{ deep: true }
)
watch( watch(
() => [props.lockedTypeLabel, props.lockType], () => [props.lockedTypeLabel, props.lockType],
() => { () => {
+116 -13
View File
@@ -24,11 +24,26 @@
<div v-if="expanded" class="space-y-4"> <div v-if="expanded" class="space-y-4">
<div <div
v-for="(field, fieldIndex) in fields" v-for="(field, fieldIndex) in fields"
:key="fieldIndex" :key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
class="border border-gray-200 rounded-lg p-4 bg-gray-50" class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
:class="fieldReorderClass(fieldIndex)"
draggable="true"
@dragstart="onFieldDragStart(fieldIndex, $event)"
@dragenter="onFieldDragEnter(fieldIndex)"
@dragover.prevent
@drop="onFieldDrop(fieldIndex)"
@dragend="onFieldDragEnd"
> >
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
title="Réorganiser"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs p-1" class="btn btn-ghost btn-xs p-1"
@@ -160,6 +175,7 @@ import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideListChecks from '~icons/lucide/list-checks' import IconLucideListChecks from '~icons/lucide/list-checks'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -183,8 +199,57 @@ const fields = computed({
set: value => emit('update:modelValue', value) set: value => emit('update:modelValue', value)
}) })
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const expanded = ref(false) const expanded = ref(false)
const expandedFields = ref([]) const expandedFields = ref([])
const draggingFieldIndex = ref(null)
const fieldDropTargetIndex = ref(null)
const applyOrderIndex = (list = []) => {
if (!Array.isArray(list)) { return [] }
list.forEach((field, index) => {
if (field && typeof field === 'object') {
field.orderIndex = index
if (typeof field.__key !== 'string' || !field.__key) {
field.__key = createFieldKey()
}
}
})
return list
}
const createEmptyField = () => ({
name: '',
type: '',
required: false,
optionsText: '',
orderIndex: fields.value.length,
__key: createFieldKey()
})
const resetDragState = () => {
draggingFieldIndex.value = null
fieldDropTargetIndex.value = null
}
const reorderFields = (from, to) => {
const list = Array.isArray(fields.value) ? fields.value.slice() : []
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
if (Array.isArray(expandedFields.value)) {
const expandedCopy = expandedFields.value.slice()
const [expandedState] = expandedCopy.splice(from, 1)
expandedCopy.splice(to, 0, expandedState)
expandedFields.value = expandedCopy
}
fields.value = applyOrderIndex(list)
resetDragState()
}
watch( watch(
() => props.expandAllTrigger, () => props.expandAllTrigger,
@@ -223,26 +288,25 @@ const toggleField = (index) => {
} }
const addField = () => { const addField = () => {
fields.value = [ const next = Array.isArray(fields.value) ? fields.value.slice() : []
...fields.value, next.push(createEmptyField())
{ fields.value = applyOrderIndex(next)
name: '',
type: '',
required: false,
optionsText: ''
}
]
expandedFields.value.push(true) expandedFields.value.push(true)
expanded.value = true expanded.value = true
} }
const removeField = (index) => { const removeField = (index) => {
fields.value = fields.value.filter((_, i) => i !== index) const next = Array.isArray(fields.value)
? fields.value.filter((_, i) => i !== index)
: []
fields.value = applyOrderIndex(next)
expandedFields.value.splice(index, 1) expandedFields.value.splice(index, 1)
} }
const updateField = (index, patch) => { const updateField = (index, patch) => {
fields.value = fields.value.map((field, i) => (i === index ? { ...field, ...patch } : field)) const next = Array.isArray(fields.value) ? fields.value.slice() : []
next[index] = { ...next[index], ...patch }
fields.value = applyOrderIndex(next)
} }
const updateOptions = (index, value) => { const updateOptions = (index, value) => {
@@ -250,4 +314,43 @@ const updateOptions = (index, value) => {
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}) })
} }
const onFieldDragStart = (index, event) => {
draggingFieldIndex.value = index
fieldDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onFieldDragEnter = (index) => {
if (draggingFieldIndex.value === null) { return }
fieldDropTargetIndex.value = index
}
const onFieldDrop = (index) => {
if (draggingFieldIndex.value === null) {
resetDragState()
return
}
reorderFields(draggingFieldIndex.value, index)
}
const onFieldDragEnd = () => {
resetDragState()
}
const fieldReorderClass = (index) => {
if (draggingFieldIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingFieldIndex.value !== null &&
fieldDropTargetIndex.value === index &&
draggingFieldIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
</script> </script>
+29 -3
View File
@@ -26,6 +26,11 @@
@update:model-value="(value) => (formData.pieceRequirements = value)" @update:model-value="(value) => (formData.pieceRequirements = value)"
/> />
<TypeEditProductRequirementsSection
:model-value="formData.productRequirements"
@update:model-value="(value) => (formData.productRequirements = value)"
/>
<TypeEditActionsBar :saving="saving" @reset="resetForm" /> <TypeEditActionsBar :saving="saving" @reset="resetForm" />
</form> </form>
</template> </template>
@@ -38,6 +43,7 @@ import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSectio
import TypeEditToolbar from '~/components/TypeEditToolbar.vue' import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue' import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue' import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -54,14 +60,34 @@ const emit = defineEmits(['update:modelValue', 'submit'])
const deepClone = value => JSON.parse(JSON.stringify(value)) const deepClone = value => JSON.parse(JSON.stringify(value))
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const withNormalizedOrder = (items = []) => {
if (!Array.isArray(items)) { return [] }
return items
.map((item, index) => {
const clone = deepClone(item)
const currentOrder =
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
clone.orderIndex = currentOrder
if (typeof clone?.__key !== 'string' || !clone.__key) {
clone.__key = createFieldKey()
}
return clone
})
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((item, index) => ({ ...item, orderIndex: index }))
}
const createDefaultForm = (source = {}) => ({ const createDefaultForm = (source = {}) => ({
name: source.name || '', name: source.name || '',
description: source.description || '', description: source.description || '',
category: source.category || '', category: source.category || '',
maintenanceFrequency: source.maintenanceFrequency || '', maintenanceFrequency: source.maintenanceFrequency || '',
customFields: deepClone(source.customFields || []), customFields: withNormalizedOrder(source.customFields || []),
componentRequirements: deepClone(source.componentRequirements || []), componentRequirements: withNormalizedOrder(source.componentRequirements || []),
pieceRequirements: deepClone(source.pieceRequirements || []) pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
productRequirements: withNormalizedOrder(source.productRequirements || []),
}) })
const formData = reactive(createDefaultForm(props.modelValue)) const formData = reactive(createDefaultForm(props.modelValue))
@@ -0,0 +1,95 @@
<template>
<RequirementListEditor
v-model="requirements"
:type-options="productTypes"
type-field="typeProductId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="false"
:min-fallback="0"
:type-loading="loadingProductTypes"
/>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { useProductTypes } from '~/composables/useProductTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typeProductId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as () => Requirement[],
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value: Requirement[]) => emit('update:modelValue', value),
})
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typeProductId: null,
label: '',
minCount: 0,
maxCount: null,
required: false,
allowNewModels: true,
})
const labels: Labels = {
headerTitle: 'Produits requis',
addButton: 'Ajouter un produit',
description:
"Définissez les produits catalogue attendus pour ce type de machine. Sélectionnez la catégorie de produit, précisez les quantités minimales et maximales, puis indiquez si de nouveaux produits peuvent être créés à l'usage.",
emptyState: 'Aucun produit requis configuré pour le moment.',
typeSelectLabel: 'Catégorie de produit',
typePlaceholder: 'Sélectionner une catégorie',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex : Lubrifiant recommandé',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux produits lors de l'instanciation",
}
onMounted(async () => {
if (!productTypes.value.length) {
await loadProductTypes()
}
})
</script>
+1
View File
@@ -9,6 +9,7 @@
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p> <p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p> <p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p> <p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
<p v-if="type.description"> <p v-if="type.description">
<strong>Description:</strong> {{ type.description }} <strong>Description:</strong> {{ type.description }}
</p> </p>
+10 -3
View File
@@ -51,7 +51,7 @@ import {
import { useToast } from "~/composables/useToast"; import { useToast } from "~/composables/useToast";
const DEFAULT_DESCRIPTION = const DEFAULT_DESCRIPTION =
"Gérez les catégories utilisées pour structurer les catalogues de composants et de pièces. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination."; "Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -210,8 +210,15 @@ const onOffsetChange = (value: number) => {
} }
}; };
const resolveCategoryBasePath = (category: ModelCategory) => const resolveCategoryBasePath = (category: ModelCategory) => {
category === "COMPONENT" ? "/component-category" : "/piece-category"; if (category === "COMPONENT") {
return "/component-category";
}
if (category === "PIECE") {
return "/piece-category";
}
return "/product-category";
};
const openCreatePage = () => { const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value); const basePath = resolveCategoryBasePath(selectedCategory.value);
+47 -7
View File
@@ -32,6 +32,7 @@
> >
<option value="COMPONENT">Composants</option> <option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option> <option value="PIECE">Pièces</option>
<option value="PRODUCT">Produits</option>
</select> </select>
</div> </div>
@@ -84,7 +85,7 @@
</div> </div>
<div <div
v-else v-else-if="form.category === 'PIECE'"
class="space-y-3 rounded-lg border border-base-300 p-4" class="space-y-3 rounded-lg border border-base-300 p-4"
> >
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
@@ -93,6 +94,17 @@
</p> </p>
<PieceModelStructureEditor v-model="pieceStructure" /> <PieceModelStructureEditor v-model="pieceStructure" />
</div> </div>
<div
v-else
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="productStructure" />
</div>
</template> </template>
</section> </section>
@@ -114,12 +126,16 @@ import ComponentModelStructureEditor from '~/components/ComponentModelStructureE
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue' import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
import { import {
clonePieceStructure, clonePieceStructure,
cloneProductStructure,
cloneStructure, cloneStructure,
defaultPieceStructure, defaultPieceStructure,
defaultProductStructure,
defaultStructure, defaultStructure,
formatPieceStructurePreview, formatPieceStructurePreview,
formatProductStructurePreview,
formatStructurePreview, formatStructurePreview,
normalizePieceStructureForSave, normalizePieceStructureForSave,
normalizeProductStructureForSave,
normalizeStructureForEditor, normalizeStructureForEditor,
normalizeStructureForSave, normalizeStructureForSave,
} from '~/shared/modelUtils' } from '~/shared/modelUtils'
@@ -171,6 +187,7 @@ const nameInput = ref<HTMLInputElement | null>(null)
const componentStructure = ref(normalizeStructureForEditor(defaultStructure())) const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure())) const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
const productStructure = ref(normalizeProductStructureForSave(defaultProductStructure()))
const generateCodeFromName = (name: string) => { const generateCodeFromName = (name: string) => {
const fallback = 'type' const fallback = 'type'
@@ -196,10 +213,19 @@ const resetStructures = (incomingStructure: ModelTypePayload['structure'], categ
return return
} }
pieceStructure.value = normalizePieceStructureForSave( if (category === 'PIECE') {
incomingStructure && props.initialData?.category === 'PIECE' pieceStructure.value = normalizePieceStructureForSave(
? incomingStructure incomingStructure && props.initialData?.category === 'PIECE'
: defaultPieceStructure(), ? incomingStructure
: defaultPieceStructure(),
)
return
}
productStructure.value = normalizeProductStructureForSave(
incomingStructure && props.initialData?.category === 'PRODUCT'
? cloneProductStructure(incomingStructure)
: defaultProductStructure(),
) )
} }
@@ -263,10 +289,19 @@ const handleSubmit = () => {
return return
} }
if (form.category === 'PIECE') {
emit('submit', {
...common,
category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
})
return
}
emit('submit', { emit('submit', {
...common, ...common,
category: 'PIECE', category: 'PRODUCT',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)), structure: normalizeProductStructureForSave(cloneProductStructure(productStructure.value)),
}) })
} }
@@ -311,11 +346,16 @@ watch(
if (category === 'PIECE') { if (category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure()) pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
} }
if (category === 'PRODUCT') {
productStructure.value = normalizeProductStructureForSave(defaultProductStructure())
}
}, },
) )
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value)) const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value)) const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
const productStructurePreview = computed(() => formatProductStructurePreview(productStructure.value))
onMounted(() => { onMounted(() => {
nextTick(() => nameInput.value?.focus()) nextTick(() => nameInput.value?.focus())
+1 -2
View File
@@ -33,7 +33,6 @@
<thead> <thead>
<tr class="text-base-content/70"> <tr class="text-base-content/70">
<th scope="col">Nom</th> <th scope="col">Nom</th>
<th scope="col">Catégorie</th>
<th scope="col">Notes</th> <th scope="col">Notes</th>
<th scope="col" class="w-32 text-right">Actions</th> <th scope="col" class="w-32 text-right">Actions</th>
</tr> </tr>
@@ -41,7 +40,6 @@
<tbody> <tbody>
<tr v-for="item in items" :key="item.id"> <tr v-for="item in items" :key="item.id">
<td class="font-medium">{{ item.name }}</td> <td class="font-medium">{{ item.name }}</td>
<td>{{ categoryLabel(item.category) }}</td>
<td class="max-w-xs align-middle"> <td class="max-w-xs align-middle">
<span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span> <span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span>
<span v-else class="text-base-content/50"></span> <span v-else class="text-base-content/50"></span>
@@ -133,6 +131,7 @@ const emit = defineEmits<{
const categoryDictionary: Record<ModelCategory, string> = { const categoryDictionary: Record<ModelCategory, string> = {
COMPONENT: 'Composants', COMPONENT: 'Composants',
PIECE: 'Pièces', PIECE: 'Pièces',
PRODUCT: 'Produits',
}; };
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category; const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
+5 -3
View File
@@ -78,12 +78,13 @@
import { computed } from 'vue'; import { computed } from 'vue';
import IconLucidePlus from '~icons/lucide/plus'; import IconLucidePlus from '~icons/lucide/plus';
import IconLucideSearch from '~icons/lucide/search'; import IconLucideSearch from '~icons/lucide/search';
import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt'; type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
category: 'COMPONENT' | 'PIECE'; category: ModelCategory;
search: string; search: string;
sort: SortField; sort: SortField;
dir: SortDirection; dir: SortDirection;
@@ -92,16 +93,17 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:category', value: 'COMPONENT' | 'PIECE'): void; (e: 'update:category', value: ModelCategory): void;
(e: 'update:search', value: string): void; (e: 'update:search', value: string): void;
(e: 'update:sort', value: SortField): void; (e: 'update:sort', value: SortField): void;
(e: 'update:dir', value: SortDirection): void; (e: 'update:dir', value: SortDirection): void;
(e: 'create'): void; (e: 'create'): void;
}>(); }>();
const categories: Array<{ label: string; value: 'COMPONENT' | 'PIECE' }> = [ const categories: Array<{ label: string; value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' }, { label: 'Composants', value: 'COMPONENT' },
{ label: 'Pièces', value: 'PIECE' }, { label: 'Pièces', value: 'PIECE' },
{ label: 'Produits', value: 'PRODUCT' },
]; ];
const onSearch = (event: Event) => { const onSearch = (event: Event) => {
+7 -1
View File
@@ -18,7 +18,7 @@
<div class="flex items-center gap-2 text-gray-600"> <div class="flex items-center gap-2 text-gray-600">
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" /> <IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
<span>{{ site.contactPhone }}</span> <span>{{ formattedContactPhone }}</span>
</div> </div>
<div class="flex items-start gap-2 text-gray-600"> <div class="flex items-start gap-2 text-gray-600">
@@ -53,6 +53,7 @@ import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin' import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucidePhone from '~icons/lucide/phone' import IconLucidePhone from '~icons/lucide/phone'
import IconLucideUser from '~icons/lucide/user' import IconLucideUser from '~icons/lucide/user'
import { formatPhone } from '~/utils/formatters/phone'
const props = defineProps({ const props = defineProps({
site: { site: {
@@ -64,4 +65,9 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']) const emit = defineEmits(['edit', 'delete'])
const machineCount = computed(() => props.site?.machines?.length || 0) const machineCount = computed(() => props.site?.machines?.length || 0)
const formattedContactPhone = computed(() => {
const value = props.site?.contactPhone ?? ''
const formatted = formatPhone(value)
return formatted || value || '—'
})
</script> </script>
+3 -2
View File
@@ -1,6 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
const composants = ref([]) const composants = ref([])
const loading = ref(false) const loading = ref(false)
@@ -27,7 +28,7 @@ const loadComposants = async () => {
const createComposant = async (composantData) => { const createComposant = async (composantData) => {
loading.value = true loading.value = true
try { try {
const result = await post('/composants', composantData) const result = await post('/composants', buildConstructeurRequestPayload(composantData))
if (result.success) { if (result.success) {
composants.value.push(result.data) composants.value.push(result.data)
const displayName = result.data?.name const displayName = result.data?.name
@@ -48,7 +49,7 @@ const loadComposants = async () => {
const updateComposantData = async (id, composantData) => { const updateComposantData = async (id, composantData) => {
loading.value = true loading.value = true
try { try {
const result = await patch(`/composants/${id}`, composantData) const result = await patch(`/composants/${id}`, buildConstructeurRequestPayload(composantData))
if (result.success) { if (result.success) {
const updated = result.data const updated = result.data
const index = composants.value.findIndex(comp => comp.id === id) const index = composants.value.findIndex(comp => comp.id === id)
+10 -10
View File
@@ -19,7 +19,7 @@ export function useConstructeurs () {
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des constructeurs:', error) console.error('Erreur lors du chargement des fournisseurs:', error)
return { success: false, error: error.message } return { success: false, error: error.message }
} finally { } finally {
loading.value = false loading.value = false
@@ -36,14 +36,14 @@ export function useConstructeurs () {
const result = await post('/constructeurs', data) const result = await post('/constructeurs', data)
if (result.success) { if (result.success) {
constructeurs.value = [result.data, ...constructeurs.value] constructeurs.value = [result.data, ...constructeurs.value]
showSuccess(`Constructeur "${result.data.name}" créé`) showSuccess(`Fournisseur "${result.data.name}" créé`)
} else if (result.error) { } else if (result.error) {
showError(result.error) showError(result.error)
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la création du constructeur:', error) console.error('Erreur lors de la création du fournisseur:', error)
showError('Impossible de créer le constructeur') showError('Impossible de créer le fournisseur')
return { success: false, error: error.message } return { success: false, error: error.message }
} finally { } finally {
loading.value = false loading.value = false
@@ -59,14 +59,14 @@ export function useConstructeurs () {
if (index !== -1) { if (index !== -1) {
constructeurs.value[index] = result.data constructeurs.value[index] = result.data
} }
showSuccess(`Constructeur "${result.data.name}" mis à jour`) showSuccess(`Fournisseur "${result.data.name}" mis à jour`)
} else if (result.error) { } else if (result.error) {
showError(result.error) showError(result.error)
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour du constructeur:', error) console.error('Erreur lors de la mise à jour du fournisseur:', error)
showError('Impossible de mettre à jour le constructeur') showError('Impossible de mettre à jour le fournisseur')
return { success: false, error: error.message } return { success: false, error: error.message }
} finally { } finally {
loading.value = false loading.value = false
@@ -79,14 +79,14 @@ export function useConstructeurs () {
const result = await del(`/constructeurs/${id}`) const result = await del(`/constructeurs/${id}`)
if (result.success) { if (result.success) {
constructeurs.value = constructeurs.value.filter(item => item.id !== id) constructeurs.value = constructeurs.value.filter(item => item.id !== id)
showSuccess('Constructeur supprimé') showSuccess('Fournisseur supprimé')
} else if (result.error) { } else if (result.error) {
showError(result.error) showError(result.error)
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression du constructeur:', error) console.error('Erreur lors de la suppression du fournisseur:', error)
showError('Impossible de supprimer le constructeur') showError('Impossible de supprimer le fournisseur')
return { success: false, error: error.message } return { success: false, error: error.message }
} finally { } finally {
loading.value = false loading.value = false
+6
View File
@@ -60,6 +60,11 @@ export function useDocuments () {
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
} }
const loadDocumentsByProduct = async (productId, options = {}) => {
if (!productId) { return { success: false, error: 'Aucun produit sélectionné' } }
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
}
const loadDocumentsByPiece = async (pieceId, options = {}) => { const loadDocumentsByPiece = async (pieceId, options = {}) => {
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } } if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
@@ -140,6 +145,7 @@ export function useDocuments () {
loadDocumentsByMachine, loadDocumentsByMachine,
loadDocumentsByComponent, loadDocumentsByComponent,
loadDocumentsByPiece, loadDocumentsByPiece,
loadDocumentsByProduct,
uploadDocuments, uploadDocuments,
deleteDocument deleteDocument
} }
+21 -4
View File
@@ -5,6 +5,20 @@ import { useApi } from './useApi'
const machineTypes = ref([]) const machineTypes = ref([])
const loading = ref(false) const loading = ref(false)
const normalizeRequirementList = (value) => (Array.isArray(value) ? value : [])
const normalizeMachineType = (type) => {
if (!type || typeof type !== 'object') {
return type
}
return {
...type,
componentRequirements: normalizeRequirementList(type.componentRequirements),
pieceRequirements: normalizeRequirementList(type.pieceRequirements),
productRequirements: normalizeRequirementList(type.productRequirements),
}
}
export function useMachineTypesApi () { export function useMachineTypesApi () {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
@@ -14,7 +28,9 @@ export function useMachineTypesApi () {
try { try {
const result = await get('/types/machines') const result = await get('/types/machines')
if (result.success) { if (result.success) {
machineTypes.value = result.data machineTypes.value = Array.isArray(result.data)
? result.data.map(normalizeMachineType)
: []
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`) showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
} }
} catch (error) { } catch (error) {
@@ -29,7 +45,7 @@ export function useMachineTypesApi () {
try { try {
const result = await post('/types/machines', typeData) const result = await post('/types/machines', typeData)
if (result.success) { if (result.success) {
machineTypes.value.push(result.data) machineTypes.value.push(normalizeMachineType(result.data))
showSuccess(`Type de machine "${typeData.name}" créé avec succès`) showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
} }
return result return result
@@ -46,9 +62,10 @@ export function useMachineTypesApi () {
try { try {
const result = await patch(`/types/machines/${id}`, typeData) const result = await patch(`/types/machines/${id}`, typeData)
if (result.success) { if (result.success) {
const normalized = normalizeMachineType(result.data)
const index = machineTypes.value.findIndex(type => type.id === id) const index = machineTypes.value.findIndex(type => type.id === id)
if (index !== -1) { if (index !== -1) {
machineTypes.value[index] = result.data machineTypes.value[index] = normalized
} }
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`) showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
} }
@@ -91,7 +108,7 @@ export function useMachineTypesApi () {
const result = await get(`/types/machines/${id}`) const result = await get(`/types/machines/${id}`)
if (result.success) { if (result.success) {
// Ajouter au cache local // Ajouter au cache local
machineTypes.value.push(result.data) machineTypes.value.push(normalizeMachineType(result.data))
} }
return result return result
} catch (error) { } catch (error) {
+4 -3
View File
@@ -1,6 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
const machines = ref([]) const machines = ref([])
const loading = ref(false) const loading = ref(false)
@@ -76,7 +77,7 @@ export function useMachines () {
const createMachine = async (machineData) => { const createMachine = async (machineData) => {
loading.value = true loading.value = true
try { try {
const result = await post('/machines', machineData) const result = await post('/machines', buildConstructeurRequestPayload(machineData))
if (result.success) { if (result.success) {
const createdMachine = normalizeMachineResponse(result.data) || const createdMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) || normalizeMachineResponse(result.data?.machine) ||
@@ -105,13 +106,13 @@ export function useMachines () {
// Les composants et pièces seront créés automatiquement // Les composants et pièces seront créés automatiquement
} }
return await createMachine(machineWithStructure) return await createMachine(buildConstructeurRequestPayload(machineWithStructure))
} }
const updateMachineData = async (id, machineData) => { const updateMachineData = async (id, machineData) => {
loading.value = true loading.value = true
try { try {
const result = await patch(`/machines/${id}`, machineData) const result = await patch(`/machines/${id}`, buildConstructeurRequestPayload(machineData))
if (result.success) { if (result.success) {
const updatedMachine = normalizeMachineResponse(result.data) || const updatedMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) || normalizeMachineResponse(result.data?.machine) ||
+3 -2
View File
@@ -1,6 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
const pieces = ref([]) const pieces = ref([])
const loading = ref(false) const loading = ref(false)
@@ -27,7 +28,7 @@ export function usePieces () {
const createPiece = async (pieceData) => { const createPiece = async (pieceData) => {
loading.value = true loading.value = true
try { try {
const result = await post('/pieces', pieceData) const result = await post('/pieces', buildConstructeurRequestPayload(pieceData))
if (result.success) { if (result.success) {
pieces.value.push(result.data) pieces.value.push(result.data)
const displayName = result.data?.name const displayName = result.data?.name
@@ -48,7 +49,7 @@ export function usePieces () {
const updatePieceData = async (id, pieceData) => { const updatePieceData = async (id, pieceData) => {
loading.value = true loading.value = true
try { try {
const result = await patch(`/pieces/${id}`, pieceData) const result = await patch(`/pieces/${id}`, buildConstructeurRequestPayload(pieceData))
if (result.success) { if (result.success) {
const updated = result.data const updated = result.data
const index = pieces.value.findIndex(piece => piece.id === id) const index = pieces.value.findIndex(piece => piece.id === id)
+132
View File
@@ -0,0 +1,132 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
const productTypes = ref([])
const loadingProductTypes = ref(false)
export function useProductTypes () {
const { showSuccess, showError } = useToast()
const generateCodeFromName = (name) => {
return (name || '')
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
const loadProductTypes = async () => {
loadingProductTypes.value = true
try {
const data = await listModelTypes({
category: 'PRODUCT',
sort: 'name',
dir: 'asc',
limit: 200,
})
productTypes.value = data.items.map(item => ({
...item,
description: item.description ?? item.notes ?? null,
}))
return { success: true, data: productTypes.value }
} catch (error) {
const message = error?.message || 'Erreur inconnue'
showError(`Impossible de charger les types de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const createProductType = async (payload) => {
loadingProductTypes.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category: 'PRODUCT',
notes: payload.description ?? payload.notes,
description: payload.description ?? null,
structure: payload.structure,
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null,
}
productTypes.value.push(normalized)
showSuccess(`Type de produit "${data.name}" créé`)
return { success: true, data: normalized }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la création du type de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const updateProductType = async (id, payload) => {
loadingProductTypes.value = true
try {
const data = await updateModelType(id, {
name: payload.name,
description: payload.description,
notes: payload.notes,
code: payload.code,
structure: payload.structure,
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null,
}
const index = productTypes.value.findIndex(type => type.id === id)
if (index !== -1) {
productTypes.value[index] = normalized
}
showSuccess(`Type de produit "${data.name}" mis à jour`)
return { success: true, data: normalized }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la mise à jour du type de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const deleteProductType = async (id) => {
loadingProductTypes.value = true
try {
await deleteModelType(id)
productTypes.value = productTypes.value.filter(type => type.id !== id)
showSuccess('Type de produit supprimé')
return { success: true }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la suppression du type de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
return {
productTypes,
loadingProductTypes,
loadProductTypes,
createProductType,
updateProductType,
deleteProductType,
}
}
+184
View File
@@ -0,0 +1,184 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
const products = ref([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const error = ref(null)
const replaceInCache = (item) => {
if (!item?.id) {
return false
}
const index = products.value.findIndex((product) => product.id === item.id)
if (index === -1) {
products.value.unshift(item)
return true
}
const clone = products.value.slice()
clone[index] = item
products.value = clone
return false
}
export function useProducts () {
const { showError } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadProducts = async (options = {}) => {
if (loading.value) {
return {
success: true,
data: { items: products.value, total: total.value },
}
}
if (loaded.value && !options.force) {
return {
success: true,
data: { items: products.value, total: total.value },
}
}
loading.value = true
error.value = null
try {
const result = await get('/products?limit=100')
if (result.success) {
const items = Array.isArray(result.data?.items) ? result.data.items : []
products.value = items
total.value = typeof result.data?.total === 'number' ? result.data.total : items.length
loaded.value = true
} else if (result.error) {
error.value = result.error
showError(`Impossible de charger les produits: ${result.error}`)
}
return result
} catch (err) {
console.error('Erreur lors du chargement des produits:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(`Impossible de charger les produits: ${message}`)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const createProduct = async (payload) => {
loading.value = true
error.value = null
try {
const result = await post('/products', payload)
if (result.success && result.data) {
const added = replaceInCache(result.data)
if (added) {
total.value += 1
}
} else if (result.error) {
error.value = result.error
showError(result.error)
}
return result
} catch (err) {
console.error('Erreur lors de la création du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(message)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const updateProduct = async (id, payload) => {
loading.value = true
error.value = null
try {
const result = await patch(`/products/${id}`, payload)
if (result.success && result.data) {
replaceInCache(result.data)
} else if (result.error) {
error.value = result.error
showError(result.error)
}
return result
} catch (err) {
console.error('Erreur lors de la mise à jour du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(message)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const deleteProduct = async (id) => {
loading.value = true
error.value = null
try {
const result = await del(`/products/${id}`)
if (result.success) {
const removed = products.value.find((product) => product.id === id)
products.value = products.value.filter((product) => product.id !== id)
total.value = Math.max(0, total.value - 1)
} else if (result.error) {
error.value = result.error
showError(result.error)
}
return result
} catch (err) {
console.error('Erreur lors de la suppression du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(message)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const getProduct = async (id, options = {}) => {
if (!options.force) {
const cached = products.value.find((product) => product.id === id)
if (cached) {
return { success: true, data: cached }
}
}
try {
const result = await get(`/products/${id}`)
if (result.success && result.data) {
replaceInCache(result.data)
}
return result
} catch (err) {
console.error('Erreur lors du chargement du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
return { success: false, error: message }
}
}
const clearProductsCache = () => {
products.value = []
total.value = 0
loaded.value = false
error.value = null
}
return {
products,
total,
loading,
loaded,
error,
loadProducts,
createProduct,
updateProduct,
deleteProduct,
getProduct,
clearProductsCache,
}
}
+15 -6
View File
@@ -34,7 +34,7 @@
v-model="searchTerm" v-model="searchTerm"
type="text" type="text"
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, référence ou catégorie…" placeholder="Nom ou référence…"
/> />
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -93,8 +93,8 @@
<tr> <tr>
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Catégorie</th>
<th>Référence</th> <th>Référence</th>
<th>Type de composant</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -107,8 +107,8 @@
/> />
</td> </td>
<td>{{ component.name || 'Composant sans nom' }}</td> <td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.typeComposant?.name || '—' }}</td>
<td>{{ component.reference || '—' }}</td> <td>{{ component.reference || '—' }}</td>
<td>{{ resolveComponentType(component) }}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -180,6 +180,17 @@ const resolvePreviewAlt = (component: Record<string, any>) => {
return 'Aperçu du document' return 'Aperçu du document'
} }
const resolveComponentType = (component: Record<string, any>) => {
const type = component?.typeComposant
if (type?.name) {
return type.name
}
if (component?.typeComposantLabel) {
return component.typeComposantLabel
}
return '—'
}
const resolveDeleteGuard = (component: Record<string, any>) => { const resolveDeleteGuard = (component: Record<string, any>) => {
const blockingReasons: string[] = [] const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks) const machineLinks = Array.isArray(component?.machineLinks)
@@ -232,11 +243,9 @@ const visibleComposants = computed(() => {
? source.filter((component) => { ? source.filter((component) => {
const name = (component?.name || '').toLowerCase() const name = (component?.name || '').toLowerCase()
const reference = (component?.reference || '').toLowerCase() const reference = (component?.reference || '').toLowerCase()
const category = (component?.typeComposant?.name || '').toLowerCase()
return ( return (
name.includes(term) || name.includes(term) ||
reference.includes(term) || reference.includes(term)
category.includes(term)
) )
}) })
: [...source] : [...source]
+26 -12
View File
@@ -89,19 +89,19 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="saving"
placeholder="Référence interne ou constructeur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Constructeur</span> <span class="label-text">Fournisseur</span>
</label> </label>
<ConstructeurSelect <ConstructeurSelect
v-model="editionForm.constructeurId" v-model="editionForm.constructeurIds"
class="w-full" class="w-full"
:disabled="saving" :disabled="saving"
placeholder="Rechercher un constructeur..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
</div> </div>
@@ -404,6 +404,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 { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
@@ -423,6 +424,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const route = useRoute() const route = useRoute()
@@ -443,12 +445,11 @@ const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([]) 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 selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
reference: '' as string, reference: '' as string,
constructeurId: null as string | null, constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
}) })
@@ -651,7 +652,11 @@ watch(
editionForm.name = currentComponent.name || '' editionForm.name = currentComponent.name || ''
editionForm.reference = currentComponent.reference || '' editionForm.reference = currentComponent.reference || ''
editionForm.constructeurId = currentComponent.constructeur?.id || currentComponent.constructeurId || null editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent,
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
currentComponent.constructeur ? [currentComponent.constructeur] : [],
)
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : '' editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
customFieldInputs.value = buildCustomFieldInputs( customFieldInputs.value = buildCustomFieldInputs(
@@ -691,7 +696,7 @@ const submitEdition = async () => {
const reference = editionForm.reference.trim() const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null payload.reference = reference ? reference : null
payload.constructeurId = editionForm.constructeurId || null payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
if (rawPrice) { if (rawPrice) {
const parsed = Number(rawPrice) const parsed = Number(rawPrice)
@@ -751,6 +756,7 @@ const buildCustomFieldInputs = (
...definition, ...definition,
customFieldId: definition.customFieldId || definition.id, customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null, customFieldValueId: null,
orderIndex: definition.orderIndex,
} }
} }
@@ -760,8 +766,14 @@ const buildCustomFieldInputs = (
customFieldId: matched.customField?.id || definition.customFieldId || definition.id, customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null, customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue), value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
} }
}) }).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
return resolved return resolved
} }
@@ -775,11 +787,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -797,7 +810,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
const customFieldValueId = typeof rawField.customFieldValueId === 'string' const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {
+128 -16
View File
@@ -62,19 +62,19 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="submitting || !selectedType"
placeholder="Référence interne ou constructeur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Constructeur</span> <span class="label-text">Fournisseur</span>
</label> </label>
<ConstructeurSelect <ConstructeurSelect
v-model="creationForm.constructeurId" v-model="creationForm.constructeurIds"
class="w-full" class="w-full"
:disabled="submitting || !selectedType" :disabled="submitting || !selectedType"
placeholder="Rechercher un constructeur..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
</div> </div>
@@ -148,6 +148,18 @@
</ul> </ul>
</div> </div>
<div v-if="getStructureProducts(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(product, index) in getStructureProducts(selectedTypeStructure)"
:key="product.role || product.typeProductId || product.familyCode || index"
>
{{ resolveProductLabel(product) }}
</li>
</ul>
</div>
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2"> <div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3> <h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
@@ -189,14 +201,16 @@
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70" class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
> >
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> <span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces et de composants Chargement du catalogue de pièces, produits et composants
</div> </div>
<ComponentStructureAssignmentNode <ComponentStructureAssignmentNode
v-else-if="structureAssignments" v-else-if="structureAssignments"
:assignment="structureAssignments" :assignment="structureAssignments"
:pieces="availablePieces" :pieces="availablePieces"
:products="availableProducts"
:components="availableComponents" :components="availableComponents"
:pieces-loading="piecesLoading" :pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading" :components-loading="componentsLoading"
/> />
<p v-else class="text-xs text-error"> <p v-else class="text-xs text-error">
@@ -335,12 +349,15 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { import type {
ComponentModelPiece, ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructure, ComponentModelStructure,
ComponentModelStructureNode, ComponentModelStructureNode,
} from '~/shared/types/inventory' } from '~/shared/types/inventory'
@@ -366,6 +383,11 @@ const {
loadPieces, loadPieces,
loading: piecesLoading, loading: piecesLoading,
} = usePieces() } = usePieces()
const {
products: productCatalogRef,
loadProducts,
loading: productsLoading,
} = useProducts()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
@@ -376,7 +398,7 @@ const submitting = ref(false)
const creationForm = reactive({ const creationForm = reactive({
name: '' as string, name: '' as string,
reference: '' as string, reference: '' as string,
constructeurId: null as string | null, constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
}) })
const lastSuggestedName = ref('') const lastSuggestedName = ref('')
@@ -386,9 +408,10 @@ const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false) const uploadingDocuments = ref(false)
const availablePieces = computed(() => pieceCatalogRef.value ?? []) const availablePieces = computed(() => pieceCatalogRef.value ?? [])
const availableProducts = computed(() => productCatalogRef.value ?? [])
const availableComponents = computed(() => componentCatalogRef.value ?? []) const availableComponents = computed(() => componentCatalogRef.value ?? [])
const structureDataLoading = computed( const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value, () => piecesLoading.value || componentsLoading.value || productsLoading.value,
) )
watch( watch(
@@ -485,6 +508,21 @@ const extractPiecesFromNode = (
) )
} }
const extractProductsFromNode = (
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
): ComponentModelProduct[] => {
if (!definition || typeof definition !== 'object') {
return []
}
const raw = Array.isArray((definition as any).products)
? (definition as any).products
: []
return raw.filter(
(item: unknown): item is ComponentModelProduct =>
!!item && typeof item === 'object',
)
}
const buildAssignmentNode = ( const buildAssignmentNode = (
definition: ComponentModelStructureNode | ComponentModelStructure, definition: ComponentModelStructureNode | ComponentModelStructure,
path: string, path: string,
@@ -495,6 +533,12 @@ const buildAssignmentNode = (
selectedPieceId: '', selectedPieceId: '',
})) }))
const products = extractProductsFromNode(definition).map((product, index) => ({
path: `${path}:product-${index}`,
definition: product,
selectedProductId: '',
}))
const subcomponents = extractSubcomponents(definition).map( const subcomponents = extractSubcomponents(definition).map(
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`), (child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
) )
@@ -504,6 +548,7 @@ const buildAssignmentNode = (
definition, definition,
selectedComponentId: '', selectedComponentId: '',
pieces, pieces,
products,
subcomponents, subcomponents,
} }
} }
@@ -521,7 +566,7 @@ const hasAssignments = (node: StructureAssignmentNode | null): boolean => {
if (!node) { if (!node) {
return false return false
} }
if (node.pieces.length > 0 || node.subcomponents.length > 0) { if (node.pieces.length > 0 || node.products.length > 0 || node.subcomponents.length > 0) {
return true return true
} }
return node.subcomponents.some((child) => hasAssignments(child)) return node.subcomponents.some((child) => hasAssignments(child))
@@ -538,13 +583,21 @@ const isAssignmentNodeComplete = (
const piecesComplete = node.pieces.every( const piecesComplete = node.pieces.every(
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0, (piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
) )
const productsComplete = node.products.every(
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
)
const subcomponentsComplete = node.subcomponents.every( const subcomponentsComplete = node.subcomponents.every(
(child) => (child) =>
!!child.selectedComponentId && !!child.selectedComponentId &&
child.selectedComponentId.length > 0 && child.selectedComponentId.length > 0 &&
isAssignmentNodeComplete(child, false), isAssignmentNodeComplete(child, false),
) )
return piecesComplete && subcomponentsComplete && (isRootNode || !!node.selectedComponentId) return (
piecesComplete &&
productsComplete &&
subcomponentsComplete &&
(isRootNode || !!node.selectedComponentId)
)
} }
const structureSelectionsComplete = computed(() => { const structureSelectionsComplete = computed(() => {
@@ -587,6 +640,15 @@ const sanitizePieceDefinition = (definition: ComponentModelPiece) =>
familyCode: (definition as any).familyCode ?? null, familyCode: (definition as any).familyCode ?? null,
}) })
const sanitizeProductDefinition = (definition: ComponentModelProduct) =>
stripNullish({
role: (definition as any).role ?? null,
typeProductId: definition.typeProductId ?? null,
typeProductLabel: (definition as any).typeProductLabel ?? null,
reference: (definition as any).reference ?? null,
familyCode: (definition as any).familyCode ?? null,
})
const serializeStructureAssignments = ( const serializeStructureAssignments = (
root: StructureAssignmentNode | null, root: StructureAssignmentNode | null,
) => { ) => {
@@ -608,6 +670,16 @@ const serializeStructureAssignments = (
}), }),
) )
const serializedProducts = assignment.products
.filter((product) => !!product.selectedProductId)
.map((product) =>
stripNullish({
path: product.path,
definition: sanitizeProductDefinition(product.definition),
selectedProductId: product.selectedProductId,
}),
)
const serializedSubcomponents = assignment.subcomponents const serializedSubcomponents = assignment.subcomponents
.map((child) => serializeNode(child, false)) .map((child) => serializeNode(child, false))
.filter((child) => Object.keys(child).length > 0) .filter((child) => Object.keys(child).length > 0)
@@ -623,6 +695,9 @@ const serializeStructureAssignments = (
if (serializedPieces.length) { if (serializedPieces.length) {
base.pieces = serializedPieces base.pieces = serializedPieces
} }
if (serializedProducts.length) {
base.products = serializedProducts
}
if (serializedSubcomponents.length) { if (serializedSubcomponents.length) {
base.subcomponents = serializedSubcomponents base.subcomponents = serializedSubcomponents
} }
@@ -633,6 +708,7 @@ const serializeStructureAssignments = (
const serializedRoot = serializeNode(root, true) const serializedRoot = serializeNode(root, true)
if ( if (
(!serializedRoot.pieces || serializedRoot.pieces.length === 0) && (!serializedRoot.pieces || serializedRoot.pieces.length === 0) &&
(!serializedRoot.products || serializedRoot.products.length === 0) &&
(!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0) (!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
) { ) {
return null return null
@@ -681,6 +757,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.pieces) ? structure.pieces : [] return Array.isArray(structure?.pieces) ? structure.pieces : []
} }
const getStructureProducts = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.products) ? structure.products : []
}
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => { const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
if (Array.isArray(structure?.subcomponents)) { if (Array.isArray(structure?.subcomponents)) {
return structure.subcomponents return structure.subcomponents
@@ -708,6 +788,25 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
return parts.length ? parts.join(' • ') : 'Pièce' return parts.length ? parts.join(' • ') : 'Pièce'
} }
const resolveProductLabel = (product: Record<string, any>) => {
const parts: string[] = []
if (product.role) {
parts.push(product.role)
}
if (product.typeProduct?.name) {
parts.push(product.typeProduct.name)
} else if (product.typeProductLabel) {
parts.push(product.typeProductLabel)
} else if (product.typeProduct?.code) {
parts.push(`Catégorie ${product.typeProduct.code}`)
} else if (product.familyCode) {
parts.push(`Catégorie ${product.familyCode}`)
} else if (product.typeProductId) {
parts.push(`#${product.typeProductId}`)
}
return parts.length ? parts.join(' • ') : 'Produit'
}
const resolveSubcomponentLabel = (node: Record<string, any>) => { const resolveSubcomponentLabel = (node: Record<string, any>) => {
const parts: string[] = [] const parts: string[] = []
if (node.alias) { if (node.alias) {
@@ -737,7 +836,7 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurId = null creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
lastSuggestedName.value = '' lastSuggestedName.value = ''
structureAssignments.value = null structureAssignments.value = null
@@ -758,8 +857,8 @@ const submitCreation = async () => {
payload.reference = reference payload.reference = reference
} }
if (creationForm.constructeurId) { if (creationForm.constructeurIds.length) {
payload.constructeurId = creationForm.constructeurId payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
} }
const rawPrice = typeof creationForm.prix === 'string' const rawPrice = typeof creationForm.prix === 'string'
@@ -775,8 +874,17 @@ const submitCreation = async () => {
} }
} }
const rootProductSelection =
structureAssignments.value?.products?.find(
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
) ?? null
if (rootProductSelection?.selectedProductId) {
payload.productId = rootProductSelection.selectedProductId.trim()
}
if (structureHasRequirements.value && !structureSelectionsComplete.value) { if (structureHasRequirements.value && !structureSelectionsComplete.value) {
toast.showError('Complétez la sélection des pièces et sous-composants.') toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
return return
} }
@@ -828,6 +936,7 @@ onMounted(async () => {
loadComponentTypes(), loadComponentTypes(),
loadPieces(), loadPieces(),
loadComposants(), loadComposants(),
loadProducts(),
]) ])
}) })
@@ -840,6 +949,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const fieldKey = (field: CustomFieldInput, index: number) => const fieldKey = (field: CustomFieldInput, index: number) =>
@@ -851,11 +961,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -873,7 +984,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
const customFieldValueId = typeof rawField.customFieldValueId === 'string' const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {
+17 -8
View File
@@ -3,15 +3,15 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-3xl font-bold"> <h1 class="text-3xl font-bold">
Constructeurs Fournisseurs
</h1> </h1>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
Gérez les constructeurs et leurs coordonnées. Gérez les fournisseurs et leurs coordonnées.
</p> </p>
</div> </div>
<button class="btn btn-primary" @click="openCreateModal"> <button class="btn btn-primary" @click="openCreateModal">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" /> <IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Nouveau constructeur Nouveau fournisseur
</button> </button>
</div> </div>
@@ -46,11 +46,11 @@
<div v-if="loading" class="py-16 text-center text-sm text-gray-500"> <div v-if="loading" class="py-16 text-center text-sm text-gray-500">
<span class="loading loading-spinner loading-lg mb-2" /> <span class="loading loading-spinner loading-lg mb-2" />
Chargement des constructeurs... Chargement des fournisseurs...
</div> </div>
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500"> <div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500">
Aucun constructeur trouvé. Aucun fournisseur trouvé.
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-x-auto">
@@ -69,7 +69,7 @@
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm"> <tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
<td>{{ constructeur.name }}</td> <td>{{ constructeur.name }}</td>
<td>{{ constructeur.email || '—' }}</td> <td>{{ constructeur.email || '—' }}</td>
<td>{{ constructeur.phone || '—' }}</td> <td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
<td class="text-right"> <td class="text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)"> <button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
@@ -90,7 +90,7 @@
<dialog class="modal" :class="{ 'modal-open': modalOpen }"> <dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg mb-4"> <h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} constructeur {{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
</h3> </h3>
<form class="space-y-4" @submit.prevent="saveConstructeur"> <form class="space-y-4" @submit.prevent="saveConstructeur">
<div class="form-control"> <div class="form-control">
@@ -122,6 +122,7 @@ import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue' import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { formatPhone } from '~/utils/formatters/phone'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs() const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
@@ -150,6 +151,14 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value) await searchConstructeurs(searchTerm.value)
}, 300) }, 300)
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
function debounce (fn, delay) { function debounce (fn, delay) {
let timeout let timeout
return (...args) => { return (...args) => {
@@ -202,7 +211,7 @@ const saveConstructeur = async () => {
} }
const confirmDelete = async (constructeur) => { const confirmDelete = async (constructeur) => {
if (!confirm(`Supprimer le constructeur "${constructeur.name}" ?`)) { return } if (!confirm(`Supprimer le fournisseur "${constructeur.name}" ?`)) { return }
const result = await deleteConstructeur(constructeur.id) const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) { if (!result.success && result.error) {
showError(result.error) showError(result.error)
+16 -1
View File
@@ -151,7 +151,7 @@
class="w-4 h-4 text-secondary" class="w-4 h-4 text-secondary"
aria-hidden="true" aria-hidden="true"
/> />
<span>{{ site.contactPhone }}</span> <span>{{ formatPhoneDisplay(site.contactPhone) }}</span>
</div> </div>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<IconLucideMapPinned <IconLucideMapPinned
@@ -423,6 +423,12 @@
selectedMachineType.pieceRequirements?.length || 0 selectedMachineType.pieceRequirements?.length || 0
}}</span> }}</span>
</div> </div>
<div class="flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{
selectedMachineType.productRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium">Catégorie :</span> <span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{ <span class="badge badge-outline badge-sm">{{
@@ -465,6 +471,7 @@ import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down' import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideSettings2 from '~icons/lucide/settings-2' import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag' import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone'
const { sites, loading, loadSites, createSite } = useSites() const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi() const { machineTypes, loadMachineTypes } = useMachineTypesApi()
@@ -516,6 +523,14 @@ const totalMachines = computed(() => {
}, 0) }, 0)
}) })
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
const filteredSites = computed(() => { const filteredSites = computed(() => {
let filtered = sites.value let filtered = sites.value
+8
View File
@@ -58,6 +58,13 @@
pièces</span pièces</span
> >
</div> </div>
<div class="flex items-center gap-2">
<IconLucideBox class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.productRequirements?.length || 0 }} produit(s)
requis</span
>
</div>
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button <button
@@ -99,6 +106,7 @@ import { useToast } from "~/composables/useToast";
import IconLucidePlus from "~icons/lucide/plus"; import IconLucidePlus from "~icons/lucide/plus";
import IconLucidePackage from "~icons/lucide/package"; import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid"; import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box";
const { machineTypes, loading, loadMachineTypes, deleteMachineType } = const { machineTypes, loading, loadMachineTypes, deleteMachineType } =
useMachineTypesApi(); useMachineTypesApi();
+29 -4
View File
@@ -65,6 +65,10 @@
<IconLucideList class="h-4 w-4" aria-hidden="true" /> <IconLucideList class="h-4 w-4" aria-hidden="true" />
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces {{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
</span> </span>
<span class="inline-flex items-center gap-1">
<IconLucideBox class="h-4 w-4" aria-hidden="true" />
{{ type.productRequirements?.length || 0 }} produit(s)
</span>
</div> </div>
</div> </div>
</article> </article>
@@ -85,6 +89,7 @@ import { useToast } from '~/composables/useToast'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideClipboardList from '~icons/lucide/clipboard-list' import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideList from '~icons/lucide/list' import IconLucideList from '~icons/lucide/list'
import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi() const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast() const { showError } = useToast()
@@ -100,7 +105,8 @@ const createEmptyType = () => ({
maintenanceFrequency: '', maintenanceFrequency: '',
customFields: [], customFields: [],
componentRequirements: [], componentRequirements: [],
pieceRequirements: [] pieceRequirements: [],
productRequirements: []
}) })
const draftType = ref(createEmptyType()) const draftType = ref(createEmptyType())
@@ -139,12 +145,15 @@ const parseOptions = (field = {}) => {
const normalizeCustomFields = (fields = []) => const normalizeCustomFields = (fields = []) =>
fields fields
.filter(field => field?.name && field.name.trim() !== '') .filter(field => field?.name && field.name.trim() !== '')
.map(field => ({ .map((field, index) => ({
name: field.name, name: field.name,
type: field.type || '', type: field.type || '',
required: !!field.required, required: !!field.required,
options: parseOptions(field) options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
})) }))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => { const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) { if (value === '' || value === undefined || value === null) {
@@ -184,6 +193,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index })) .map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId)
.map((req, index) => ({
typeProductId: req.typeProductId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const buildPayload = typeData => ({ const buildPayload = typeData => ({
name: typeData.name, name: typeData.name,
description: typeData.description, description: typeData.description,
@@ -191,7 +215,8 @@ const buildPayload = typeData => ({
maintenanceFrequency: typeData.maintenanceFrequency, maintenanceFrequency: typeData.maintenanceFrequency,
customFields: normalizeCustomFields(typeData.customFields), customFields: normalizeCustomFields(typeData.customFields),
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements), componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements) pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
productRequirements: normalizeProductRequirements(typeData.productRequirements)
}) })
const resetForm = () => { const resetForm = () => {
+1339 -84
View File
File diff suppressed because it is too large Load Diff
+606 -14
View File
@@ -90,13 +90,17 @@
<span class="font-medium">Groupes de pièces :</span> <span class="font-medium">Groupes de pièces :</span>
<span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</span> <span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</span>
</span> </span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{ selectedMachineType.productRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<span class="font-medium">Catégorie :</span> <span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span> <span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span>
</span> </span>
</div> </div>
<p <p
v-if="(selectedMachineType.componentRequirements?.length || 0) === 0 && (selectedMachineType.pieceRequirements?.length || 0) === 0" v-if="(selectedMachineType.componentRequirements?.length || 0) === 0 && (selectedMachineType.pieceRequirements?.length || 0) === 0 && (selectedMachineType.productRequirements?.length || 0) === 0"
class="text-xs text-gray-500" class="text-xs text-gray-500"
> >
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type. Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
@@ -196,7 +200,7 @@
Référence : {{ findComponentById(entry.composantId)?.reference || "—" }} Référence : {{ findComponentById(entry.composantId)?.reference || "—" }}
</div> </div>
<div> <div>
Constructeur : Fournisseur :
{{ findComponentById(entry.composantId)?.constructeur?.name || findComponentById(entry.composantId)?.constructeurName || "—" }} {{ findComponentById(entry.composantId)?.constructeur?.name || findComponentById(entry.composantId)?.constructeurName || "—" }}
</div> </div>
@@ -298,16 +302,136 @@
Référence : {{ findPieceById(entry.pieceId)?.reference || "—" }} Référence : {{ findPieceById(entry.pieceId)?.reference || "—" }}
</div> </div>
<div> <div>
Constructeur : Fournisseur :
{{ findPieceById(entry.pieceId)?.constructeur?.name || findPieceById(entry.pieceId)?.constructeurName || "—" }} {{ findPieceById(entry.pieceId)?.constructeur?.name || findPieceById(entry.pieceId)?.constructeurName || "—" }}
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
<div v-if="selectedMachineType?.productRequirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Produits catalogue requis
</h4>
<div
v-for="requirement in selectedMachineType.productRequirements"
:id="`product-group-${requirement.id}`"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h5 class="font-medium text-sm">
{{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
</h5>
<p class="text-xs text-gray-500">
Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
· Max : {{ requirement.maxCount ?? '' }}
</p>
<p
v-if="(requirement.allowNewModels ?? true) === false"
class="text-xs text-error"
>
Sélection de produits existants uniquement.
</p>
</div>
<button
type="button"
class="btn btn-sm btn-outline"
:disabled="requirement.maxCount !== null && getProductRequirementEntries(requirement.id).length >= requirement.maxCount"
@click="addProductSelectionEntry(requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getProductRequirementEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucun produit sélectionné pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getProductRequirementEntries(requirement.id)"
:key="`${requirement.id}-product-${entryIndex}`"
class="bg-base-200/60 rounded-md p-3 space-y-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
<span>
Catégorie appliquée :
{{ requirement.typeProduct?.name || 'Non définie' }}
</span>
<button
type="button"
class="btn btn-square btn-xs btn-error"
@click="removeProductSelectionEntry(requirement.id, entryIndex)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-1 gap-3">
<div class="space-y-2">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Produit existant</span>
</label>
<ProductSelect
:model-value="entry.productId || ''"
:type-product-id="requirement.typeProductId || requirement.typeProduct?.id || null"
:placeholder="productsLoading ? 'Chargement' : 'Sélectionner un produit'"
empty-text="Aucun produit disponible pour cette catégorie"
:disabled="productsLoading"
@update:modelValue="setProductRequirementProduct(requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="!productsLoading && getProductOptions(requirement).length === 0"
class="text-xs text-error"
>
Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
</p>
</div>
<div
v-if="entry.productId"
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
>
<div class="font-medium">
{{ findProductById(entry.productId)?.name || 'Produit' }}
</div>
<div>
Référence : {{ findProductById(entry.productId)?.reference || "—" }}
</div>
<div>
Prix indicatif :
<span
v-if="findProductById(entry.productId)?.supplierPrice !== undefined && findProductById(entry.productId)?.supplierPrice !== null"
>
{{ Number(findProductById(entry.productId)?.supplierPrice).toFixed(2) }} €
</span>
<span v-else>
</span>
</div>
<div>
Fournisseurs :
<span v-if="findProductById(entry.productId)?.constructeurs?.length">
{{ findProductById(entry.productId)?.constructeurs.map(constructeur => constructeur?.name).filter(Boolean).join(', ') }}
</span>
<span v-else>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="machinePreview" class="space-y-4"> </div>
</div>
<div v-if="machinePreview" class="space-y-4">
<div class="border border-base-200 rounded-lg bg-base-100/80"> <div class="border border-base-200 rounded-lg bg-base-100/80">
<div class="p-4 space-y-4"> <div class="p-4 space-y-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -486,6 +610,73 @@
Aucun groupe de pièces à configurer pour ce type. Aucun groupe de pièces à configurer pour ce type.
</div> </div>
<div v-if="machinePreview.productGroups.length" class="space-y-3">
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Produits requis
</h5>
<div
v-for="group in machinePreview.productGroups"
:key="group.id"
:id="`product-group-${group.id}`"
class="border border-base-200 rounded-md p-3 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="text-sm font-semibold">
{{ group.label }}
</p>
<p class="text-xs text-gray-500">
Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
Couverture : {{ group.count }}
</span>
<span class="badge badge-ghost badge-sm">
Direct {{ group.completed }} / {{ group.total || 0 }}
</span>
</div>
</div>
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
<ul class="list-disc pl-4 space-y-1">
<li v-for="issue in group.issues" :key="issue.message">
{{ issue.message }}
</li>
</ul>
</div>
<ul v-if="group.entries?.length" class="space-y-2">
<li
v-for="entry in group.entries"
:key="entry.key"
class="flex items-start gap-3"
>
<component
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
class="w-4 h-4 mt-0.5"
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
aria-hidden="true"
/>
<div class="flex-1">
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
{{ entry.title }}
</p>
<p v-if="entry.subtitle" class="text-xs text-gray-500">
{{ entry.subtitle }}
</p>
</div>
</li>
</ul>
<p v-else class="text-xs text-gray-500">
Couverture assurée via composants ou pièces liés.
</p>
</div>
</div>
<div <div
v-if="machinePreview.issues.length && machinePreview.status !== 'ready'" v-if="machinePreview.issues.length && machinePreview.status !== 'ready'"
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning" class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
@@ -551,9 +742,11 @@ import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils' import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
import IconLucideEye from '~icons/lucide/eye' import IconLucideEye from '~icons/lucide/eye'
@@ -566,6 +759,7 @@ const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi() const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
const { composants, loadComposants, loading: composantsLoading } = useComposants() const { composants, loadComposants, loading: composantsLoading } = useComposants()
const { pieces, loadPieces, loading: piecesLoading } = usePieces() const { pieces, loadPieces, loading: piecesLoading } = usePieces()
const { products, loadProducts, loading: productsLoading } = useProducts()
const toast = useToast() const toast = useToast()
const submitting = ref(false) const submitting = ref(false)
@@ -579,6 +773,7 @@ const newMachine = reactive({
const componentRequirementSelections = reactive({}) const componentRequirementSelections = reactive({})
const pieceRequirementSelections = reactive({}) const pieceRequirementSelections = reactive({})
const productRequirementSelections = reactive({})
const selectedMachineType = computed(() => { const selectedMachineType = computed(() => {
if (!newMachine.typeMachineId) { if (!newMachine.typeMachineId) {
@@ -604,7 +799,12 @@ const machineTypeDescription = (type) => {
} }
const componentCount = type.componentRequirements?.length ?? 0 const componentCount = type.componentRequirements?.length ?? 0
const pieceCount = type.pieceRequirements?.length ?? 0 const pieceCount = type.pieceRequirements?.length ?? 0
parts.push(`${componentCount} composant(s)`, `${pieceCount} pièce(s)`) const productCount = type.productRequirements?.length ?? 0
parts.push(
`${componentCount} composant(s)`,
`${pieceCount} pièce(s)`,
`${productCount} produit(s)`
)
return parts.join(' • ') return parts.join(' • ')
} }
@@ -630,6 +830,17 @@ const pieceById = computed(() => {
const componentInventory = computed(() => composants.value || []) const componentInventory = computed(() => composants.value || [])
const pieceInventory = computed(() => pieces.value || []) const pieceInventory = computed(() => pieces.value || [])
const productInventory = computed(() => products.value || [])
const productById = computed(() => {
const map = new Map()
;(productInventory.value || []).forEach((product) => {
if (product?.id) {
map.set(product.id, product)
}
})
return map
})
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value) const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -904,6 +1115,11 @@ const componentOptionDescription = (component) => {
if (machineAssignments.length) { if (machineAssignments.length) {
parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
} }
const productTypeName = component.product?.typeProduct?.name
const productLabel = component.product?.name || component.product?.reference
if (productTypeName || productLabel) {
parts.push(`Produit: ${productTypeName || productLabel}`)
}
return parts.join(' • ') return parts.join(' • ')
} }
@@ -929,9 +1145,83 @@ const pieceOptionDescription = (piece) => {
if (componentAssignments.length) { if (componentAssignments.length) {
parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`) parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
} }
const productTypeName = piece.product?.typeProduct?.name
const productLabel = piece.product?.name || piece.product?.reference
if (productTypeName || productLabel) {
parts.push(`Produit: ${productTypeName || productLabel}`)
}
return parts.join(' • ') return parts.join(' • ')
} }
const getProductOptions = (requirement) => {
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
return productInventory.value.filter((product) => {
if (!product?.id) {
return false
}
if (!requirementTypeId) {
return true
}
const productTypeId =
product.typeProductId ||
product.typeProduct?.id ||
null
return productTypeId === requirementTypeId
})
}
const productOptionLabel = (product) => product?.name || product?.reference || 'Produit'
const productOptionDescription = (product) => {
if (!product) {
return ''
}
const parts = []
if (product.reference) {
parts.push(`Réf. ${product.reference}`)
}
if (product.constructeurs?.length) {
const label = product.constructeurs
.map((constructeur) => constructeur?.name)
.filter(Boolean)
.join(', ')
if (label) {
parts.push(`Fournisseurs: ${label}`)
}
}
if (product.supplierPrice !== undefined && product.supplierPrice !== null) {
const price = Number(product.supplierPrice)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.join(' • ')
}
const getProductTypeIdFromComponent = (component) => {
if (!component || typeof component !== 'object') {
return null
}
return (
component.product?.typeProductId ||
component.product?.typeProduct?.id ||
component.productTypeId ||
null
)
}
const getProductTypeIdFromPiece = (piece) => {
if (!piece || typeof piece !== 'object') {
return null
}
return (
piece.product?.typeProductId ||
piece.product?.typeProduct?.id ||
piece.productTypeId ||
null
)
}
const setComponentRequirementComponent = (requirement, index, componentId) => { const setComponentRequirementComponent = (requirement, index, componentId) => {
const entries = getComponentRequirementEntries(requirement.id) const entries = getComponentRequirementEntries(requirement.id)
const entry = entries[index] const entry = entries[index]
@@ -971,6 +1261,13 @@ const findPieceById = (id) => {
} }
return pieceById.value.get(id) || null return pieceById.value.get(id) || null
} }
const findProductById = (id) => {
if (!id) {
return null
}
return productById.value.get(id) || null
}
const getStatusBadgeClass = (status) => { const getStatusBadgeClass = (status) => {
if (status === 'ready') { if (status === 'ready') {
return 'badge-success' return 'badge-success'
@@ -1003,6 +1300,7 @@ const resolvePieceRequirementTypeLabel = (requirement, entry) => {
const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || [] const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || []
const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || [] const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || []
const getProductRequirementEntries = requirementId => productRequirementSelections[requirementId] || []
const createComponentSelectionEntry = (requirement, source = null) => ({ const createComponentSelectionEntry = (requirement, source = null) => ({
typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null, typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null,
@@ -1016,6 +1314,170 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
definition: {}, definition: {},
}) })
const createProductSelectionEntry = (requirement, source = null) => ({
typeProductId:
source?.typeProductId ||
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null,
productId: source?.productId || null,
})
const computeProductUsageFromSelections = (type) => {
const usage = new Map()
const increment = (typeProductId) => {
if (!typeProductId) {
return
}
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
}
for (const requirement of type.componentRequirements || []) {
const entries = getComponentRequirementEntries(requirement.id)
entries.forEach((entry) => {
if (!entry?.composantId) {
return
}
const component = findComponentById(entry.composantId)
const typeProductId = getProductTypeIdFromComponent(component)
increment(typeProductId)
})
}
for (const requirement of type.pieceRequirements || []) {
const entries = getPieceRequirementEntries(requirement.id)
entries.forEach((entry) => {
if (!entry?.pieceId) {
return
}
const piece = findPieceById(entry.pieceId)
const typeProductId = getProductTypeIdFromPiece(piece)
increment(typeProductId)
})
}
for (const requirement of type.productRequirements || []) {
const entries = getProductRequirementEntries(requirement.id)
entries.forEach((entry) => {
if (!entry?.productId) {
return
}
const product = findProductById(entry.productId)
const typeProductId =
product?.typeProductId ||
product?.typeProduct?.id ||
entry?.typeProductId ||
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null
increment(typeProductId)
})
}
return usage
}
const buildProductRequirementStats = (type) => {
const usage = computeProductUsageFromSelections(type)
const stats = (type.productRequirements || []).map((requirement) => {
const typeProductId =
requirement.typeProductId ||
requirement.typeProduct?.id ||
null
const label =
requirement.label?.trim() ||
requirement.typeProduct?.name ||
requirement.typeProduct?.code ||
'Produit requis'
const typeName = requirement.typeProduct?.name || 'Non défini'
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
const max = requirement.maxCount ?? null
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
const rawEntries = getProductRequirementEntries(requirement.id)
const normalizedEntries = rawEntries.map((entry, index) => {
const product = entry?.productId ? findProductById(entry.productId) : null
const subtitleParts = []
if (product?.reference) {
subtitleParts.push(`Réf. ${product.reference}`)
}
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
const price = Number(product.supplierPrice)
if (!Number.isNaN(price)) {
subtitleParts.push(`${price.toFixed(2)}`)
}
}
if (Array.isArray(product?.constructeurs) && product.constructeurs.length) {
const label = product.constructeurs.map((constructeur) => constructeur?.name).filter(Boolean).join(', ')
if (label) {
subtitleParts.push(`Fournisseurs: ${label}`)
}
}
return {
key: `${requirement.id}-${index}`,
status: product ? 'complete' : 'pending',
title: product?.name || product?.reference || `Sélection #${index + 1}`,
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
}
})
const issues = []
if (count < min) {
issues.push({
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (max !== null && count > max) {
issues.push({
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (normalizedEntries.length > 0 && normalizedEntries.some((entry) => entry.status !== 'complete')) {
issues.push({
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
const completed = normalizedEntries.filter((entry) => entry.status === 'complete').length
const total = normalizedEntries.length
const status = issues.some((issue) => issue.kind === 'error')
? 'error'
: issues.some((issue) => issue.kind === 'warning')
? 'warning'
: 'ready'
return {
id: requirement.id,
requirement,
label,
typeName,
count,
min,
max,
completed,
total,
entries: normalizedEntries,
issues,
allowNewModels: requirement.allowNewModels ?? true,
status,
}
})
return { stats, usage }
}
const clearRequirementSelections = () => { const clearRequirementSelections = () => {
Object.keys(componentRequirementSelections).forEach((key) => { Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key] delete componentRequirementSelections[key]
@@ -1023,6 +1485,9 @@ const clearRequirementSelections = () => {
Object.keys(pieceRequirementSelections).forEach((key) => { Object.keys(pieceRequirementSelections).forEach((key) => {
delete pieceRequirementSelections[key] delete pieceRequirementSelections[key]
}) })
Object.keys(productRequirementSelections).forEach((key) => {
delete productRequirementSelections[key]
})
} }
const addComponentSelectionEntry = (requirement) => { const addComponentSelectionEntry = (requirement) => {
@@ -1061,6 +1526,51 @@ const removePieceSelectionEntry = (requirementId, index) => {
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index) pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
} }
const addProductSelectionEntry = (requirement) => {
const entries = getProductRequirementEntries(requirement.id)
const max = requirement.maxCount ?? null
if (max !== null && entries.length >= max) {
toast.showError(`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || requirement.typeProduct?.name || 'ce groupe'}`)
return
}
productRequirementSelections[requirement.id] = [
...entries,
createProductSelectionEntry(requirement),
]
}
const removeProductSelectionEntry = (requirementId, index) => {
const entries = getProductRequirementEntries(requirementId)
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
const setProductRequirementProduct = (requirement, index, productId) => {
const entries = getProductRequirementEntries(requirement.id)
const entry = entries[index]
if (!entry) {
return
}
const normalizedProductId = productId || null
entry.productId = normalizedProductId
if (normalizedProductId) {
const product = findProductById(normalizedProductId)
entry.typeProductId =
product?.typeProductId ||
product?.typeProduct?.id ||
entry.typeProductId ||
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null
} else {
entry.typeProductId =
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null
}
}
const extractParentIdentifiers = (source) => { const extractParentIdentifiers = (source) => {
if (!isPlainObject(source)) { if (!isPlainObject(source)) {
return {} return {}
@@ -1113,6 +1623,7 @@ const validateRequirementSelections = (type) => {
const errors = [] const errors = []
const componentLinksPayload = [] const componentLinksPayload = []
const pieceLinksPayload = [] const pieceLinksPayload = []
const productLinksPayload = []
for (const requirement of type.componentRequirements || []) { for (const requirement of type.componentRequirements || []) {
const entries = getComponentRequirementEntries(requirement.id) const entries = getComponentRequirementEntries(requirement.id)
@@ -1216,6 +1727,58 @@ const validateRequirementSelections = (type) => {
}) })
} }
const { stats: productStats } = buildProductRequirementStats(type)
for (const requirement of type.productRequirements || []) {
const entries = getProductRequirementEntries(requirement.id)
const max = requirement.maxCount ?? null
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
}
entries.forEach((entry) => {
if (!entry.productId) {
errors.push(`Sélectionner un produit pour "${requirement.label || requirement.typeProduct?.name || 'Produits'}".`)
return
}
const product = findProductById(entry.productId)
if (!product) {
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
return
}
const requiredTypeId = requirement.typeProductId || requirement.typeProduct?.id || null
const productTypeId =
product.typeProductId ||
product.typeProduct?.id ||
entry.typeProductId ||
null
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
return
}
const payload = {
requirementId: requirement.id,
productId: entry.productId,
}
Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry))
productLinksPayload.push(payload)
})
}
productStats.forEach((stat) => {
stat.issues
.filter((issue) => issue.kind === 'error')
.forEach((issue) => {
errors.push(issue.message)
})
})
if (errors.length > 0) { if (errors.length > 0) {
return { valid: false, error: errors[0] } return { valid: false, error: errors[0] }
} }
@@ -1224,6 +1787,7 @@ const validateRequirementSelections = (type) => {
valid: true, valid: true,
componentLinks: componentLinksPayload, componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload, pieceLinks: pieceLinksPayload,
productLinks: productLinksPayload,
} }
} }
@@ -1425,20 +1989,24 @@ const machinePreview = computed(() => {
issues, issues,
completed, completed,
total: entries.length, total: entries.length,
status status
} }
}) })
const { stats: productGroups } = buildProductRequirementStats(type)
const aggregatedIssues = [ const aggregatedIssues = [
...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })), ...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })),
...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))), ...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))) ...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
...productGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
] ]
const statuses = [ const statuses = [
baseStatus, baseStatus,
...componentGroups.map(group => group.status), ...componentGroups.map(group => group.status),
...pieceGroups.map(group => group.status) ...pieceGroups.map(group => group.status),
...productGroups.map(group => group.status),
] ]
const overallStatus = statuses.includes('error') const overallStatus = statuses.includes('error')
@@ -1455,11 +2023,14 @@ const machinePreview = computed(() => {
}, },
componentGroups, componentGroups,
pieceGroups, pieceGroups,
productGroups,
type: { type: {
name: type.name, name: type.name,
category: type.category || null, category: type.category || null,
hasStructuredDefinition: hasStructuredDefinition:
(type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0 (type.componentRequirements?.length || 0) > 0 ||
(type.pieceRequirements?.length || 0) > 0 ||
(type.productRequirements?.length || 0) > 0
}, },
status: overallStatus, status: overallStatus,
ready: overallStatus === 'ready', ready: overallStatus === 'ready',
@@ -1508,6 +2079,7 @@ const handleIssueClick = (issue) => {
const initializeRequirementSelections = (type) => { const initializeRequirementSelections = (type) => {
const componentRequirements = type.componentRequirements || [] const componentRequirements = type.componentRequirements || []
const pieceRequirements = type.pieceRequirements || [] const pieceRequirements = type.pieceRequirements || []
const productRequirements = type.productRequirements || []
componentRequirements.forEach((requirement) => { componentRequirements.forEach((requirement) => {
const min = requirement.minCount ?? (requirement.required ? 1 : 0) const min = requirement.minCount ?? (requirement.required ? 1 : 0)
@@ -1528,6 +2100,19 @@ const initializeRequirementSelections = (type) => {
pieceRequirementSelections[requirement.id] = [] pieceRequirementSelections[requirement.id] = []
} }
}) })
productRequirements.forEach((requirement) => {
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
productRequirementSelections[requirement.id] = Array.from(
{ length: initialCount },
() => createProductSelectionEntry(requirement),
)
} else {
productRequirementSelections[requirement.id] = []
}
})
} }
const finalizeMachineCreation = async () => { const finalizeMachineCreation = async () => {
@@ -1553,10 +2138,14 @@ const finalizeMachineCreation = async () => {
typeMachineId: type.id typeMachineId: type.id
} }
const hasRequirements = (type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0 const hasRequirements =
(type.componentRequirements?.length || 0) > 0 ||
(type.pieceRequirements?.length || 0) > 0 ||
(type.productRequirements?.length || 0) > 0
let componentLinks = [] let componentLinks = []
let pieceLinks = [] let pieceLinks = []
let productLinks = []
if (hasRequirements) { if (hasRequirements) {
const validationResult = validateRequirementSelections(type) const validationResult = validateRequirementSelections(type)
@@ -1566,6 +2155,7 @@ const finalizeMachineCreation = async () => {
} }
componentLinks = validationResult.componentLinks componentLinks = validationResult.componentLinks
pieceLinks = validationResult.pieceLinks pieceLinks = validationResult.pieceLinks
productLinks = validationResult.productLinks
} }
const payload = { const payload = {
@@ -1573,7 +2163,8 @@ const finalizeMachineCreation = async () => {
...(hasRequirements ...(hasRequirements
? { ? {
componentLinks, componentLinks,
pieceLinks pieceLinks,
productLinks
} }
: {}) : {})
} }
@@ -1621,7 +2212,8 @@ onMounted(async () => {
loadSites(), loadSites(),
loadMachineTypes(), loadMachineTypes(),
loadComposants(), loadComposants(),
loadPieces() loadPieces(),
loadProducts()
]) ])
}) })
</script> </script>
+15 -6
View File
@@ -33,7 +33,7 @@
v-model="searchTerm" v-model="searchTerm"
type="text" type="text"
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, référence ou catégorie…" placeholder="Nom ou référence…"
/> />
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -92,8 +92,8 @@
<tr> <tr>
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Catégorie</th>
<th>Référence</th> <th>Référence</th>
<th>Type de pièce</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -106,8 +106,8 @@
/> />
</td> </td>
<td>{{ piece.name || 'Pièce sans nom' }}</td> <td>{{ piece.name || 'Pièce sans nom' }}</td>
<td>{{ piece.typePiece?.name || '—' }}</td>
<td>{{ piece.reference || '—' }}</td> <td>{{ piece.reference || '—' }}</td>
<td>{{ resolvePieceType(piece) }}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -182,6 +182,17 @@ const resolvePreviewAlt = (piece: Record<string, any>) => {
return 'Aperçu du document' return 'Aperçu du document'
} }
const resolvePieceType = (piece: Record<string, any>) => {
const type = piece?.typePiece
if (type?.name) {
return type.name
}
if (piece?.typePieceLabel) {
return piece.typePieceLabel
}
return '—'
}
const resolveDeleteGuard = (piece: Record<string, any>) => { const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = [] const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks) const machineLinks = Array.isArray(piece?.machineLinks)
@@ -234,11 +245,9 @@ const visiblePieces = computed(() => {
? source.filter((piece) => { ? source.filter((piece) => {
const name = (piece?.name || '').toLowerCase() const name = (piece?.name || '').toLowerCase()
const reference = (piece?.reference || '').toLowerCase() const reference = (piece?.reference || '').toLowerCase()
const category = (piece?.typePiece?.name || '').toLowerCase()
return ( return (
name.includes(term) || name.includes(term) ||
reference.includes(term) || reference.includes(term)
category.includes(term)
) )
}) })
: [...source] : [...source]
+122 -19
View File
@@ -89,19 +89,19 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="saving" :disabled="saving"
placeholder="Référence interne ou constructeur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Constructeur</span> <span class="label-text">Fournisseur</span>
</label> </label>
<ConstructeurSelect <ConstructeurSelect
v-model="editionForm.constructeurId" v-model="editionForm.constructeurIds"
class="w-full" class="w-full"
:disabled="saving" :disabled="saving"
placeholder="Rechercher un constructeur..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
</div> </div>
@@ -123,6 +123,36 @@
</div> </div>
</div> </div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<ProductSelect
v-model="editionForm.productId"
:disabled="saving"
:type-product-id="primaryProductRequirement?.typeProductId || null"
helper-text="Un produit valide est requis pour cette pièce."
/>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
@@ -356,6 +386,7 @@ import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
@@ -365,7 +396,8 @@ import { useDocuments } from '~/composables/useDocuments'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
import type { PieceModelStructure } from '~/shared/types/inventory' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
@@ -382,6 +414,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const route = useRoute() const route = useRoute()
@@ -407,8 +440,9 @@ const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
reference: '' as string, reference: '' as string,
constructeurId: null as string | null, constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
productId: null as string | null,
}) })
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
@@ -540,6 +574,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
}) })
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { customFieldInputs.value.every((field) => {
if (!field.required) { if (!field.required) {
@@ -552,12 +622,15 @@ const requiredCustomFieldsFilled = computed(() =>
}), }),
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() =>
piece.value && Boolean(
editionForm.name && piece.value &&
requiredCustomFieldsFilled.value && editionForm.name &&
!saving.value, requiredCustomFieldsFilled.value &&
)) (!requiresProductSelection.value || editionForm.productId) &&
!saving.value,
),
)
const toFieldString = (value: unknown): string => { const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -602,8 +675,13 @@ watch(
editionForm.name = currentPiece.name || '' editionForm.name = currentPiece.name || ''
editionForm.reference = currentPiece.reference || '' editionForm.reference = currentPiece.reference || ''
editionForm.constructeurId = currentPiece.constructeur?.id || currentPiece.constructeurId || null editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece,
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : '' editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
customFieldInputs.value = buildCustomFieldInputs( customFieldInputs.value = buildCustomFieldInputs(
currentType?.structure ?? null, currentType?.structure ?? null,
@@ -630,6 +708,11 @@ const submitEdition = async () => {
return return
} }
if (requiresProductSelection.value && !editionForm.productId) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const rawPrice = typeof editionForm.prix === 'string' const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim() ? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined : editionForm.prix === null || editionForm.prix === undefined
@@ -642,7 +725,13 @@ const submitEdition = async () => {
const reference = editionForm.reference.trim() const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null payload.reference = reference ? reference : null
payload.constructeurId = editionForm.constructeurId || null payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const selectedProductId =
typeof editionForm.productId === 'string'
? editionForm.productId.trim()
: ''
payload.productId = selectedProductId || null
if (rawPrice) { if (rawPrice) {
const parsed = Number(rawPrice) const parsed = Number(rawPrice)
@@ -701,6 +790,7 @@ const buildCustomFieldInputs = (
...definition, ...definition,
customFieldId: definition.customFieldId || definition.id, customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null, customFieldValueId: null,
orderIndex: definition.orderIndex,
} }
} }
@@ -710,8 +800,14 @@ const buildCustomFieldInputs = (
customFieldId: matched.customField?.id || definition.customFieldId || definition.id, customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null, customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue), value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
} }
}) }).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
return resolved return resolved
} }
@@ -725,11 +821,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -750,7 +847,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {
@@ -825,7 +924,11 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
return String(defaultValue) return String(defaultValue)
} }
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : [] const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({ const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name, customFieldName: field.name,
+115 -19
View File
@@ -62,19 +62,19 @@
type="text" type="text"
class="input input-bordered input-sm md:input-md" class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType" :disabled="submitting || !selectedType"
placeholder="Référence interne ou constructeur" placeholder="Référence interne ou fournisseur"
> >
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Constructeur</span> <span class="label-text">Fournisseur</span>
</label> </label>
<ConstructeurSelect <ConstructeurSelect
v-model="creationForm.constructeurId" v-model="creationForm.constructeurIds"
class="w-full" class="w-full"
:disabled="submitting || !selectedType" :disabled="submitting || !selectedType"
placeholder="Rechercher un constructeur..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
</div> </div>
@@ -96,6 +96,36 @@
</div> </div>
</div> </div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<ProductSelect
v-model="creationForm.productId"
:disabled="submitting || !selectedType"
:type-product-id="primaryProductRequirement?.typeProductId || null"
helper-text="Un produit est requis pour cette pièce."
/>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
@@ -253,6 +283,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports' import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
@@ -260,7 +291,8 @@ import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
import type { PieceModelStructure } from '~/shared/types/inventory' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
@@ -283,8 +315,9 @@ const submitting = ref(false)
const creationForm = reactive({ const creationForm = reactive({
name: '' as string, name: '' as string,
reference: '' as string, reference: '' as string,
constructeurId: null as string | null, constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
productId: null as string | null,
}) })
const lastSuggestedName = ref('') const lastSuggestedName = ref('')
@@ -331,6 +364,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
}) })
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
watch(selectedType, (type) => { watch(selectedType, (type) => {
if (!type) { if (!type) {
clearCreationForm() clearCreationForm()
@@ -342,6 +411,7 @@ watch(selectedType, (type) => {
} }
lastSuggestedName.value = creationForm.name lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure) customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
creationForm.productId = null
}) })
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
@@ -356,12 +426,15 @@ const requiredCustomFieldsFilled = computed(() =>
}), }),
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() =>
selectedType.value && Boolean(
creationForm.name && selectedType.value &&
requiredCustomFieldsFilled.value && creationForm.name &&
!submitting.value, requiredCustomFieldsFilled.value &&
)) (!requiresProductSelection.value || creationForm.productId) &&
!submitting.value,
),
)
const toFieldString = (value: unknown): string => { const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -376,13 +449,18 @@ const toFieldString = (value: unknown): string => {
return '' return ''
} }
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : [] const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurId = null creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
creationForm.productId = null
lastSuggestedName.value = '' lastSuggestedName.value = ''
} }
@@ -391,6 +469,12 @@ const submitCreation = async () => {
toast.showError('Sélectionnez une catégorie de pièce.') toast.showError('Sélectionnez une catégorie de pièce.')
return return
} }
if (requiresProductSelection.value && !creationForm.productId) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: creationForm.name.trim(), name: creationForm.name.trim(),
typePieceId: selectedType.value.id, typePieceId: selectedType.value.id,
@@ -401,8 +485,16 @@ const submitCreation = async () => {
payload.reference = reference payload.reference = reference
} }
if (creationForm.constructeurId) { if (creationForm.constructeurIds.length) {
payload.constructeurId = creationForm.constructeurId payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
const selectedProductId =
typeof creationForm.productId === 'string'
? creationForm.productId.trim()
: ''
if (selectedProductId) {
payload.productId = selectedProductId
} }
const rawPrice = typeof creationForm.prix === 'string' const rawPrice = typeof creationForm.prix === 'string'
@@ -466,6 +558,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const fieldKey = (field: CustomFieldInput, index: number) => const fieldKey = (field: CustomFieldInput, index: number) =>
@@ -477,11 +570,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -502,7 +596,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {
+292
View File
@@ -0,0 +1,292 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des produits</h1>
<p class="text-sm text-base-content/70">
Retrouvez l'ensemble des produits du catalogue, leurs informations fournisseurs et leurs catégories.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/product/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un produit
</NuxtLink>
<NuxtLink to="/product-category" class="btn btn-outline btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
/>
</label>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
<select
id="product-sort"
v-model="sortField"
class="select select-bordered select-sm"
>
<option value="name">Nom</option>
<option value="createdAt">Date de création</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-dir">Ordre</label>
<select
id="product-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/60 lg:text-right">
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-10">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<div
v-else-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
<span class="font-semibold">Impossible de charger les produits</span>
<span class="text-sm">{{ errorMessage }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
Réessayer
</button>
</div>
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
Chargement du catalogue
</p>
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70">
Aucun produit n'a encore été enregistré.
</p>
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
Aucun produit ne correspond à votre recherche.
</p>
<div v-else class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-16">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Type de produit</th>
<th>Fournisseurs</th>
<th class="text-right">Prix indicatif</th>
<th class="w-32 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="product in filteredProducts" :key="product.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(product)"
:alt="resolvePreviewAlt(product)"
/>
</td>
<td class="font-medium">{{ product.name }}</td>
<td>{{ product.reference || '—' }}</td>
<td>{{ product.typeProduct?.name || '—' }}</td>
<td>
<span v-if="product.constructeurs?.length" class="text-sm">
{{ formatConstructeurs(product.constructeurs) }}
</span>
<span v-else class="text-sm text-base-content/50"></span>
</td>
<td class="text-right">
{{ formatPrice(product.supplierPrice) }}
</td>
<td class="text-right space-x-2">
<NuxtLink
:to="`/product/${product.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(product)"
>
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead } from '#imports'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
useHead(() => ({
title: 'Catalogue des produits',
}))
const {
products,
total,
loading,
loaded,
error,
loadProducts,
deleteProduct,
} = useProducts()
const toast = useToast()
const searchTerm = ref('')
const sortField = ref<'name' | 'createdAt'>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
const normalizedProducts = computed(() => (Array.isArray(products.value) ? products.value : []))
const hasLoaded = computed(() => loaded.value)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const filteredProducts = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const items = normalizedProducts.value.slice()
const filtered = term
? items.filter((product) => {
const name = (product?.name || '').toLowerCase()
const reference = (product?.reference || '').toLowerCase()
const typeName = (product?.typeProduct?.name || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term) ||
typeName.includes(term)
)
})
: items
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
) * direction
}
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
return (dateA - dateB) * direction
})
})
const filteredCount = computed(() => filteredProducts.value.length)
const totalCount = computed(() => {
const reported = Number(total.value)
if (!Number.isFinite(reported) || reported < 0) {
return normalizedProducts.value.length
}
return reported
})
const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') {
return '—'
}
const number = Number(value)
if (Number.isNaN(number)) {
return '—'
}
return priceFormatter.format(number)
}
const formatConstructeurs = (constructeurs: Array<Record<string, any>>) =>
constructeurs
.map((constructeur) => constructeur?.name)
.filter((name): name is string => Boolean(name))
.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) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path)
if (!withPath.length) {
return normalized[0] ?? null
}
const images = withPath.filter((doc) => isImageDocument(doc))
if (images.length) {
return images[0]
}
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
return withPath[0]
}
const resolvePreviewAlt = (product: Record<string, any>) => {
const parts = [product?.name, product?.reference].filter(Boolean)
if (parts.length) {
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
}
const reload = async () => {
await loadProducts({ force: true })
}
const confirmDelete = async (product: Record<string, any>) => {
const confirmed = window.confirm(
`Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
)
if (!confirmed) {
return
}
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${product.name}" supprimé`)
}
}
onMounted(async () => {
await loadProducts()
})
</script>
+122
View File
@@ -0,0 +1,122 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
<p class="text-base text-base-content/70">
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/product-category">
Retour au catalogue
</NuxtLink>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<div v-if="loading" class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie</span>
</div>
<ModelTypeForm
v-else
mode="edit"
initial-category="PRODUCT"
:initial-data="initialData"
:lock-category="true"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
)
useHead(() => ({
title: title.value,
}))
const navigateBackToList = async () => {
await router.push('/product-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const normalizeError = (error: any) => {
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
return Array.isArray(message) ? message[0] : message
}
const loadCategory = async () => {
loading.value = true
try {
const id = String(route.params.id)
const response = await getModelType(id)
if (response.category !== 'PRODUCT') {
showError("Cette catégorie n'est pas un produit.")
await navigateBackToList()
return
}
initialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined,
}
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
const id = String(route.params.id)
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} catch (error) {
showError(normalizeError(error))
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategory()
})
</script>
+11
View File
@@ -0,0 +1,11 @@
<template>
<ManagementView
category="PRODUCT"
heading="Catégories de produit"
description="Gérez les catégories de produits et leurs champs personnalisés communs."
/>
</template>
<script setup lang="ts">
import ManagementView from '~/components/model-types/ManagementView.vue'
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">Nouvelle catégorie de produit</h1>
<p class="text-base text-base-content/70">
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/product-category">
Retour au catalogue
</NuxtLink>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<ModelTypeForm
mode="create"
initial-category="PRODUCT"
:lock-category="true"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
title: 'Nouvelle catégorie de produit',
}))
const router = useRouter()
const { showError, showSuccess } = useToast()
const saving = ref(false)
const handleCancel = () => {
router.push('/product-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
showSuccess('Catégorie de produit créée avec succès.')
await router.push('/product-category')
} catch (error: any) {
const message = error?.data?.message || error?.message || 'Une erreur est survenue lors de la création.'
showError(Array.isArray(message) ? message[0] : message)
} finally {
saving.value = false
}
}
</script>
+747
View File
@@ -0,0 +1,747 @@
<template>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@close="closePreview"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du produit</p>
</div>
<div v-else-if="!product" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Produit introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
Retour au catalogue
</NuxtLink>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du produit et ses champs personnalisés.
</p>
</div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
Retour au catalogue
</NuxtLink>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
>
</div>
</div>
<div v-if="structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
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="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="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="saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="saving"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<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="saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
</div>
</div>
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce produit.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents
</p>
<div v-else-if="productDocuments.length" class="space-y-2">
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments || saving"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
Aucun document n'est associé à ce produit pour le moment.
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
} = useDocuments()
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
)
const canSubmit = computed(() =>
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) {
return '—'
}
if (size === 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
const shouldInlinePdf = (document: any) => {
if (!document || !isPdfDocument(document) || !document.path) {
return false
}
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
return false
}
return true
}
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src || ''
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`
}
return `${src}#toolbar=0&navpanes=0`
}
const documentPreviewSrc = (document: any) => {
if (!document?.path) {
return ''
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path)
}
return document.path
}
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
}
return 'h-16 w-16'
}
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const downloadDocument = (doc: any) => {
if (!doc?.path) {
return
}
const target = String(doc.path)
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(target, '_blank')
}
const loadProduct = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
product.value = null
loading.value = false
return
}
const result = await getProduct(id)
if (result.success) {
product.value = result.data
productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
await loadProductType()
hydrateForm()
await refreshDocuments()
} else {
product.value = null
}
loading.value = false
}
const refreshDocuments = async () => {
if (!product.value?.id) {
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
if (result.success) {
productDocuments.value = Array.isArray(result.data) ? result.data : []
}
} finally {
loadingDocuments.value = false
}
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteProductDocument(documentId, { updateStore: false })
if (result.success) {
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
toast.showSuccess('Document supprimé')
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !product.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadProductDocuments(
{
files,
context: { productId: product.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
toast.showSuccess('Document(s) ajouté(s)')
} else if (result.error) {
toast.showError(result.error)
}
} finally {
uploadingDocuments.value = false
}
}
const loadProductType = async () => {
if (!product.value?.typeProductId) {
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
return
}
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
}
}
const hydrateForm = () => {
if (!product.value) {
return
}
editionForm.name = product.value.name || ''
editionForm.reference = product.value.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
product.value,
Array.isArray(product.value.constructeurs) ? product.value.constructeurs : [],
)
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
? String(product.value.supplierPrice)
: ''
customFieldInputs.value = buildCustomFieldInputs(structure.value, product.value.customFieldValues)
}
watch(
() => product.value?.documents,
(docs) => {
if (Array.isArray(docs)) {
productDocuments.value = docs
}
},
{ immediate: true },
)
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
const buildCustomFieldInputs = (
productStructure: ProductModelStructure | null,
values: any[] | null | undefined,
): CustomFieldInput[] => {
if (!productStructure || typeof productStructure !== 'object') {
return []
}
const definitions = Array.isArray(productStructure.customFields) ? productStructure.customFields : []
const valueList = Array.isArray(values) ? values : []
const byId = new Map<string, any>()
const byName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) {
byId.set(fieldId, entry)
}
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) {
byName.set(fieldName, entry)
}
})
return definitions
.map((definition, index) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? byId.get(definitionId) : null) || byName.get(definition.name)
const type = typeof definition.type === 'string' ? definition.type : 'text'
const options = Array.isArray(definition.options) ? definition.options : []
const required = !!definition.required
const orderIndex = typeof definition.orderIndex === 'number' ? definition.orderIndex : index
if (!matched) {
return {
id: definition.id ?? null,
name: definition.name,
type,
required,
options,
value: '',
customFieldId: definition.customFieldId || definition.id || null,
customFieldValueId: null,
orderIndex,
}
}
const resolvedValue = matched.value ?? ''
return {
id: definition.id ?? null,
name: definition.name,
type,
required,
options,
value: formatDefaultValue(type, resolvedValue),
customFieldId: matched.customField?.id || definition.customFieldId || definition.id || null,
customFieldValueId: matched.id ?? null,
orderIndex,
}
})
.filter((field): field is CustomFieldInput => !!field?.name)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const formatDefaultValue = (type: string, value: any): string => {
if (value === null || value === undefined) {
return ''
}
if (type === 'boolean') {
return String(value === true || String(value).toLowerCase() === 'true')
}
return String(value)
}
const submitEdition = async () => {
if (!product.value) {
return
}
const payload: Record<string, any> = {
name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null,
constructeurIds: uniqueConstructeurIds(editionForm.constructeurIds),
}
const rawPrice = editionForm.supplierPrice.trim()
payload.supplierPrice = rawPrice
? Number.isNaN(Number(rawPrice))
? null
: Number(rawPrice)
: null
saving.value = true
try {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await saveCustomFieldValues(result.data.id)
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return
}
toast.showSuccess('Produit mis à jour avec succès')
await router.push('/product-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
} finally {
saving.value = false
}
}
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
const value = field.value ?? ''
if (field.customFieldValueId) {
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
failed.push(field.name)
}
continue
}
if (!field.customFieldId) {
continue
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
)
if (!result.success) {
failed.push(field.name)
}
}
return failed
}
onMounted(async () => {
await loadProduct()
})
</script>
+518
View File
@@ -0,0 +1,518 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
</p>
</div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
Retour au catalogue
</NuxtLink>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
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="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="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="submitting"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="submitting"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<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="submitting"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
>
</div>
</div>
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
<span v-if="submitting" class="loading loading-spinner loading-sm mr-2"></span>
Créer le produit
</button>
</div>
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import { useProductTypes } from '~/composables/useProductTypes'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ProductModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface ProductCatalogType extends ModelType {
structure: ProductModelStructure | null
customFields?: Array<Record<string, any>>
}
const route = useRoute()
const router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
orderIndex: number
}
const customFieldInputs = ref<CustomFieldInput[]>([])
const loadingTypes = computed(() => loadingProductTypes.value)
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],
)
const typeOptionLabel = (type?: ProductCatalogType) => type?.name || 'Catégorie'
const typeOptionDescription = (type?: ProductCatalogType) =>
type?.description ? String(type.description) : ''
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
watch(
() => route.query.typeId,
(value) => {
if (typeof value === 'string') {
selectedTypeId.value = value
}
},
)
watch(selectedTypeId, (id) => {
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
if ((id || '') === current) {
return
}
const nextQuery = { ...route.query }
if (id) {
nextQuery.typeId = id
} else {
delete nextQuery.typeId
}
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
watch(selectedType, (type) => {
if (!type) {
clearForm()
customFieldInputs.value = []
return
}
if (!creationForm.name) {
creationForm.name = type.name
}
customFieldInputs.value = normalizeCustomFieldInputs(normalizeProductStructureForSave(type.structure))
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
)
const canSubmit = computed(() => Boolean(
selectedType.value &&
creationForm.name.trim().length >= 2 &&
requiredCustomFieldsFilled.value &&
!submitting.value,
))
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: ProductModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {
return []
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') {
return null
}
const name = typeof rawField.name === 'string' ? rawField.name.trim() : ''
if (!name) {
return null
}
const type = typeof rawField.type === 'string' ? rawField.type : 'text'
const required = !!rawField.required
const options = Array.isArray(rawField.options)
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
: []
const defaultSource = rawField.defaultValue ?? rawField.value ?? rawField.default ?? null
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, orderIndex }
}
const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) {
return ''
}
if (type === 'boolean') {
return String(defaultValue === true || String(defaultValue).toLowerCase() === 'true')
}
return String(defaultValue)
}
const clearForm = () => {
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.supplierPrice = ''
}
const buildPayload = () => {
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typeProductId: selectedType.value?.id,
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
}
if (creationForm.constructeurIds.length) {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
const rawPrice = creationForm.supplierPrice.trim()
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.supplierPrice = parsed
}
}
return payload
}
const submitCreation = async () => {
if (!selectedType.value) {
toast.showError('Sélectionnez une catégorie de produit.')
return
}
submitting.value = true
try {
const payload = buildPayload()
const result = await createProduct(payload)
if (result.success && result.data?.id) {
const productId = result.data.id
const failedFields = await saveCustomFieldValues(result.data.id)
if (failedFields.length) {
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
await router.push(`/product/${result.data.id}/edit`)
return
}
if (selectedDocuments.value.length) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
context: { productId },
},
{ updateStore: false },
)
if (!uploadResult.success) {
const message = uploadResult.error
? `Documents non ajoutés : ${uploadResult.error}`
: 'Documents non ajoutés : une erreur est survenue.'
toast.showError(message)
} else {
selectedDocuments.value = []
}
}
toast.showSuccess('Produit créé avec succès')
await router.push('/product-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du produit')
} finally {
submitting.value = false
uploadingDocuments.value = false
}
}
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
if (!field.customFieldId || !field.name) {
continue
}
const value = field.value ?? ''
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
)
if (!result.success) {
failed.push(field.name)
}
}
return failed
}
onMounted(async () => {
await loadProductTypes()
if (selectedTypeId.value && !selectedType.value) {
await router.replace({
path: route.path,
query: { ...route.query, typeId: undefined },
}).catch(() => {})
}
})
</script>
+33
View File
@@ -93,6 +93,38 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Produits requis -->
<div v-if="productRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Produits requis
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.productRequirements"
:key="requirement.id || requirement.typeProductId"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typeProduct?.name || 'Produit' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeProduct?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de produits autorisée' : 'Produits existants uniquement' }}
</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -141,6 +173,7 @@ const typePageTitle = computed(() => {
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0) const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0) const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
const toDisplayCount = (value, fallback) => { const toDisplayCount = (value, fallback) => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
+26 -5
View File
@@ -70,7 +70,8 @@ const editedType = ref({
maintenanceFrequency: '', maintenanceFrequency: '',
customFields: [], customFields: [],
componentRequirements: [], componentRequirements: [],
pieceRequirements: [] pieceRequirements: [],
productRequirements: []
}) })
const parseOptions = (field = {}) => { const parseOptions = (field = {}) => {
@@ -92,12 +93,15 @@ const parseOptions = (field = {}) => {
const normalizeCustomFields = (fields = []) => const normalizeCustomFields = (fields = []) =>
fields fields
.filter(field => field?.name && field.name.trim() !== '') .filter(field => field?.name && field.name.trim() !== '')
.map(field => ({ .map((field, index) => ({
name: field.name, name: field.name,
type: field.type || '', type: field.type || '',
required: !!field.required, required: !!field.required,
options: parseOptions(field) options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
})) }))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => { const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) { if (value === '' || value === undefined || value === null) {
@@ -137,6 +141,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index })) .map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId)
.map((req, index) => ({
typeProductId: req.typeProductId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const saveChanges = async () => { const saveChanges = async () => {
try { try {
saving.value = true saving.value = true
@@ -148,7 +167,8 @@ const saveChanges = async () => {
...currentEditedType, ...currentEditedType,
customFields: normalizeCustomFields(currentEditedType.customFields), customFields: normalizeCustomFields(currentEditedType.customFields),
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements), componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements) pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
} }
const result = await updateMachineType(type.value.id, updatedType) const result = await updateMachineType(type.value.id, updatedType)
@@ -189,7 +209,8 @@ onMounted(async () => {
maintenanceFrequency: type.value.maintenanceFrequency || '', maintenanceFrequency: type.value.maintenanceFrequency || '',
customFields: type.value.customFields || [], customFields: type.value.customFields || [],
componentRequirements: type.value.componentRequirements || [], componentRequirements: type.value.componentRequirements || [],
pieceRequirements: type.value.pieceRequirements || [] pieceRequirements: type.value.pieceRequirements || [],
productRequirements: type.value.productRequirements || [],
} }
} else { } else {
console.error('Failed to load type:', result.error) console.error('Failed to load type:', result.error)
+16 -3
View File
@@ -3,11 +3,16 @@ import type { FetchOptions } from 'ofetch';
import type { import type {
ComponentModelStructure, ComponentModelStructure,
PieceModelStructure, PieceModelStructure,
ProductModelStructure,
} from '~/shared/types/inventory'; } from '~/shared/types/inventory';
export type ModelCategory = 'COMPONENT' | 'PIECE'; export type ModelCategory = 'COMPONENT' | 'PIECE' | 'PRODUCT';
export type ModelTypeStructure = ComponentModelStructure | PieceModelStructure | null; export type ModelTypeStructure =
| ComponentModelStructure
| PieceModelStructure
| ProductModelStructure
| null;
export interface BaseModelTypePayload { export interface BaseModelTypePayload {
name: string; name: string;
@@ -26,7 +31,15 @@ export interface PieceModelTypePayload extends BaseModelTypePayload {
structure?: PieceModelStructure | null; structure?: PieceModelStructure | null;
} }
export type ModelTypePayload = ComponentModelTypePayload | PieceModelTypePayload; export interface ProductModelTypePayload extends BaseModelTypePayload {
category: 'PRODUCT';
structure?: ProductModelStructure | null;
}
export type ModelTypePayload =
| ComponentModelTypePayload
| PieceModelTypePayload
| ProductModelTypePayload;
export interface ModelType extends BaseModelTypePayload { export interface ModelType extends BaseModelTypePayload {
id: string; id: string;
+125
View File
@@ -0,0 +1,125 @@
import { formatPhone } from '~/utils/formatters/phone';
export interface ConstructeurSummary {
id: string;
name?: string | null;
email?: string | null;
phone?: string | null;
}
const isObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const toStringId = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
export const uniqueConstructeurIds = (...sources: unknown[]): string[] => {
const ids = new Set<string>();
const pushId = (value: unknown) => {
const id = toStringId(value);
if (id) {
ids.add(id);
}
};
const explore = (value: unknown): void => {
if (!value) {
return;
}
if (Array.isArray(value)) {
value.forEach(explore);
return;
}
if (typeof value === 'string') {
pushId(value);
return;
}
if (isObject(value)) {
if (Array.isArray(value.constructeurIds)) {
value.constructeurIds.forEach(pushId);
}
if (value.constructeurId) {
pushId(value.constructeurId);
}
if (Array.isArray(value.constructeurs)) {
value.constructeurs.forEach(explore);
}
if (value.constructeur) {
explore(value.constructeur);
}
if (typeof value.id === 'string') {
pushId(value.id);
}
return;
}
};
sources.forEach(explore);
return Array.from(ids);
};
export const resolveConstructeurs = (
ids: string[],
...candidatePools: Array<ConstructeurSummary[] | null | undefined>
): ConstructeurSummary[] => {
if (!Array.isArray(ids) || ids.length === 0) {
return [];
}
const index = new Map<string, ConstructeurSummary>();
const register = (pool?: ConstructeurSummary[] | null) => {
if (!Array.isArray(pool)) {
return;
}
pool.forEach((entry) => {
if (entry && typeof entry === 'object' && typeof entry.id === 'string') {
index.set(entry.id, entry);
}
});
};
candidatePools.forEach(register);
return ids
.map((id) => index.get(id))
.filter((item): item is ConstructeurSummary => Boolean(item))
.map((item) => ({ ...item }));
};
export const formatConstructeurContact = (
constructeur?: ConstructeurSummary | null,
): string => {
if (!constructeur) {
return '';
}
const formattedPhone = formatPhone(constructeur.phone);
const phone = formattedPhone || constructeur.phone || null;
return [constructeur.email, phone].filter(Boolean).join(' • ');
};
export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
payload: T,
): T & { constructeurIds: string[] } => {
const ids = uniqueConstructeurIds(
payload?.constructeurIds,
payload?.constructeurId,
payload?.constructeur,
payload?.constructeurs,
);
const next = { ...payload } as Record<string, any>;
next.constructeurIds = ids;
delete next.constructeurId;
delete next.constructeur;
delete next.constructeurs;
return next as T & { constructeurIds: string[] };
};
+182 -18
View File
@@ -3,14 +3,19 @@ import {
type ComponentModelCustomFieldType, type ComponentModelCustomFieldType,
type ComponentModelCustomField, type ComponentModelCustomField,
type ComponentModelPiece, type ComponentModelPiece,
type ComponentModelProduct,
type ComponentModelStructure, type ComponentModelStructure,
type ComponentModelStructureNode, type ComponentModelStructureNode,
type PieceModelCustomField, type PieceModelCustomField,
type PieceModelProduct,
type PieceModelStructure, type PieceModelStructure,
type PieceModelStructureEditorField, type PieceModelStructureEditorField,
type PieceModelStructureForEditor, type PieceModelStructureForEditor,
type ProductModelStructure,
createEmptyProductModelStructure,
createEmptyPieceModelStructure, createEmptyPieceModelStructure,
} from './types/inventory' } from './types/inventory'
import { uniqueConstructeurIds } from './constructeurUtils'
export const isPlainObject = (value: unknown): value is Record<string, unknown> => { export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value) return value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -19,6 +24,7 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
export interface ModelStructurePreview { export interface ModelStructurePreview {
customFields: number customFields: number
pieces: number pieces: number
products: number
subcomponents: number subcomponents: number
} }
@@ -36,6 +42,7 @@ const ensureStructureShape = (input: any): ComponentModelStructure => {
...base, ...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [], customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [], pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
subcomponents: Array.isArray((input as any).subcomponents) subcomponents: Array.isArray((input as any).subcomponents)
? (input as any).subcomponents ? (input as any).subcomponents
: Array.isArray((input as any).subComponents) : Array.isArray((input as any).subComponents)
@@ -93,7 +100,7 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
} }
return fields return fields
.map((field) => { .map((field, index) => {
const rawName = const rawName =
typeof field?.name === 'string' typeof field?.name === 'string'
? field.name ? field.name
@@ -172,6 +179,8 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (customFieldId) { if (customFieldId) {
result.customFieldId = customFieldId result.customFieldId = customFieldId
} }
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result return result
}) })
.filter((field): field is ComponentModelCustomField => !!field) .filter((field): field is ComponentModelCustomField => !!field)
@@ -237,6 +246,66 @@ const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
.filter((piece): piece is ComponentModelPiece => !!piece) .filter((piece): piece is ComponentModelPiece => !!piece)
} }
const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products
.map((product) => {
const rawTypeProductId = typeof product?.typeProductId === 'string'
? product.typeProductId.trim()
: typeof product?.typeProduct?.id === 'string'
? product.typeProduct.id.trim()
: ''
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
? product.typeProductLabel.trim()
: typeof product?.typeProduct?.name === 'string'
? product.typeProduct.name.trim()
: ''
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
? product.reference.trim()
: undefined
const rawFamilyCode = typeof product?.familyCode === 'string'
? product.familyCode.trim()
: typeof product?.typeProduct?.code === 'string'
? product.typeProduct.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelProduct = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typeProductId) {
result.typeProductId = typeProductId
}
if (typeProductLabel) {
result.typeProductLabel = typeProductLabel
}
return result
})
.filter((product): product is ComponentModelProduct => !!product)
}
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => { const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
@@ -328,6 +397,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
const result: ComponentModelStructure = { const result: ComponentModelStructure = {
customFields: customFields as ComponentModelCustomField[], customFields: customFields as ComponentModelCustomField[],
pieces: sanitizePieces(source.pieces), pieces: sanitizePieces(source.pieces),
products: sanitizeProducts(source.products),
subcomponents: hydrateSubcomponents(source.subcomponents), subcomponents: hydrateSubcomponents(source.subcomponents),
} }
@@ -395,6 +465,20 @@ export const normalizeStructureForSave = (input: any): any => {
return payload return payload
}) as any }) as any
const backendProducts = sanitizeProducts(source.products).map((product) => {
const payload: Record<string, any> = {}
if ((product as any).familyCode) {
payload.familyCode = (product as any).familyCode
}
if (product.typeProductId) {
payload.typeProductId = product.typeProductId
}
if (product.role) {
payload.role = product.role
}
return payload
}) as any
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => { const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
const payload: Record<string, any> = {} const payload: Record<string, any> = {}
if (subcomponent.typeComposantId) { if (subcomponent.typeComposantId) {
@@ -420,6 +504,7 @@ export const normalizeStructureForSave = (input: any): any => {
const result: ComponentModelStructure = { const result: ComponentModelStructure = {
customFields: backendCustomFields, customFields: backendCustomFields,
pieces: backendPieces, pieces: backendPieces,
products: backendProducts,
subcomponents: backendSubcomponents, subcomponents: backendSubcomponents,
} }
@@ -447,7 +532,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
return [] return []
} }
return fields.map((field) => { return fields.map((field, index) => {
const valueObject = extractFieldValueObject(field) const valueObject = extractFieldValueObject(field)
const name = typeof field?.name === 'string' const name = typeof field?.name === 'string'
? field.name ? field.name
@@ -512,6 +597,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
const id = typeof field?.id === 'string' ? field.id : undefined const id = typeof field?.id === 'string' ? field.id : undefined
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
return { return {
name, name,
@@ -522,6 +608,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
defaultValue, defaultValue,
id, id,
customFieldId, customFieldId,
orderIndex,
} }
}) })
} }
@@ -540,6 +627,20 @@ const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
})) }))
} }
const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
reference: product?.reference ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => { const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
@@ -564,6 +665,7 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure =
return { return {
customFields: hydrateCustomFields(source.customFields), customFields: hydrateCustomFields(source.customFields),
pieces: hydratePieces(source.pieces), pieces: hydratePieces(source.pieces),
products: hydrateProducts(source.products),
subcomponents: hydrateSubcomponents( subcomponents: hydrateSubcomponents(
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents, Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
), ),
@@ -579,7 +681,7 @@ const mapComponentCustomFields = (fields: any[]) => {
if (!Array.isArray(fields)) { if (!Array.isArray(fields)) {
return [] return []
} }
return hydrateCustomFields(fields).map((field) => { return hydrateCustomFields(fields).map((field, index) => {
const defaultValue = const defaultValue =
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== '' field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
? field.defaultValue ? field.defaultValue
@@ -596,6 +698,7 @@ const mapComponentCustomFields = (fields: any[]) => {
typeof (field as any)?.customFieldId === 'string' typeof (field as any)?.customFieldId === 'string'
? (field as any).customFieldId ? (field as any).customFieldId
: undefined, : undefined,
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
} }
}) })
} }
@@ -613,6 +716,19 @@ const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
})) }))
} }
const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
reference: product?.reference ?? '',
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => { const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
@@ -639,6 +755,7 @@ export const extractStructureFromComponent = (component: any) => {
const raw = { const raw = {
customFields: mapComponentCustomFields(component.customFields), customFields: mapComponentCustomFields(component.customFields),
pieces: mapComponentPieces(component.pieces), pieces: mapComponentPieces(component.pieces),
products: mapComponentProducts(component.products),
subcomponents: mapSubcomponents( subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents) Array.isArray(component?.subcomponents)
? component.subcomponents ? component.subcomponents
@@ -656,12 +773,13 @@ export const extractStructureFromComponent = (component: any) => {
export const computeStructureStats = (structure: any): ModelStructurePreview => { export const computeStructureStats = (structure: any): ModelStructurePreview => {
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {
return { customFields: 0, pieces: 0, subcomponents: 0 } return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
} }
return { return {
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0, customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0, pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
products: Array.isArray(structure.products) ? structure.products.length : 0,
subcomponents: Array.isArray(structure.subcomponents) subcomponents: Array.isArray(structure.subcomponents)
? structure.subcomponents.length ? structure.subcomponents.length
: Array.isArray(structure.subComponents) : Array.isArray(structure.subComponents)
@@ -672,13 +790,14 @@ export const computeStructureStats = (structure: any): ModelStructurePreview =>
export const formatStructurePreview = (structure: any) => { export const formatStructurePreview = (structure: any) => {
const stats = computeStructureStats(structure) const stats = computeStructureStats(structure)
if (!stats.customFields && !stats.pieces && !stats.subcomponents) { if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
return 'Structure vide' return 'Structure vide'
} }
const segments: string[] = [] const segments: string[] = []
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`) if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`) if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
if (stats.products) segments.push(`${stats.products} produit(s)`)
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`) if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
return segments.join(' • ') return segments.join(' • ')
} }
@@ -686,7 +805,7 @@ export const formatStructurePreview = (structure: any) => {
export interface DefinitionOverridePayload { export interface DefinitionOverridePayload {
name?: string name?: string
reference?: string reference?: string
constructeurId?: string | null constructeurIds?: string[]
prix?: number prix?: number
} }
@@ -711,8 +830,14 @@ export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverride
} }
} }
if (definition.constructeurId !== undefined && definition.constructeurId !== null && definition.constructeurId !== '') { const constructeurIds = uniqueConstructeurIds(
payload.constructeurId = definition.constructeurId definition.constructeurIds,
definition.constructeurId,
definition.constructeur,
definition.constructeurs,
)
if (constructeurIds.length) {
payload.constructeurIds = constructeurIds
} }
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') { if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
@@ -729,6 +854,10 @@ export const defaultPieceStructure = (): PieceModelStructure => ({
...createEmptyPieceModelStructure(), ...createEmptyPieceModelStructure(),
}) })
export const defaultProductStructure = (): ProductModelStructure => ({
...createEmptyProductModelStructure(),
})
const ensurePieceStructureShape = (input: any): PieceModelStructure => { const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const base = createEmptyPieceModelStructure() const base = createEmptyPieceModelStructure()
if (!isPlainObject(input)) { if (!isPlainObject(input)) {
@@ -738,10 +867,11 @@ const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const clone: PieceModelStructure = { const clone: PieceModelStructure = {
...base, ...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [], customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
} }
for (const [key, value] of Object.entries(input as Record<string, unknown>)) { for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (key === 'customFields') { if (key === 'customFields' || key === 'products') {
continue continue
} }
clone[key] = value clone[key] = value
@@ -759,13 +889,17 @@ export const clonePieceStructure = (input: any): PieceModelStructure => {
} }
} }
export const cloneProductStructure = (input: any): ProductModelStructure => {
return clonePieceStructure(input)
}
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => { const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (!Array.isArray(fields)) { if (!Array.isArray(fields)) {
return [] return []
} }
return fields return fields
.map((field) => { .map((field, index) => {
const name = typeof field?.name === 'string' ? field.name.trim() : '' const name = typeof field?.name === 'string' ? field.name.trim() : ''
if (!name) { if (!name) {
return null return null
@@ -792,17 +926,25 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (options) { if (options) {
result.options = options result.options = options
} }
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result return result
}) })
.filter((field): field is PieceModelCustomField => !!field) .filter((field): field is PieceModelCustomField => !!field)
} }
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
return sanitizeProducts(products) as PieceModelProduct[]
}
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => { export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
const source = clonePieceStructure(input) const source = clonePieceStructure(input)
const restEntries = Object.entries(source).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return { return {
...Object.fromEntries( ...Object.fromEntries(restEntries),
Object.entries(source).filter(([key]) => key !== 'customFields'), products: sanitizePieceProducts(source.products),
),
customFields: sanitizePieceCustomFields(source.customFields), customFields: sanitizePieceCustomFields(source.customFields),
} }
} }
@@ -812,7 +954,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
return [] return []
} }
return fields.map((field) => ({ return fields.map((field, index) => ({
name: field?.name ?? '', name: field?.name ?? '',
type: field?.type ?? 'text', type: field?.type ?? 'text',
required: !!field?.required, required: !!field?.required,
@@ -822,6 +964,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
: Array.isArray(field?.options) : Array.isArray(field?.options)
? field.options.join('\n') ? field.options.join('\n')
: '', : '',
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
})) }))
} }
@@ -829,8 +972,9 @@ export const hydratePieceStructureForEditor = (input: any): PieceModelStructureF
const source = clonePieceStructure(input) const source = clonePieceStructure(input)
const payload: PieceModelStructureForEditor = { const payload: PieceModelStructureForEditor = {
...Object.fromEntries( ...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields'), Object.entries(source).filter(([key]) => key !== 'customFields' && key !== 'products'),
), ),
products: hydrateProducts(source.products) as PieceModelProduct[],
customFields: hydratePieceCustomFields(source.customFields), customFields: hydratePieceCustomFields(source.customFields),
} }
return payload return payload
@@ -844,10 +988,30 @@ export const formatPieceStructurePreview = (structure: any) => {
const customFields = Array.isArray((structure as any).customFields) const customFields = Array.isArray((structure as any).customFields)
? (structure as any).customFields.length ? (structure as any).customFields.length
: 0 : 0
const products = Array.isArray((structure as any).products)
? (structure as any).products.length
: 0
if (!customFields) { if (!customFields && !products) {
return 'Aucun champ personnalisé' return 'Aucun produit ni champ personnalisé'
} }
return `${customFields} champ(s) personnalisé(s)` const segments: string[] = []
if (products) {
segments.push(`${products} produit(s)`)
}
if (customFields) {
segments.push(`${customFields} champ(s) personnalisé(s)`)
}
return segments.join(' · ')
} }
export const normalizeProductStructureForSave = (input: any): ProductModelStructure =>
normalizePieceStructureForSave(input)
export const hydrateProductStructureForEditor = (input: any) =>
hydratePieceStructureForEditor(input)
export const formatProductStructurePreview = (structure: any) =>
formatPieceStructurePreview(structure)
+31
View File
@@ -9,6 +9,7 @@ export interface ComponentModelCustomField {
optionsText?: string optionsText?: string
id?: string id?: string
customFieldId?: string customFieldId?: string
orderIndex?: number
} }
export interface ComponentModelPiece { export interface ComponentModelPiece {
@@ -19,18 +20,28 @@ export interface ComponentModelPiece {
role?: string role?: string
} }
export interface ComponentModelProduct {
typeProductId?: string
typeProductLabel?: string
reference?: string
familyCode?: string
role?: string
}
export interface ComponentModelStructureNode { export interface ComponentModelStructureNode {
typeComposantId?: string typeComposantId?: string
typeComposantLabel?: string typeComposantLabel?: string
modelId?: string modelId?: string
familyCode?: string familyCode?: string
alias?: string alias?: string
products?: ComponentModelProduct[]
subcomponents: ComponentModelStructureNode[] subcomponents: ComponentModelStructureNode[]
} }
export interface ComponentModelStructure extends ComponentModelStructureNode { export interface ComponentModelStructure extends ComponentModelStructureNode {
customFields: ComponentModelCustomField[] customFields: ComponentModelCustomField[]
pieces: ComponentModelPiece[] pieces: ComponentModelPiece[]
products: ComponentModelProduct[]
} }
export type PieceModelCustomFieldType = ComponentModelCustomFieldType export type PieceModelCustomFieldType = ComponentModelCustomFieldType
@@ -40,10 +51,20 @@ export interface PieceModelCustomField {
type: PieceModelCustomFieldType type: PieceModelCustomFieldType
required: boolean required: boolean
options?: string[] options?: string[]
orderIndex?: number
}
export interface PieceModelProduct {
typeProductId?: string
typeProductLabel?: string
reference?: string
familyCode?: string
role?: string
} }
export interface PieceModelStructure { export interface PieceModelStructure {
customFields: PieceModelCustomField[] customFields: PieceModelCustomField[]
products?: PieceModelProduct[]
[key: string]: unknown [key: string]: unknown
} }
@@ -53,9 +74,13 @@ export interface PieceModelStructureEditorField extends PieceModelCustomField {
export interface PieceModelStructureForEditor { export interface PieceModelStructureForEditor {
customFields: PieceModelStructureEditorField[] customFields: PieceModelStructureEditorField[]
products?: PieceModelProduct[]
[key: string]: unknown [key: string]: unknown
} }
export type ProductModelCustomField = PieceModelCustomField
export type ProductModelStructure = PieceModelStructure
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date'] const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const isPlainObject = (value: unknown): value is Record<string, unknown> => { const isPlainObject = (value: unknown): value is Record<string, unknown> => {
@@ -250,9 +275,15 @@ export const componentModelStructureValidator = {
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({ export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
customFields: [], customFields: [],
pieces: [], pieces: [],
products: [],
subcomponents: [], subcomponents: [],
}) })
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({ export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
customFields: [], customFields: [],
products: [],
})
export const createEmptyProductModelStructure = (): ProductModelStructure => ({
customFields: [],
}) })
+2 -2
View File
@@ -1,9 +1,9 @@
import { normalizeEmail } from '~/utils/formatters/email' import { normalizeEmail } from '~/utils/formatters/email'
export const EMAIL_INPUT_PATTERN = '[^\s@]+'
const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const EMAIL_INPUT_PATTERN = EMAIL_VALIDATION_PATTERN.source
export type EmailValidationResult = { export type EmailValidationResult = {
valid: boolean valid: boolean
error?: string error?: string
+1 -1
View File
@@ -1,7 +1,7 @@
import { normalizePhone } from '~/utils/formatters/phone' import { normalizePhone } from '~/utils/formatters/phone'
/** Pattern used for the HTML input `pattern` attribute on phone fields. */ /** Pattern used for the HTML input `pattern` attribute on phone fields. */
export const PHONE_INPUT_PATTERN = '[0-9+ ]*' export const PHONE_INPUT_PATTERN = '[0-9+ .]*'
const PHONE_VALIDATION_PATTERN = /^\+?\d{7,15}$/ const PHONE_VALIDATION_PATTERN = /^\+?\d{7,15}$/
+32 -9
View File
@@ -11,8 +11,8 @@ const PHONE_CHAR_PATTERN = /[^+\d]/g
* Normalises a phone number by trimming whitespace, removing spacing/separators and * Normalises a phone number by trimming whitespace, removing spacing/separators and
* converting international prefixes written with `00` to their `+` variant. * converting international prefixes written with `00` to their `+` variant.
*/ */
export const normalizePhone = (rawValue: string): string => { export const normalizePhone = (rawValue: string | null | undefined): string => {
const trimmed = (rawValue || '').trim() const trimmed = typeof rawValue === 'string' ? rawValue.trim() : ''
if (!trimmed) { if (!trimmed) {
return '' return ''
} }
@@ -26,30 +26,53 @@ export const normalizePhone = (rawValue: string): string => {
} }
/** /**
* Formats a phone number by grouping digits by two while keeping any international * Formats a phone number by grouping digits by two and joining them with dots while
* prefix. The function remains tolerant to partially entered numbers. * keeping any international prefix. The function remains tolerant to partially
* entered numbers and returns an empty string for nullish inputs.
*/ */
export const formatPhone = (rawValue: string): string => { export const formatPhone = (rawValue: string | null | undefined): string => {
if (rawValue == null) {
return ''
}
const normalized = normalizePhone(rawValue) const normalized = normalizePhone(rawValue)
if (!normalized) { if (!normalized) {
return '' return ''
} }
if (normalized.startsWith('+33')) {
let nationalNumber = normalized.slice(3)
if (nationalNumber.startsWith('0')) {
nationalNumber = nationalNumber.slice(1)
}
if (nationalNumber.length % 2 !== 0) {
nationalNumber = `0${nationalNumber}`
}
const groups = nationalNumber.match(/\d{1,2}/g) ?? []
if (groups.length === 0) {
return '+33'
}
return ['+33', ...groups].join('.')
}
const hasInternationalPrefix = normalized.startsWith('+') const hasInternationalPrefix = normalized.startsWith('+')
const prefix = hasInternationalPrefix ? normalized.slice(0, 1) : '' const prefix = hasInternationalPrefix ? normalized.slice(0, 1) : ''
const digits = hasInternationalPrefix ? normalized.slice(1) : normalized const digits = hasInternationalPrefix ? normalized.slice(1) : normalized
const groups = digits.match(/.{1,2}/g) ?? [] const groups = digits.match(/\d{1,2}/g) ?? []
const grouped = groups.join(' ') const grouped = groups.join('.')
return prefix ? `${prefix}${grouped ? ' ' : ''}${grouped}` : grouped return prefix ? `${prefix}${grouped}` : grouped
} }
/** /**
* Masks a phone number for display purposes by replacing the middle digits with ·. * Masks a phone number for display purposes by replacing the middle digits with ·.
* Useful for UI fragments where the full number should not be exposed. * Useful for UI fragments where the full number should not be exposed.
*/ */
export const maskPhone = (rawValue: string): string => { export const maskPhone = (rawValue: string | null | undefined): string => {
const normalized = normalizePhone(rawValue) const normalized = normalizePhone(rawValue)
if (!normalized) { if (!normalized) {
return '' return ''
+225 -37
View File
@@ -1,3 +1,26 @@
import {
uniqueConstructeurIds,
resolveConstructeurs,
formatConstructeurContact,
} from '~/shared/constructeurUtils'
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatCurrency = (value) => {
if (value === undefined || value === null || value === '') {
return null
}
const number = Number(value)
if (Number.isNaN(number)) {
return null
}
return currencyFormatter.format(number)
}
const formatSize = (size) => { const formatSize = (size) => {
if (size === undefined || size === null) { return '—' } if (size === undefined || size === null) { return '—' }
if (size === 0) { return '0 B' } if (size === 0) { return '0 B' }
@@ -49,6 +72,49 @@ const renderPrintDocuments = (documents = [], title, sectionClass = 'print-secti
` `
} }
const renderPrintProductSummary = (product, title = 'Produit catalogue', sectionClass = 'print-piece-section') => {
if (!product) { return '' }
const infoEntries = [
{ label: 'Nom', value: product.name || '—' },
{ label: 'Référence', value: product.reference || '—' },
{ label: 'Catégorie', value: product.typeName || '—' },
{
label: 'Prix indicatif',
value: product.supplierPrice || '—',
},
{
label: 'Fournisseur(s)',
value: product.constructeurs?.length
? product.constructeurs.map((constructeur) => constructeur.name).filter(Boolean).join(', ') || '—'
: '—',
},
]
const infoMarkup = infoEntries
.map((field) => `<div class="print-field"><label>${field.label}</label><span>${field.value || '—'}</span></div>`)
.join('')
const customFieldsBlock = product.customFields?.length
? renderPrintCustomFields(product.customFields, 'Champs personnalisés du produit', 'print-subsection')
: ''
const documentsBlock = product.documents?.length
? renderPrintDocuments(product.documents, 'Documents du produit', 'print-subsection')
: ''
return `
<div class="${sectionClass}">
<h4>${title}</h4>
<div class="print-grid">
${infoMarkup}
</div>
${customFieldsBlock}
${documentsBlock}
</div>
`
}
const renderPrintPieces = ( const renderPrintPieces = (
pieces = [], pieces = [],
title = 'Pièces indépendantes', title = 'Pièces indépendantes',
@@ -59,9 +125,12 @@ const renderPrintPieces = (
const cards = pieces const cards = pieces
.map((piece, idx) => { .map((piece, idx) => {
const indexLabel = piece.indexPath ? piece.indexPath.join('.') : `${idx + 1}` const indexLabel = piece.indexPath ? piece.indexPath.join('.') : `${idx + 1}`
const constructeurBadge = piece.constructeur?.name const constructeurBadges = (piece.constructeurs || [])
? `<span class="print-badge print-badge--subtle">Constructeur: ${piece.constructeur.name}</span>` .map((constructeur, badgeIdx) => {
: '' const suffix = piece.constructeurs.length > 1 ? ` ${badgeIdx + 1}` : ''
return `<span class="print-badge print-badge--subtle">Fournisseur${suffix}: ${constructeur.name}</span>`
})
.join('')
const customFields = (piece.customFields || []) const customFields = (piece.customFields || [])
.filter(field => field.value && field.value !== '—' && field.value !== '') .filter(field => field.value && field.value !== '—' && field.value !== '')
@@ -85,6 +154,8 @@ const renderPrintPieces = (
.join('')}</ul></div>` .join('')}</ul></div>`
: '' : ''
const productBlock = renderPrintProductSummary(piece.product, 'Produit catalogue')
return ` return `
<div class="print-piece-card"> <div class="print-piece-card">
<div class="print-piece-header"> <div class="print-piece-header">
@@ -93,19 +164,27 @@ const renderPrintPieces = (
<div class="print-piece-title">${piece.name}</div> <div class="print-piece-title">${piece.name}</div>
<div class="print-piece-subtitle">${piece.reference || 'Référence non définie'}</div> <div class="print-piece-subtitle">${piece.reference || 'Référence non définie'}</div>
</div> </div>
${constructeurBadge} ${constructeurBadges}
</div> </div>
${piece.description ? `<p class="print-piece-description">${piece.description}</p>` : ''} ${piece.description ? `<p class="print-piece-description">${piece.description}</p>` : ''}
<div class="print-piece-meta"> <div class="print-piece-meta">
<div class="print-field-mini"> <div class="print-field-mini">
<label>Constructeur</label> <label>Fournisseur(s)</label>
<span>${piece.constructeur?.name || '—'}</span> <span>${piece.constructeurs?.length
? piece.constructeurs.map(constructeur => constructeur.name).join(', ')
: '—'}</span>
</div> </div>
<div class="print-field-mini"> <div class="print-field-mini">
<label>Contact</label> <label>Contact(s)</label>
<span>${piece.constructeur?.contact || '—'}</span> <span>${piece.constructeurs?.length
? piece.constructeurs
.map(constructeur => constructeur.contact)
.filter(Boolean)
.join(' • ') || '—'
: '—'}</span>
</div> </div>
</div> </div>
${productBlock}
${customFieldsBlock} ${customFieldsBlock}
${documentsBlock} ${documentsBlock}
</div> </div>
@@ -128,12 +207,17 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
return components return components
.map((component, idx) => { .map((component, idx) => {
const badges = [] const badges = []
if (component.constructeur?.name) { if (component.constructeurs?.length) {
badges.push(`Constructeur: ${component.constructeur.name}`) const label = component.constructeurs.map((constructeur, badgeIdx) => {
const suffix = component.constructeurs.length > 1 ? ` ${badgeIdx + 1}` : ''
return `Constructeur${suffix}: ${constructeur.name}`
})
badges.push(...label)
} }
const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}` const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}`
const currentIndex = [...indexPath, idx + 1] const currentIndex = [...indexPath, idx + 1]
const indexLabel = currentIndex.join('.') const indexLabel = currentIndex.join('.')
const productBlock = renderPrintProductSummary(component.product, 'Produit catalogue', 'print-section print-subsection print-section--product')
return ` return `
<div class="${sectionClass}"> <div class="${sectionClass}">
<h3> <h3>
@@ -142,6 +226,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
</h3> </h3>
${component.description ? `<p class="print-muted">${component.description}</p>` : ''} ${component.description ? `<p class="print-muted">${component.description}</p>` : ''}
${badges.length ? `<div class="badge-group">${badges.map(badge => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''} ${badges.length ? `<div class="badge-group">${badges.map(badge => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''}
${productBlock}
${renderPrintCustomFields( ${renderPrintCustomFields(
component.customFields, component.customFields,
'Champs personnalisés', 'Champs personnalisés',
@@ -183,33 +268,115 @@ const normalizeCustomFields = (values = []) => {
const normalizeConstructeur = (constructeur) => { const normalizeConstructeur = (constructeur) => {
if (!constructeur) { return null } if (!constructeur) { return null }
const contact = formatConstructeurContact(constructeur)
return { return {
id: constructeur.id || null,
name: constructeur.name || '—', name: constructeur.name || '—',
contact: [constructeur.email, constructeur.phone].filter(Boolean).join(' • ') || '—' contact: contact || '—'
} }
} }
const normalizePiece = piece => ({ const normalizeConstructeurList = (...sources) => {
id: piece.id, const ids = uniqueConstructeurIds(...sources)
name: piece.name || 'Pièce sans nom', const pools = sources
description: piece.description || '', .flatMap((source) => {
reference: piece.reference || '', if (Array.isArray(source)) {
customFields: normalizeCustomFields(piece.customFieldValues || []), if (source.length && typeof source[0] === 'object') {
documents: normalizeDocuments(piece.documents || []), return [source]
constructeur: normalizeConstructeur(piece.constructeur), }
indexPath: piece.indexPath || null return []
}) }
if (source && typeof source === 'object' && 'id' in source) {
return [[source]]
}
return []
})
.filter(Boolean)
const resolved = resolveConstructeurs(ids, ...pools)
return resolved
.map(normalizeConstructeur)
.filter(Boolean)
}
const normalizeComponent = component => ({ const normalizeProduct = (product) => {
id: component.id, if (!product) { return null }
name: component.name || 'Composant sans nom', const constructeurs = normalizeConstructeurList(
description: component.description || '', product.constructeurs,
customFields: normalizeCustomFields(component.customFieldValues || []), product.constructeur,
documents: normalizeDocuments(component.documents || []), product.constructeurIds,
pieces: (component.pieces || []).map(normalizePiece), product.constructeurId,
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent), )
constructeur: normalizeConstructeur(component.constructeur) return {
}) id: product.id || null,
name: product.name || 'Produit sans nom',
reference: product.reference || '',
supplierPrice: formatCurrency(product.supplierPrice),
typeName: product.typeProduct?.name || null,
constructeurs,
customFields: normalizeCustomFields(product.customFieldValues || []),
documents: normalizeDocuments(product.documents || []),
}
}
const normalizePiece = piece => {
const rawProduct = piece.product || null
const constructeurs = normalizeConstructeurList(
piece.constructeurs,
piece.constructeur,
piece.originalPiece?.constructeurs,
piece.originalPiece?.constructeur,
piece.constructeurIds,
piece.constructeurId,
rawProduct?.constructeurs,
rawProduct?.constructeur,
rawProduct?.constructeurIds,
rawProduct?.constructeurId,
)
const product = normalizeProduct(rawProduct)
return {
id: piece.id,
name: piece.name || 'Pièce sans nom',
description: piece.description || '',
reference: piece.reference || '',
customFields: normalizeCustomFields(piece.customFieldValues || []),
documents: normalizeDocuments(piece.documents || []),
constructeurs,
constructeur: constructeurs[0] || null,
product,
indexPath: piece.indexPath || null
}
}
const normalizeComponent = component => {
const rawProduct = component.product || null
const constructeurs = normalizeConstructeurList(
component.constructeurs,
component.constructeur,
component.originalComposant?.constructeurs,
component.originalComposant?.constructeur,
component.constructeurIds,
component.constructeurId,
rawProduct?.constructeurs,
rawProduct?.constructeur,
rawProduct?.constructeurIds,
rawProduct?.constructeurId,
)
const product = normalizeProduct(rawProduct)
return {
id: component.id,
name: component.name || 'Composant sans nom',
description: component.description || '',
customFields: normalizeCustomFields(component.customFieldValues || []),
documents: normalizeDocuments(component.documents || []),
pieces: (component.pieces || []).map(normalizePiece),
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
constructeurs,
constructeur: constructeurs[0] || null,
product,
}
}
export const buildMachinePrintContext = ({ export const buildMachinePrintContext = ({
machine, machine,
@@ -255,6 +422,24 @@ export const buildMachinePrintContext = ({
machineBadges.push(`Ref: ${machineReference}`) machineBadges.push(`Ref: ${machineReference}`)
} }
const machineConstructeurs = normalizeConstructeurList(
machine?.constructeurs,
machine?.constructeur,
machine?.constructeurIds,
machine?.constructeurId,
)
const machineConstructeurNames = machineConstructeurs.length
? machineConstructeurs.map((constructeur) => constructeur.name).join(', ')
: ''
const machineConstructeurContacts = machineConstructeurs.length
? machineConstructeurs
.map((constructeur) => constructeur.contact)
.filter(Boolean)
.join(' • ')
: ''
const normalizedPieces = machinePieces const normalizedPieces = machinePieces
.map(normalizePiece) .map(normalizePiece)
.filter(piece => isPieceSelected(piece.id)) .filter(piece => isPieceSelected(piece.id))
@@ -300,7 +485,10 @@ export const buildMachinePrintContext = ({
site: machine?.site?.name || '', site: machine?.site?.name || '',
category: machine?.typeMachine?.category || '', category: machine?.typeMachine?.category || '',
badges: machineBadges, badges: machineBadges,
constructeur: normalizeConstructeur(machine?.constructeur), constructeurs: machineConstructeurs,
constructeur: machineConstructeurs[0] || null,
constructeurNames: machineConstructeurNames,
constructeurContacts: machineConstructeurContacts,
includeInfo: includeMachineInfo, includeInfo: includeMachineInfo,
customFields: includeMachineCustomFields customFields: includeMachineCustomFields
? normalizeCustomFields(machine?.customFieldValues || []) ? normalizeCustomFields(machine?.customFieldValues || [])
@@ -342,11 +530,11 @@ export const buildMachinePrintHtml = (context, styles) => {
<div class="print-section print-section--machine"> <div class="print-section print-section--machine">
<h3>Informations générales</h3> <h3>Informations générales</h3>
<div class="print-grid"> <div class="print-grid">
${renderPrintField('Nom', context.machine.name)} ${renderPrintField('Nom', context.machine.name)}
${renderPrintField('Référence', context.machine.reference, 'Non définie')} ${renderPrintField('Référence', context.machine.reference, 'Non définie')}
${renderPrintField('Site', context.machine.site, 'Non défini')} ${renderPrintField('Site', context.machine.site, 'Non défini')}
${renderPrintField('Constructeur', context.machine.constructeur?.name, 'Non défini')} ${renderPrintField('Constructeur(s)', context.machine.constructeurNames, 'Non défini')}
${renderPrintField('Contact Constructeur', context.machine.constructeur?.contact, 'Non défini')} ${renderPrintField('Contact(s) Constructeur(s)', context.machine.constructeurContacts, 'Non défini')}
</div> </div>
</div> </div>
`) `)