34 Commits

Author SHA1 Message Date
Matthieu
a1d15c23a4 feat(history): add audit history views for products, pieces and components 2026-01-25 21:20:14 +01:00
Matthieu
a7101c7e77 feat(model-types): add related-items modal and guard category edits 2026-01-25 20:29:28 +01:00
Matthieu
adccfa9b46 fix : use name parameter for API search filter 2026-01-25 15:53:57 +01:00
Matthieu
5f54acdfac chore : merge migration-to-symfony into master for v1.0.0 2026-01-25 12:06:59 +01:00
Matthieu
94239031d6 feat: add version system from parent VERSION file 2026-01-25 12:01:26 +01:00
Matthieu
b27662d2bc Show component selections and support multi product requirements 2026-01-25 11:40:29 +01:00
Matthieu
55739fe50f Fix machines display on overview; disable inline PDF thumbnails 2026-01-25 09:46:11 +01:00
Matthieu
1f5f1509a9 wip: machine create skeleton links 2026-01-24 00:58:06 +01:00
Matthieu
a8cb4d1ac0 wip: dynamic search for component create 2026-01-23 23:29:40 +01:00
8af8374282 feat(ui): ajoute la pagination et la recherche serveur 2026-01-23 19:35:00 +01:00
9cc7ac10f0 WIP: corrections multiples formulaires et sérialisation
- Fix constructeurUtils: réordonner delete/add pour sauvegarder les fournisseurs
- Fix prix/supplierPrice: envoyer en string pour DECIMAL Doctrine
- Fix useMachineTypesApi: normaliser les requirements et forceRefresh
- Fix SearchSelect: watch deep sur baseOptions
- Debug logs temporaires pour pieceRequirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:28:40 +01:00
Matthieu
86d15faa01 fix: add missing template tag and preserve constructeurIds
- Fix missing <template> tag in product/create.vue causing build error
- Preserve constructeurIds when product already has constructeurs loaded

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 11:48:56 +01:00
Matthieu
603c03ca00 fix(frontend): handle supplier price parsing in edit 2026-01-21 18:11:09 +01:00
Matthieu
155cd9b358 fix(frontend): handle supplier price parsing 2026-01-21 18:02:52 +01:00
2f3d4c5260 chore(dev): exposer le serveur nuxt 2026-01-15 13:43:28 +01:00
51edd7f655 fix(machines): enrichir les relations 2026-01-15 13:43:23 +01:00
2e4d61c3ea fix(modeles): normaliser structure et champs perso 2026-01-15 13:43:18 +01:00
52f75c5301 fix(modeles): paginer apres filtre categorie 2026-01-15 12:51:30 +01:00
84048bf3a2 fix(modeles): filtrer par categorie 2026-01-14 23:10:42 +01:00
0bfb69ad13 fix(fournisseurs): résoudre les IRIs 2026-01-14 23:10:34 +01:00
ddce3ff3ae feat(tri): mémoriser les préférences de tri 2026-01-14 23:10:27 +01:00
b5af7f13b6 wip(frontend) : api calls + skeleton fetch 2026-01-12 13:14:12 +01:00
e99f053233 feat(front): aligner api platform et sessions [INV-20260111-02] 2026-01-11 17:14:24 +01:00
Matthieu
936a73fde3 Fix fournisseur handling across catalog flows 2025-12-03 11:29:11 +01:00
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
76 changed files with 11609 additions and 982 deletions

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>

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,118 @@
</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"
:initial-options="componentConstructeursDisplay"
@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 +444,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 +525,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 +660,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 +712,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 +753,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 +772,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 +813,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 +854,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 +879,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 +975,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 +1022,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) {

View File

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

View File

@@ -17,12 +17,13 @@
<SearchSelect <SearchSelect
:model-value="assignment.selectedComponentId || ''" :model-value="assignment.selectedComponentId || ''"
:options="componentOptions" :options="componentOptions"
:loading="componentsLoading" :loading="componentsLoading || componentLoadingByPath[assignment.path]"
size="sm" size="sm"
placeholder="Rechercher un composant..." placeholder="Rechercher un composant..."
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'" :empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel" :option-label="componentOptionLabel"
:option-description="componentOptionDescription" :option-description="componentOptionDescription"
@search="fetchComponentOptions"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/> />
</div> </div>
@@ -45,27 +46,67 @@
> >
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs font-medium text-base-content"> <p class="text-xs font-medium text-base-content">
{{ describePieceRequirement(pieceAssignment.definition) }} {{ describePieceRequirement(pieceAssignment) }}
</p> </p>
<p v-if="!getPieceOptions(pieceAssignment.definition).length" class="text-[11px] text-error"> <p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
Aucune pièce disponible pour cette famille. Aucune pièce disponible pour cette famille.
</p> </p>
</div> </div>
<SearchSelect <SearchSelect
:model-value="pieceAssignment.selectedPieceId || ''" :model-value="pieceAssignment.selectedPieceId || ''"
:options="getPieceOptions(pieceAssignment.definition)" :options="getPieceOptions(pieceAssignment)"
:loading="piecesLoading" :loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
size="xs" size="xs"
placeholder="Rechercher une pièce..." placeholder="Rechercher une pièce..."
:empty-text="getPieceOptions(pieceAssignment.definition).length ? 'Aucun résultat' : 'Aucune pièce disponible'" :empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:option-label="pieceOptionLabel" :option-label="pieceOptionLabel"
:option-description="pieceOptionDescription" :option-description="pieceOptionDescription"
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/> />
</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) }}
</p>
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
Aucun produit disponible pour cette catégorie.
</p>
</div>
<SearchSelect
:model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment)"
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
size="xs"
placeholder="Rechercher un produit..."
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel"
:option-description="productOptionDescription"
@search="(term) => fetchProductOptions(productAssignment, term)"
@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 +122,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>
@@ -91,10 +134,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue'; import SearchSelect from '~/components/common/SearchSelect.vue';
import { useApi } from '~/composables/useApi';
import type { import type {
ComponentModelPiece, ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode, ComponentModelStructureNode,
} from '~/shared/types/inventory'; } from '~/shared/types/inventory';
@@ -122,17 +167,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 +204,27 @@ 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;
pieceTypeLabelMap?: Record<string, string>;
productTypeLabelMap?: Record<string, string>;
componentTypeLabelMap?: Record<string, string>;
}>(), }>(),
{ {
depth: 0, depth: 0,
pieces: () => [], pieces: () => [],
products: () => [],
components: () => [], components: () => [],
componentsLoading: false, componentsLoading: false,
piecesLoading: false, piecesLoading: false,
productsLoading: false,
pieceTypeLabelMap: () => ({}),
productTypeLabelMap: () => ({}),
componentTypeLabelMap: () => ({}),
}, },
); );
@@ -161,10 +235,42 @@ const wrapperClass = computed(() =>
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4', depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
); );
const { get } = useApi();
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
const pieceLoadingByPath = ref<Record<string, boolean>>({});
const productLoadingByPath = ref<Record<string, boolean>>({});
const componentLoadingByPath = ref<Record<string, boolean>>({});
const extractCollection = (payload: any): any[] => {
if (Array.isArray(payload)) {
return payload;
}
if (Array.isArray(payload?.member)) {
return payload.member;
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member'];
}
if (Array.isArray(payload?.data)) {
return payload.data;
}
return [];
};
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
target[key] = value;
};
const componentOptions = computed(() => { const componentOptions = computed(() => {
if (isRoot.value) { if (isRoot.value) {
return []; return [];
} }
const cached = componentOptionsByPath.value[props.assignment.path];
if (cached) {
return cached;
}
const definition = props.assignment.definition || {}; const definition = props.assignment.definition || {};
const requiredTypeId = const requiredTypeId =
definition.typeComposantId || definition.modelId || null; definition.typeComposantId || definition.modelId || null;
@@ -210,6 +316,104 @@ const componentOptionDescription = (component?: ComponentOption | null) => {
return parts.join(' • '); return parts.join(' • ');
}; };
const typeIri = (id: string) => `/api/model_types/${id}`;
const primedPiecePaths = new Set<string>();
const primedProductPaths = new Set<string>();
const primedComponentPaths = new Set<string>();
const fetchComponentOptions = async (term = '') => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (componentLoadingByPath.value[key]) {
return;
}
const definition = props.assignment.definition || {};
const requiredTypeId =
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeComposant', typeIri(requiredTypeId));
}
setLoading(componentLoadingByPath.value, key, true);
try {
const result = await get(`/composants?${params.toString()}`);
if (result.success) {
componentOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(componentLoadingByPath.value, key, false);
}
};
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
const key = assignment.path;
if (pieceLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typePieceId || (definition as any).typePiece?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typePiece', typeIri(requiredTypeId));
}
setLoading(pieceLoadingByPath.value, key, true);
try {
const result = await get(`/pieces?${params.toString()}`);
if (result.success) {
pieceOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(pieceLoadingByPath.value, key, false);
}
};
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
const key = assignment.path;
if (productLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typeProductId || (definition as any).typeProduct?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeProduct', typeIri(requiredTypeId));
}
setLoading(productLoadingByPath.value, key, true);
try {
const result = await get(`/products?${params.toString()}`);
if (result.success) {
productOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(productLoadingByPath.value, key, false);
}
};
watch( watch(
componentOptions, componentOptions,
(options) => { (options) => {
@@ -226,7 +430,8 @@ watch(
{ immediate: true }, { immediate: true },
); );
const describePieceRequirement = (definition: ComponentModelPiece) => { const describePieceRequirement = (assignment: StructurePieceAssignment) => {
const definition = assignment.definition;
const parts: string[] = []; const parts: string[] = [];
const addPart = (value?: string | null) => { const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : ''; const trimmed = typeof value === 'string' ? value.trim() : '';
@@ -235,16 +440,17 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
} }
}; };
const options = getPieceOptions(definition); const options = getPieceOptions(assignment);
const fallbackPiece = options[0] || null; const fallbackPiece = options[0] || null;
const fallbackType = fallbackPiece?.typePiece || null; const fallbackType = fallbackPiece?.typePiece || null;
addPart(definition.role); addPart(definition.role);
addPart( const explicitLabel =
definition.typePieceLabel || definition.typePieceLabel ||
(definition as any).typePiece?.name || (definition as any).typePiece?.name ||
fallbackType?.name, (definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
); fallbackType?.name;
addPart(explicitLabel);
const family = const family =
definition.familyCode || definition.familyCode ||
@@ -269,12 +475,118 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
return parts.length ? parts.join(' • ') : 'Pièce du squelette'; return parts.length ? parts.join(' • ') : 'Pièce du squelette';
}; };
const getProductOptions = (assignment: StructureProductAssignment) => {
const cached = productOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
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 = (assignment: StructureProductAssignment) => {
const definition = assignment.definition;
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(assignment);
const fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role);
const explicitLabel =
definition.typeProductLabel ||
(definition as any).typeProduct?.name ||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
fallbackType?.name;
addPart(explicitLabel);
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;
if (alias) { if (alias) {
return alias; return alias;
} }
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
return props.componentTypeLabelMap[definition.typeComposantId];
}
if (definition.typeComposant?.name) { if (definition.typeComposant?.name) {
return definition.typeComposant.name; return definition.typeComposant.name;
} }
@@ -288,6 +600,7 @@ const requirementDescription = computed(() => {
const definition = props.assignment.definition || {}; const definition = props.assignment.definition || {};
const family = const family =
definition.typeComposantLabel || definition.typeComposantLabel ||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
definition.typeComposant?.name || definition.typeComposant?.name ||
definition.familyCode; definition.familyCode;
if (family) { if (family) {
@@ -296,7 +609,12 @@ const requirementDescription = computed(() => {
return 'Sélectionnez un composant enfant conforme à cette position.'; return 'Sélectionnez un composant enfant conforme à cette position.';
}); });
const getPieceOptions = (definition: ComponentModelPiece) => { const getPieceOptions = (assignment: StructurePieceAssignment) => {
const cached = pieceOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
const requiredTypeId = const requiredTypeId =
definition.typePieceId || definition.typePieceId ||
(definition as any).typePiece?.id || (definition as any).typePiece?.id ||
@@ -366,15 +684,54 @@ watch(
() => [props.pieces, props.assignment.pieces], () => [props.pieces, props.assignment.pieces],
() => { () => {
for (const pieceAssignment of props.assignment.pieces) { for (const pieceAssignment of props.assignment.pieces) {
const options = getPieceOptions(pieceAssignment.definition); const options = getPieceOptions(pieceAssignment);
if ( if (
pieceAssignment.selectedPieceId && pieceAssignment.selectedPieceId &&
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId) !options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
) { ) {
pieceAssignment.selectedPieceId = ''; pieceAssignment.selectedPieceId = '';
} }
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
primedPiecePaths.add(pieceAssignment.path);
fetchPieceOptions(pieceAssignment).catch(() => {});
}
} }
}, },
{ 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);
if (
productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = '';
}
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path);
fetchProductOptions(productAssignment).catch(() => {});
}
}
},
{ deep: true, immediate: true },
);
watch(
() => props.assignment.definition,
() => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
primedComponentPaths.add(key);
fetchComponentOptions().catch(() => {});
}
},
{ immediate: true },
);
</script> </script>

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,153 @@
</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...',
} },
initialOptions: {
type: Array as PropType<ConstructeurSummary[]>,
default: () => [],
},
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const { constructeurs, searchConstructeurs, createConstructeur } = useConstructeurs() const {
constructeurs,
searchConstructeurs,
createConstructeur,
ensureConstructeurs,
} = 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 normalizedInitialOptions = computed(() =>
uniqueOptions((props.initialOptions as ConstructeurSummary[]) || []),
)
const applyOptions = (items: ConstructeurSummary[] = []) => {
const normalized = uniqueOptions([
...normalizedInitialOptions.value,
...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([
...normalizedInitialOptions.value,
...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>()
) normalizedInitialOptions.value.forEach((item) => {
map.set(item.id, item)
})
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 +269,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 +289,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 +311,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 +342,50 @@ 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) {
const fetched = await ensureConstructeurs(ids)
if (fetched.length) {
applyOptions([...options.value, ...fetched])
}
}
},
{ immediate: true },
)
watch(
constructeurs,
(list) => {
applyOptions((list as ConstructeurSummary[]) || [])
if (!searchTerm.value) {
lastSearchTerm = ''
}
},
{ immediate: true },
)
watch(
normalizedInitialOptions,
() => {
applyOptions(options.value)
},
{ immediate: true },
)
onMounted(() => { onMounted(() => {
window.addEventListener('click', clickHandler) window.addEventListener('click', clickHandler)
ensureOptionsLoaded() ensureOptionsLoaded()
@@ -261,6 +393,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>

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({})

View File

@@ -12,12 +12,6 @@
loading="lazy" loading="lazy"
decoding="async" decoding="async"
> >
<iframe
v-else-if="canRenderPdf"
:src="previewSrc"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component <component
v-else v-else
:is="icon.component" :is="icon.component"
@@ -54,8 +48,6 @@ const props = defineProps<{
alt?: string; alt?: string;
}>(); }>();
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
const normalizedDocument = computed(() => props.document ?? null); const normalizedDocument = computed(() => props.document ?? null);
const canRenderImage = computed(() => { const canRenderImage = computed(() => {
@@ -64,14 +56,9 @@ const canRenderImage = computed(() => {
}); });
const canRenderPdf = computed(() => { const canRenderPdf = computed(() => {
const doc = normalizedDocument.value; // Rendering many PDF iframes in a list is very heavy for the browser.
if (!doc || !isPdfDocument(doc) || !doc.path) { // We intentionally disable inline PDF previews and fall back to an icon.
return false; return false;
}
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) {
return false;
}
return true;
}); });
const appendPdfViewerParams = (src: string) => { const appendPdfViewerParams = (src: string) => {

View File

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

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,35 @@
}}</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"
:initial-options="pieceConstructeursDisplay"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange" @update:model-value="handleConstructeurChange"
/> />
</div> </div>
@@ -102,6 +120,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 +485,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 +495,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 +524,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 +580,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 +626,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 +674,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 +693,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 +740,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 +793,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 +817,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 +902,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 +1416,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,
}); });
}; };

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>

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>

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],
() => { () => {

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>

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,45 @@ 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 normalizeCustomField = (field = {}, index = 0) => {
const clone = deepClone(field)
if (clone.type === 'select') {
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
if (Array.isArray(clone.options)) {
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
} else {
clone.optionsText = ''
}
}
}
const currentOrder =
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
clone.orderIndex = currentOrder
if (typeof clone?.__key !== 'string' || !clone.__key) {
clone.__key = createFieldKey()
}
return clone
}
const withNormalizedOrder = (items = []) => {
if (!Array.isArray(items)) { return [] }
return items
.map((item, index) => normalizeCustomField(item, index))
.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))

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted, watch } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue' import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
@@ -91,5 +91,11 @@ onMounted(async () => {
if (!pieceTypes.value.length) { if (!pieceTypes.value.length) {
await loadPieceTypes() await loadPieceTypes()
} }
console.log('[PieceRequirementsSection] pieceTypes loaded:', pieceTypes.value.map(t => ({ id: t.id, name: t.name })))
console.log('[PieceRequirementsSection] requirements on mount:', props.modelValue.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
}) })
watch(() => props.modelValue, (newVal) => {
console.log('[PieceRequirementsSection] requirements updated:', newVal.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
}, { deep: true })
</script> </script>

View File

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

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>

View File

@@ -0,0 +1,128 @@
<template>
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(1)"
>
<IconLucideChevronFirst class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
<IconLucideChevronLeft class="w-4 h-4" />
</button>
<template v-for="page in visiblePages" :key="page">
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
<button
v-else
type="button"
class="btn btn-sm"
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
@click="goToPage(page)"
>
{{ page }}
</button>
</template>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
<IconLucideChevronRight class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(totalPages)"
>
<IconLucideChevronLast class="w-4 h-4" />
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalPages: {
type: Number,
required: true
},
maxVisiblePages: {
type: Number,
default: 5
}
})
const emit = defineEmits(['update:currentPage'])
const visiblePages = computed(() => {
const pages = []
const total = props.totalPages
const current = props.currentPage
const maxVisible = props.maxVisiblePages
if (total <= maxVisible + 2) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
return pages
}
// Always show first page
pages.push(1)
const half = Math.floor(maxVisible / 2)
let start = Math.max(2, current - half)
let end = Math.min(total - 1, current + half)
// Adjust if near start
if (current <= half + 1) {
end = maxVisible
}
// Adjust if near end
if (current >= total - half) {
start = total - maxVisible + 1
}
if (start > 2) {
pages.push('ellipsis-start')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < total - 1) {
pages.push('ellipsis-end')
}
// Always show last page
pages.push(total)
return pages
})
const goToPage = (page) => {
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
emit('update:currentPage', page)
}
}
</script>

View File

@@ -122,7 +122,7 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue', 'search'])
const searchTerm = ref('') const searchTerm = ref('')
const openDropdown = ref(false) const openDropdown = ref(false)
@@ -184,11 +184,13 @@ watch(
watch( watch(
baseOptions, baseOptions,
() => { (newOptions) => {
if (!openDropdown.value) { console.log('[SearchSelect] baseOptions changed, count:', newOptions.length, 'modelValue:', props.modelValue, 'selectedOption:', selectedOption.value?.id)
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : searchTerm.value if (!openDropdown.value && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
} }
} },
{ deep: true }
) )
watch(openDropdown, (isOpen) => { watch(openDropdown, (isOpen) => {
@@ -265,6 +267,7 @@ function handleInput () {
if (!openDropdown.value) { if (!openDropdown.value) {
openDropdown.value = true openDropdown.value = true
} }
emit('search', searchTerm.value)
} }
function closeDropdown () { function closeDropdown () {

View File

@@ -29,10 +29,65 @@
:total="total" :total="total"
:limit="limit" :limit="limit"
:offset="offset" :offset="offset"
@related="openRelatedModal"
@edit="openEditPage" @edit="openEditPage"
@delete="confirmDelete" @delete="confirmDelete"
@update:offset="onOffsetChange" @update:offset="onOffsetChange"
/> />
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
<div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold text-base-content">
{{ relatedModalTitle }}
</h3>
<p class="mt-1 text-sm text-base-content/70">
{{ relatedModalSubtitle }}
</p>
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement des éléments liés
</div>
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
{{ relatedError }}
</div>
<div
v-else-if="relatedItems.length === 0"
class="px-4 py-6 text-sm text-base-content/60"
>
Aucun élément lié à cette catégorie.
</div>
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
<li
v-for="entry in relatedItems"
:key="entry.id"
class="px-2 py-1"
>
<button
type="button"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="openRelatedEdit(entry)"
>
<span class="font-medium text-base-content">{{ entry.name }}</span>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</button>
</li>
</ul>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeRelatedModal">
Fermer
</button>
</div>
</div>
</dialog>
</main> </main>
</template> </template>
@@ -41,6 +96,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useHead, useRouter } from "#imports"; import { useHead, useRouter } from "#imports";
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue"; import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
import ModelTypesTable from "~/components/model-types/Table.vue"; import ModelTypesTable from "~/components/model-types/Table.vue";
import { useApi } from "~/composables/useApi";
import { import {
deleteModelType, deleteModelType,
listModelTypes, listModelTypes,
@@ -51,7 +107,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<{
@@ -82,6 +138,7 @@ let activeController: AbortController | null = null;
const router = useRouter(); const router = useRouter();
const { showError, showSuccess } = useToast(); const { showError, showSuccess } = useToast();
const { get } = useApi();
const headingText = computed(() => props.heading); const headingText = computed(() => props.heading);
const descriptionText = computed( const descriptionText = computed(
@@ -210,8 +267,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);
@@ -250,6 +314,165 @@ const confirmDelete = async (item: ModelType) => {
} }
}; };
type RelatedEntry = {
id: string;
name: string;
reference?: string | null;
};
const relatedModalOpen = ref(false);
const relatedLoading = ref(false);
const relatedError = ref<string | null>(null);
const relatedItems = ref<RelatedEntry[]>([]);
const relatedType = ref<ModelType | null>(null);
const relatedCategoryLabels: Record<
ModelCategory,
{ plural: string; singular: string }
> = {
COMPONENT: { plural: "composants", singular: "composant" },
PIECE: { plural: "pièces", singular: "pièce" },
PRODUCT: { plural: "produits", singular: "produit" },
};
const relatedModalTitle = computed(() => {
const current = relatedType.value;
if (!current) {
return "Éléments liés";
}
return `Éléments liés à « ${current.name} »`;
});
const relatedModalSubtitle = computed(() => {
const current = relatedType.value;
if (!current) {
return "";
}
const labels =
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
const count = relatedItems.value.length;
if (relatedLoading.value) {
return `Chargement des ${labels.plural}`;
}
if (count === 0) {
return `Aucun ${labels.singular} lié.`;
}
if (count === 1) {
return `1 ${labels.singular} lié.`;
}
return `${count} ${labels.plural} liés.`;
});
const extractCollection = (payload: any): any[] => {
if (Array.isArray(payload)) {
return payload;
}
if (Array.isArray(payload?.member)) {
return payload.member;
}
if (Array.isArray(payload?.["hydra:member"])) {
return payload["hydra:member"];
}
if (Array.isArray(payload?.items)) {
return payload.items;
}
return [];
};
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === "COMPONENT") {
return { endpoint: "/composants", filterKey: "typeComposant" };
}
if (category === "PIECE") {
return { endpoint: "/pieces", filterKey: "typePiece" };
}
return { endpoint: "/products", filterKey: "typeProduct" };
};
const resolveRelatedEditBasePath = (category: ModelCategory) => {
if (category === "COMPONENT") {
return "/component";
}
if (category === "PIECE") {
return "/pieces";
}
return "/product";
};
const mapRelatedEntry = (item: any): RelatedEntry | null => {
if (!item || typeof item !== "object" || typeof item.id !== "string") {
return null;
}
const name =
typeof item.name === "string" && item.name.trim()
? item.name
: "Sans nom";
const reference =
typeof item.reference === "string" && item.reference.trim()
? item.reference
: typeof item.code === "string" && item.code.trim()
? item.code
: null;
return {
id: item.id,
name,
reference,
};
};
const loadRelatedItems = async (item: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
const params = new URLSearchParams();
params.set("itemsPerPage", "200");
params.set(filterKey, buildModelTypeIri(item.id));
params.set("order[name]", "asc");
relatedLoading.value = true;
relatedError.value = null;
relatedItems.value = [];
try {
const result = await get(`${endpoint}?${params.toString()}`);
if (!result.success) {
relatedError.value =
result.error ?? "Impossible de charger les éléments liés.";
return;
}
const collection = extractCollection(result.data);
relatedItems.value = collection
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry));
} catch (error) {
relatedError.value = extractErrorMessage(error);
} finally {
relatedLoading.value = false;
}
};
const openRelatedModal = (item: ModelType) => {
relatedType.value = item;
relatedModalOpen.value = true;
void loadRelatedItems(item);
};
const openRelatedEdit = (entry: RelatedEntry) => {
const current = relatedType.value;
if (!current) {
return;
}
const basePath = resolveRelatedEditBasePath(current.category);
relatedModalOpen.value = false;
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
showError("Navigation impossible vers la fiche d'édition.");
});
};
const closeRelatedModal = () => {
relatedModalOpen.value = false;
};
watch( watch(
() => searchInput.value, () => searchInput.value,
(value) => { (value) => {

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,9 +94,29 @@
</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>
<div
v-if="disableSubmit"
class="alert alert-warning"
role="alert"
aria-live="polite"
>
<span>{{ disableSubmitMessage }}</span>
</div>
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end"> <footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')"> <button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler Annuler
@@ -114,12 +135,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'
@@ -134,6 +159,8 @@ const props = withDefaults(defineProps<{
structureLoading?: boolean structureLoading?: boolean
allowComponentSubcomponents?: boolean allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number componentSubcomponentMaxDepth?: number
disableSubmit?: boolean
disableSubmitMessage?: string
}>(), { }>(), {
initialData: null, initialData: null,
saving: false, saving: false,
@@ -141,6 +168,8 @@ const props = withDefaults(defineProps<{
structureLoading: false, structureLoading: false,
allowComponentSubcomponents: true, allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1, componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -157,6 +186,12 @@ const componentSubcomponentMaxDepth = computed(() =>
? props.componentSubcomponentMaxDepth ? props.componentSubcomponentMaxDepth
: 1, : 1,
) )
const disableSubmit = computed(() => props.disableSubmit === true)
const disableSubmitMessage = computed(() =>
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
)
const form = reactive<ModelTypePayload>({ const form = reactive<ModelTypePayload>({
name: '', name: '',
@@ -171,6 +206,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 +232,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(),
) )
} }
@@ -222,7 +267,7 @@ const resetForm = () => {
} }
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer')) const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value) const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
const validate = () => { const validate = () => {
errors.name = undefined errors.name = undefined
@@ -263,10 +308,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 +365,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())

View File

@@ -33,20 +33,21 @@
<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-48 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<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>
</td> </td>
<td class="text-right space-x-2"> <td class="text-right space-x-2">
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
@@ -74,6 +75,9 @@
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p> <p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p> <p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end"> <footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
@@ -125,6 +129,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'related', item: ModelType): void;
(e: 'edit', item: ModelType): void; (e: 'edit', item: ModelType): void;
(e: 'delete', item: ModelType): void; (e: 'delete', item: ModelType): void;
(e: 'update:offset', offset: number): void; (e: 'update:offset', offset: number): void;
@@ -133,6 +138,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;

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) => {

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>

View File

@@ -10,6 +10,7 @@ export function useApi () {
const apiCall = async (endpoint, options = {}) => { const apiCall = async (endpoint, options = {}) => {
const url = `${API_BASE_URL}${endpoint}` const url = `${API_BASE_URL}${endpoint}`
const defaultOptions = { const defaultOptions = {
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
@@ -23,16 +24,37 @@ export function useApi () {
const response = await fetch(url, { const response = await fetch(url, {
...defaultOptions, ...defaultOptions,
...options, ...options,
headers: {
...defaultOptions.headers,
...options.headers
},
signal: controller.signal signal: controller.signal
}) })
clearTimeout(timeoutId) clearTimeout(timeoutId)
if (response.ok) { if (response.ok) {
const data = await response.json() let data = null
if (response.status !== 204) {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json') || contentType.includes('application/ld+json') || contentType.includes('+json')) {
const text = await response.text()
data = text ? JSON.parse(text) : null
} else {
const text = await response.text()
data = text || null
}
}
return { success: true, data } return { success: true, data }
} else { } else {
const errorData = await response.json().catch(() => ({})) const contentType = response.headers.get('content-type') || ''
let errorData = {}
if (contentType.includes('application/json')) {
errorData = await response.json().catch(() => ({}))
} else {
const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {}
}
const errorMessage = errorData.message || `Erreur ${response.status}: ${response.statusText}` const errorMessage = errorData.message || `Erreur ${response.status}: ${response.statusText}`
showError(errorMessage) showError(errorMessage)
return { success: false, error: errorMessage, status: response.status } return { success: false, error: errorMessage, status: response.status }
@@ -52,6 +74,9 @@ export function useApi () {
const post = async (endpoint, data) => { const post = async (endpoint, data) => {
return apiCall(endpoint, { return apiCall(endpoint, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/ld+json'
},
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
} }
@@ -59,6 +84,9 @@ export function useApi () {
const patch = async (endpoint, data) => { const patch = async (endpoint, data) => {
return apiCall(endpoint, { return apiCall(endpoint, {
method: 'PATCH', method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json'
},
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
} }

View File

@@ -0,0 +1,101 @@
import { computed, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
type GuardLabels = {
singular: string
plural: string
verifying: string
}
type GuardConfig = {
endpoint: string
filterKey: string
labels: GuardLabels
}
const extractTotal = (payload: any, fallbackLength: number) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
if (Array.isArray(payload?.member)) {
return payload.member.length
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member'].length
}
return fallbackLength
}
export function useCategoryEditGuard (config: GuardConfig) {
const { get } = useApi()
const { showError } = useToast()
const linkedCount = ref(0)
const linkedLoading = ref(false)
const loadLinkedCount = async (modelTypeId: string) => {
linkedLoading.value = true
try {
const params = new URLSearchParams()
params.set('itemsPerPage', '1')
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
const result = await get(`${config.endpoint}?${params.toString()}`)
if (!result.success) {
linkedCount.value = 0
return
}
const fallbackLength = Array.isArray(result.data?.member)
? result.data.member.length
: Array.isArray(result.data?.['hydra:member'])
? result.data['hydra:member'].length
: 0
linkedCount.value = extractTotal(result.data, fallbackLength)
} catch (error) {
linkedCount.value = 0
} finally {
linkedLoading.value = false
}
}
const isSubmitBlocked = computed(
() => linkedLoading.value || linkedCount.value > 0,
)
const submitBlockMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
if (linkedCount.value <= 0) {
return ''
}
if (linkedCount.value === 1) {
return `Modification bloquée : 1 ${config.labels.singular} est déjà lié à cette catégorie.`
}
return `Modification bloquée : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie.`
})
const guardSubmitOrNotify = () => {
if (!isSubmitBlocked.value) {
return false
}
showError(submitBlockMessage.value || 'Modification bloquée pour cette catégorie.')
return true
}
return {
linkedCount,
linkedLoading,
isSubmitBlocked,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
}
}

View File

@@ -0,0 +1,67 @@
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type ComponentHistoryActor = {
id: string
label: string
}
export type ComponentHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: ComponentHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const extractItems = (payload: any): ComponentHistoryEntry[] => {
if (Array.isArray(payload?.items)) {
return payload.items
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
return []
}
export function useComponentHistory () {
const { get } = useApi()
const history = ref<ComponentHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (componentId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`/composants/${componentId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger lhistorique.'
history.value = []
return result
}
history.value = extractItems(result.data) as ComponentHistoryEntry[]
return { success: true, data: history.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
history.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return {
history,
loading,
error,
loadHistory,
}
}

View File

@@ -1,35 +1,145 @@
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, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
const composants = ref([]) const composants = ref([])
const total = ref(0)
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const extractTotal = (payload, fallbackLength) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
return fallbackLength
}
export function useComposants () { export function useComposants () {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const { ensureConstructeurs } = useConstructeurs()
const loadComposants = async () => { const withResolvedConstructeurs = async (composant) => {
loading.value = true if (!composant || typeof composant !== 'object') {
try { return composant
const result = await get('/composants') }
if (result.success) { if (!composant.typeComposantId) {
composants.value = result.data const typeComposantId = extractRelationId(composant.typeComposant)
showInfo(`Chargement de ${composants.value.length} composant(s) réussi`) if (typeComposantId) {
composant.typeComposantId = typeComposantId
}
}
if (!composant.productId) {
const productId = extractRelationId(composant.product)
if (productId) {
composant.productId = productId
}
}
const ids = uniqueConstructeurIds(
composant.constructeurIds,
composant.constructeurs,
composant.constructeur,
)
const hasResolvedConstructeurs =
Array.isArray(composant.constructeurs)
&& composant.constructeurs.length > 0
&& composant.constructeurs.every((item) => item && typeof item === 'object')
if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids)
if (resolved.length) {
composant.constructeurs = resolved
composant.constructeurIds = ids
}
}
return composant
}
/**
* Load composants with pagination and search support
* @param {Object} options - Query options
* @param {string} [options.search] - Search term for name/reference
* @param {number} [options.page=1] - Current page (1-based)
* @param {number} [options.itemsPerPage=30] - Items per page
* @param {string} [options.orderBy='name'] - Field to order by
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
*/
const loadComposants = async (options = {}) => {
loading.value = true
try {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc'
} = options
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
params.set('name', search.trim())
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/composants?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems
total.value = extractTotal(result.data, items.length)
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
page,
itemsPerPage
}
}
}
return result
} catch (error) {
console.error('Erreur lors du chargement des composants:', error)
return { success: false, error: error.message }
} finally {
loading.value = false
} }
} catch (error) {
console.error('Erreur lors du chargement des composants:', error)
} finally {
loading.value = false
} }
}
const createComposant = async (composantData) => { const createComposant = async (composantData) => {
loading.value = true loading.value = true
try { try {
const result = await post('/composants', composantData) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
const result = await post('/composants', normalizedPayload)
if (result.success) { if (result.success) {
composants.value.push(result.data) const enriched = await withResolvedConstructeurs(result.data)
composants.value.unshift(enriched)
total.value += 1
const displayName = result.data?.name const displayName = result.data?.name
|| composantData?.definition?.name || composantData?.definition?.name
|| composantData?.name || composantData?.name
@@ -48,9 +158,10 @@ 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 normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
const result = await patch(`/composants/${id}`, normalizedPayload)
if (result.success) { if (result.success) {
const updated = result.data const updated = await withResolvedConstructeurs(result.data)
const index = composants.value.findIndex(comp => comp.id === id) const index = composants.value.findIndex(comp => comp.id === id)
if (index !== -1) { if (index !== -1) {
composants.value[index] = updated composants.value[index] = updated
@@ -73,6 +184,7 @@ const loadComposants = async () => {
if (result.success) { if (result.success) {
const deletedComposant = composants.value.find(comp => comp.id === id) const deletedComposant = composants.value.find(comp => comp.id === id)
composants.value = composants.value.filter(comp => comp.id !== id) composants.value = composants.value.filter(comp => comp.id !== id)
total.value = Math.max(0, total.value - 1)
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`) showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
} }
return result return result
@@ -89,6 +201,7 @@ const loadComposants = async () => {
return { return {
composants, composants,
total,
loading, loading,
loadComposants, loadComposants,
createComposant, createComposant,

View File

@@ -5,6 +5,58 @@ import { useToast } from './useToast'
const constructeurs = ref([]) const constructeurs = ref([])
const loading = ref(false) const loading = ref(false)
const uniqueConstructeurs = (items = []) => {
const map = new Map()
items.forEach((item) => {
if (item && typeof item === 'object' && typeof item.id === 'string') {
map.set(item.id, item)
}
})
return Array.from(map.values())
}
const normalizeIds = (ids = []) => {
if (!Array.isArray(ids)) {
return []
}
return Array.from(
new Set(
ids
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter((value) => value.length > 0),
),
)
}
const upsertConstructeurs = (items = []) => {
if (!Array.isArray(items) || !items.length) {
return
}
const merged = uniqueConstructeurs([...constructeurs.value, ...items])
constructeurs.value = merged
}
const getIndexedConstructeur = (id) =>
constructeurs.value.find((item) => item && item.id === id) || null
const extractCollection = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const pendingFetches = new Map()
export function useConstructeurs () { export function useConstructeurs () {
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast() const { showSuccess, showError } = useToast()
@@ -15,11 +67,12 @@ export function useConstructeurs () {
const query = search ? `?search=${encodeURIComponent(search)}` : '' const query = search ? `?search=${encodeURIComponent(search)}` : ''
const result = await get(`/constructeurs${query}`) const result = await get(`/constructeurs${query}`)
if (result.success) { if (result.success) {
constructeurs.value = result.data const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items)
} }
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
@@ -35,38 +88,88 @@ export function useConstructeurs () {
try { try {
const result = await post('/constructeurs', data) const result = await post('/constructeurs', data)
if (result.success) { if (result.success) {
constructeurs.value = [result.data, ...constructeurs.value] upsertConstructeurs([result.data])
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
} }
} }
const ensureConstructeurs = async (ids = []) => {
const normalizedIds = normalizeIds(ids)
if (!normalizedIds.length) {
return []
}
const collected = []
const missing = []
normalizedIds.forEach((id) => {
const existing = getIndexedConstructeur(id)
if (existing) {
collected.push(existing)
} else {
missing.push(id)
}
})
if (missing.length) {
const fetchTasks = missing.map((id) => {
const cached = pendingFetches.get(id)
if (cached) {
return cached
}
const task = get(`/constructeurs/${id}`)
.then((result) => {
if (result.success && result.data) {
return result.data
}
return null
})
.catch((error) => {
console.error('Erreur lors du chargement du fournisseur:', error)
return null
})
.finally(() => {
pendingFetches.delete(id)
})
pendingFetches.set(id, task)
return task
})
const fetched = await Promise.all(fetchTasks)
const validFetched = fetched.filter((item) => item && item.id)
if (validFetched.length) {
upsertConstructeurs(validFetched)
}
}
return normalizedIds
.map((id) => getIndexedConstructeur(id))
.filter((item) => Boolean(item))
}
const updateConstructeur = async (id, data) => { const updateConstructeur = async (id, data) => {
loading.value = true loading.value = true
try { try {
const result = await patch(`/constructeurs/${id}`, data) const result = await patch(`/constructeurs/${id}`, data)
if (result.success) { if (result.success) {
const index = constructeurs.value.findIndex(item => item.id === id) upsertConstructeurs([result.data])
if (index !== -1) { showSuccess(`Fournisseur "${result.data.name}" mis à jour`)
constructeurs.value[index] = result.data
}
showSuccess(`Constructeur "${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,21 +182,21 @@ 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
} }
} }
const getConstructeurById = id => constructeurs.value.find(item => item.id === id) const getConstructeurById = (id) => getIndexedConstructeur(id)
return { return {
constructeurs, constructeurs,
@@ -103,6 +206,7 @@ export function useConstructeurs () {
createConstructeur, createConstructeur,
updateConstructeur, updateConstructeur,
deleteConstructeur, deleteConstructeur,
getConstructeurById getConstructeurById,
ensureConstructeurs,
} }
} }

View File

@@ -1,10 +1,27 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { normalizeRelationIds } from '~/shared/apiRelations'
const documents = ref([]) const documents = ref([])
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const fileToBase64 = file => const fileToBase64 = file =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
@@ -22,7 +39,7 @@ export function useDocuments () {
try { try {
const result = await get(endpoint) const result = await get(endpoint)
if (result.success) { if (result.success) {
const data = result.data || [] const data = extractCollection(result.data)
if (updateStore) { if (updateStore) {
documents.value = data documents.value = data
} }
@@ -60,6 +77,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 })
@@ -75,14 +97,14 @@ export function useDocuments () {
for (const file of files) { for (const file of files) {
const dataUrl = await fileToBase64(file) const dataUrl = await fileToBase64(file)
const payload = { const payload = normalizeRelationIds({
name: file.name, name: file.name,
filename: file.name, filename: file.name,
mimeType: file.type || 'application/octet-stream', mimeType: file.type || 'application/octet-stream',
size: file.size, size: file.size,
path: dataUrl, path: dataUrl,
...context ...context
} })
const result = await post('/documents', payload) const result = await post('/documents', payload)
if (result.success) { if (result.success) {
@@ -140,6 +162,7 @@ export function useDocuments () {
loadDocumentsByMachine, loadDocumentsByMachine,
loadDocumentsByComponent, loadDocumentsByComponent,
loadDocumentsByPiece, loadDocumentsByPiece,
loadDocumentsByProduct,
uploadDocuments, uploadDocuments,
deleteDocument deleteDocument
} }

View File

@@ -1,20 +1,80 @@
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 { extractRelationId } from '~/shared/apiRelations'
const machineTypes = ref([]) const machineTypes = ref([])
const loading = ref(false) const loading = ref(false)
const normalizeRequirementList = (value, relationKey) => {
if (!Array.isArray(value)) {
return []
}
return value.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
return entry
}
const normalized = { ...entry }
const relationField = relationKey.replace('Id', '')
const relationValue = normalized[relationField]
console.log(`[normalizeRequirementList] Entry ${index}:`, {
relationKey,
relationField,
hasRelationKey: !!normalized[relationKey],
relationValue,
relationValueType: typeof relationValue
})
if (relationKey && !normalized[relationKey]) {
const relationId = extractRelationId(relationValue)
console.log(`[normalizeRequirementList] Extracted ID:`, relationId)
if (relationId) {
normalized[relationKey] = relationId
}
}
console.log(`[normalizeRequirementList] Normalized entry:`, normalized)
return normalized
})
}
const normalizeMachineType = (type) => {
if (!type || typeof type !== 'object') {
return type
}
return {
...type,
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
}
}
const extractCollection = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
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, put, delete: del } = useApi()
const loadMachineTypes = async () => { const loadMachineTypes = async () => {
loading.value = true loading.value = true
try { try {
const result = await get('/types/machines') const result = await get('/type_machines')
if (result.success) { if (result.success) {
machineTypes.value = result.data const items = extractCollection(result.data)
machineTypes.value = items.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) {
@@ -27,9 +87,9 @@ export function useMachineTypesApi () {
const createMachineType = async (typeData) => { const createMachineType = async (typeData) => {
loading.value = true loading.value = true
try { try {
const result = await post('/types/machines', typeData) const result = await post('/type_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
@@ -44,11 +104,12 @@ export function useMachineTypesApi () {
const updateMachineType = async (id, typeData) => { const updateMachineType = async (id, typeData) => {
loading.value = true loading.value = true
try { try {
const result = await patch(`/types/machines/${id}`, typeData) const result = await put(`/type_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`)
} }
@@ -64,7 +125,7 @@ export function useMachineTypesApi () {
const deleteMachineType = async (id) => { const deleteMachineType = async (id) => {
loading.value = true loading.value = true
try { try {
const result = await del(`/types/machines/${id}`) const result = await del(`/type_machines/${id}`)
if (result.success) { if (result.success) {
const deletedType = machineTypes.value.find(type => type.id === id) const deletedType = machineTypes.value.find(type => type.id === id)
machineTypes.value = machineTypes.value.filter(type => type.id !== id) machineTypes.value = machineTypes.value.filter(type => type.id !== id)
@@ -79,19 +140,28 @@ export function useMachineTypesApi () {
} }
} }
const getMachineTypeById = async (id) => { const getMachineTypeById = async (id, forceRefresh = false) => {
// D'abord chercher dans le cache local // D'abord chercher dans le cache local (sauf si forceRefresh)
const localType = machineTypes.value.find(type => type.id === id) if (!forceRefresh) {
if (localType) { const localType = machineTypes.value.find(type => type.id === id)
return { success: true, data: localType } if (localType) {
return { success: true, data: localType }
}
} }
// Si pas trouvé localement, récupérer depuis l'API // Récupérer depuis l'API
try { try {
const result = await get(`/types/machines/${id}`) const result = await get(`/type_machines/${id}`)
if (result.success) { if (result.success) {
// Ajouter au cache local const normalized = normalizeMachineType(result.data)
machineTypes.value.push(result.data) // Mettre à jour le cache local
const index = machineTypes.value.findIndex(type => type.id === id)
if (index !== -1) {
machineTypes.value[index] = normalized
} else {
machineTypes.value.push(normalized)
}
return { success: true, data: normalized }
} }
return result return result
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,8 @@
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'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
const machines = ref([]) const machines = ref([])
const loading = ref(false) const loading = ref(false)
@@ -31,6 +33,20 @@ const normalizeMachineResponse = (payload) => {
const normalized = { ...container } const normalized = { ...container }
if (!normalized.siteId) {
const siteId = extractRelationId(container.site)
if (siteId) {
normalized.siteId = siteId
}
}
if (!normalized.typeMachineId) {
const typeMachineId = extractRelationId(container.typeMachine)
if (typeMachineId) {
normalized.typeMachineId = typeMachineId
}
}
const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ?? const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ??
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ?? resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
[] []
@@ -55,11 +71,15 @@ export function useMachines () {
if (result.success) { if (result.success) {
const machineList = Array.isArray(result.data) const machineList = Array.isArray(result.data)
? result.data ? result.data
: Array.isArray(result.data?.machines) : Array.isArray(result.data?.member)
? result.data.machines ? result.data.member
: Array.isArray(result.data?.data) : Array.isArray(result.data?.['hydra:member'])
? result.data.data ? result.data['hydra:member']
: [] : Array.isArray(result.data?.machines)
? result.data.machines
: Array.isArray(result.data?.data)
? result.data.data
: []
const normalized = machineList const normalized = machineList
.map((item) => normalizeMachineResponse(item)) .map((item) => normalizeMachineResponse(item))
.filter(Boolean) .filter(Boolean)
@@ -76,7 +96,8 @@ 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 normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
const result = await post('/machines', normalizedPayload)
if (result.success) { if (result.success) {
const createdMachine = normalizeMachineResponse(result.data) || const createdMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) || normalizeMachineResponse(result.data?.machine) ||
@@ -111,7 +132,8 @@ export function useMachines () {
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 normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
const result = await patch(`/machines/${id}`, normalizedPayload)
if (result.success) { if (result.success) {
const updatedMachine = normalizeMachineResponse(result.data) || const updatedMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) || normalizeMachineResponse(result.data?.machine) ||

View File

@@ -0,0 +1,53 @@
import { ref, watch } from 'vue'
import { useCookie } from '#imports'
type SortCookie = {
field?: string
direction?: string
}
const readSortCookie = (value: unknown): SortCookie | null => {
if (!value) {
return null
}
if (typeof value === 'object') {
return value as SortCookie
}
if (typeof value === 'string') {
try {
return JSON.parse(value) as SortCookie
} catch {
return null
}
}
return null
}
export const usePersistedSort = <
TField extends string,
TDirection extends string,
>(
key: string,
defaults: { field: TField; direction: TDirection },
) => {
const cookie = useCookie<string | null>(`sort:${key}`, {
sameSite: 'lax',
})
const stored = readSortCookie(cookie.value)
const sortField = ref<TField>((stored?.field as TField) || defaults.field)
const sortDirection = ref<TDirection>(
(stored?.direction as TDirection) || defaults.direction,
)
watch([sortField, sortDirection], () => {
cookie.value = JSON.stringify({
field: sortField.value,
direction: sortDirection.value,
})
})
return {
sortField,
sortDirection,
}
}

View File

@@ -0,0 +1,34 @@
import { ref, watch } from 'vue'
import { useCookie } from '#imports'
const parseValue = <T>(value: unknown, fallback: T): T => {
if (value === null || value === undefined) {
return fallback
}
if (typeof value === 'string') {
try {
return JSON.parse(value) as T
} catch {
return value as unknown as T
}
}
return value as T
}
export const usePersistedValue = <T>(key: string, fallback: T) => {
const cookie = useCookie<string | null>(`pref:${key}`, {
sameSite: 'lax',
})
const initial = parseValue(cookie.value, fallback)
const state = ref<T>(initial)
watch(
state,
(value) => {
cookie.value = JSON.stringify(value)
},
{ deep: true },
)
return state
}

View File

@@ -0,0 +1,67 @@
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type PieceHistoryActor = {
id: string
label: string
}
export type PieceHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: PieceHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const extractItems = (payload: any): PieceHistoryEntry[] => {
if (Array.isArray(payload?.items)) {
return payload.items
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
return []
}
export function usePieceHistory () {
const { get } = useApi()
const history = ref<PieceHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (pieceId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`/pieces/${pieceId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger lhistorique.'
history.value = []
return result
}
history.value = extractItems(result.data) as PieceHistoryEntry[]
return { success: true, data: history.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
history.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return {
history,
loading,
error,
loadHistory,
}
}

View File

@@ -1,24 +1,142 @@
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, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
const pieces = ref([]) const pieces = ref([])
const total = ref(0)
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const extractTotal = (payload, fallbackLength) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
return fallbackLength
}
export function usePieces () { export function usePieces () {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const { ensureConstructeurs } = useConstructeurs()
const loadPieces = async () => { const withResolvedConstructeurs = async (piece) => {
if (!piece || typeof piece !== 'object') {
return piece
}
if (!piece.typePieceId) {
const typePieceId = extractRelationId(piece.typePiece)
if (typePieceId) {
piece.typePieceId = typePieceId
}
}
if (!piece.productId) {
const productId = extractRelationId(piece.product)
if (productId) {
piece.productId = productId
}
}
const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
if (productIds.length === 0 && piece.productId) {
piece.productIds = [piece.productId]
} else if (productIds.length > 0) {
piece.productIds = productIds.map((id) => String(id))
if (!piece.productId) {
piece.productId = piece.productIds[0] || null
}
}
const ids = uniqueConstructeurIds(
piece.constructeurIds,
piece.constructeurs,
piece.constructeur,
)
const hasResolvedConstructeurs =
Array.isArray(piece.constructeurs)
&& piece.constructeurs.length > 0
&& piece.constructeurs.every((item) => item && typeof item === 'object')
if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids)
if (resolved.length) {
piece.constructeurs = resolved
piece.constructeurIds = ids
}
}
return piece
}
/**
* Load pieces with pagination and search support
* @param {Object} options - Query options
* @param {string} [options.search] - Search term for name/reference
* @param {number} [options.page=1] - Current page (1-based)
* @param {number} [options.itemsPerPage=30] - Items per page
* @param {string} [options.orderBy='name'] - Field to order by
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
*/
const loadPieces = async (options = {}) => {
loading.value = true loading.value = true
try { try {
const result = await get('/pieces') const {
if (result.success) { search = '',
pieces.value = result.data page = 1,
showInfo(`Chargement de ${pieces.value.length} pièce(s) réussi`) itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc'
} = options
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
// API Platform uses property filters
params.set('name', search.trim())
} }
// API Platform OrderFilter syntax: order[field]=direction
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/pieces?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems
total.value = extractTotal(result.data, items.length)
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
page,
itemsPerPage
}
}
}
return result
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des pièces:', error) console.error('Erreur lors du chargement des pièces:', error)
return { success: false, error: error.message }
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -27,9 +145,12 @@ 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 normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
const result = await post('/pieces', normalizedPayload)
if (result.success) { if (result.success) {
pieces.value.push(result.data) const enriched = await withResolvedConstructeurs(result.data)
pieces.value.unshift(enriched)
total.value += 1
const displayName = result.data?.name const displayName = result.data?.name
|| pieceData?.definition?.name || pieceData?.definition?.name
|| pieceData?.name || pieceData?.name
@@ -48,9 +169,10 @@ 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 normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
const result = await patch(`/pieces/${id}`, normalizedPayload)
if (result.success) { if (result.success) {
const updated = result.data const updated = await withResolvedConstructeurs(result.data)
const index = pieces.value.findIndex(piece => piece.id === id) const index = pieces.value.findIndex(piece => piece.id === id)
if (index !== -1) { if (index !== -1) {
pieces.value[index] = updated pieces.value[index] = updated
@@ -73,6 +195,7 @@ export function usePieces () {
if (result.success) { if (result.success) {
const deletedPiece = pieces.value.find(piece => piece.id === id) const deletedPiece = pieces.value.find(piece => piece.id === id)
pieces.value = pieces.value.filter(piece => piece.id !== id) pieces.value = pieces.value.filter(piece => piece.id !== id)
total.value = Math.max(0, total.value - 1)
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`) showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
} }
return result return result
@@ -89,6 +212,7 @@ export function usePieces () {
return { return {
pieces, pieces,
total,
loading, loading,
loadPieces, loadPieces,
createPiece, createPiece,

View File

@@ -0,0 +1,67 @@
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type ProductHistoryActor = {
id: string
label: string
}
export type ProductHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: ProductHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const extractItems = (payload: any): ProductHistoryEntry[] => {
if (Array.isArray(payload?.items)) {
return payload.items
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
return []
}
export function useProductHistory () {
const { get } = useApi()
const history = ref<ProductHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (productId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`/products/${productId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger lhistorique.'
history.value = []
return result
}
history.value = extractItems(result.data) as ProductHistoryEntry[]
return { success: true, data: history.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
history.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return {
history,
loading,
error,
loadHistory,
}
}

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,
}
}

View File

@@ -0,0 +1,284 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
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
}
const extractCollection = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const extractTotal = (payload, fallbackLength) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
return fallbackLength
}
export function useProducts () {
const { showError } = useToast()
const { get, post, patch, delete: del } = useApi()
const { ensureConstructeurs } = useConstructeurs()
const withResolvedConstructeurs = async (product) => {
if (!product || typeof product !== 'object') {
return product
}
if (!product.typeProductId) {
const typeProductId = extractRelationId(product.typeProduct)
if (typeProductId) {
product.typeProductId = typeProductId
}
}
const ids = uniqueConstructeurIds(
product.constructeurIds,
product.constructeurs,
product.constructeur,
)
const hasResolvedConstructeurs =
Array.isArray(product.constructeurs)
&& product.constructeurs.length > 0
&& product.constructeurs.every((item) => item && typeof item === 'object')
if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids)
if (resolved.length) {
product.constructeurs = resolved
product.constructeurIds = ids
}
}
return product
}
/**
* Load products with pagination and search support
* @param {Object} options - Query options
* @param {string} [options.search] - Search term for name/reference
* @param {number} [options.page=1] - Current page (1-based)
* @param {number} [options.itemsPerPage=30] - Items per page
* @param {string} [options.orderBy='name'] - Field to order by
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
* @param {boolean} [options.force=false] - Force reload even if already loaded
*/
const loadProducts = async (options = {}) => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false
} = options
if (loading.value) {
return {
success: true,
data: { items: products.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
params.set('name', search.trim())
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/products?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
products.value = enrichedItems
total.value = extractTotal(result.data, items.length)
loaded.value = true
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
page,
itemsPerPage
}
}
} 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) => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
loading.value = true
error.value = null
try {
const result = await post('/products', normalizedPayload)
if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data)
const added = replaceInCache(enriched)
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) => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
loading.value = true
error.value = null
try {
const result = await patch(`/products/${id}`, normalizedPayload)
if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data)
replaceInCache(enriched)
} 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 = {}) => {
const shouldForce = !!options.force
if (!shouldForce) {
const cached = products.value.find((product) => product.id === id)
if (cached && Array.isArray(cached.constructeurs) && cached.constructeurs.length > 0) {
return { success: true, data: cached }
}
}
try {
const result = await get(`/products/${id}`)
if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data)
replaceInCache(enriched)
return { success: true, data: enriched }
}
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,
}
}

View File

@@ -2,7 +2,10 @@ import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
const buildUrl = (path) => { const buildUrl = (path) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const base = config.public.apiBaseUrl?.replace(/\/$/, '') || '' const baseUrl = process.server
? (config.apiBaseUrl || config.public.apiBaseUrl || '')
: (config.public.apiBaseUrl || '')
const base = baseUrl.replace(/\/$/, '')
return `${base}${path}` return `${base}${path}`
} }

View File

@@ -20,7 +20,7 @@ export function useProfiles () {
const fetchProfiles = async () => { const fetchProfiles = async () => {
loadingProfiles.value = true loadingProfiles.value = true
try { try {
profiles.value = await $fetch(buildUrl('/profiles'), { profiles.value = await $fetch(buildUrl('/session/profiles'), {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
headers: getSessionHeaders() headers: getSessionHeaders()
@@ -37,7 +37,7 @@ export function useProfiles () {
} }
const createProfile = async ({ firstName, lastName }) => { const createProfile = async ({ firstName, lastName }) => {
const profile = await $fetch(buildUrl('/profiles'), { const profile = await $fetch(buildUrl('/session/profiles'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: { firstName, lastName }, body: { firstName, lastName },
@@ -48,7 +48,7 @@ export function useProfiles () {
} }
const deleteProfile = async (profileId) => { const deleteProfile = async (profileId) => {
await $fetch(buildUrl(`/profiles/${profileId}`), { await $fetch(buildUrl(`/session/profiles/${profileId}`), {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
headers: getSessionHeaders() headers: getSessionHeaders()

View File

@@ -13,9 +13,20 @@ export function useSites () {
loading.value = true loading.value = true
try { try {
const result = await get('/sites') const result = await get('/sites')
console.log('sites api result', result)
if (result.success) { if (result.success) {
sites.value = result.data const collection = Array.isArray(result.data)
showInfo(`Chargement de ${sites.value.length} site(s) réussi`) ? result.data
: Array.isArray(result.data?.member)
? result.data.member
: Array.isArray(result.data?.['hydra:member'])
? result.data['hydra:member']
: Array.isArray(result.data?.data)
? result.data.data
: []
sites.value = collection
showInfo(`Chargement de ${collection.length} site(s) réussi`)
} }
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des sites:', error) console.error('Erreur lors du chargement des sites:', error)

View File

@@ -34,7 +34,8 @@
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…"
@input="debouncedSearch"
/> />
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -48,6 +49,7 @@
id="component-catalog-sort" id="component-catalog-sort"
v-model="sortField" v-model="sortField"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="name">Nom</option> <option value="name">Nom</option>
<option value="createdAt">Date de création</option> <option value="createdAt">Date de création</option>
@@ -64,14 +66,33 @@
id="component-catalog-dir" id="component-catalog-dir"
v-model="sortDirection" v-model="sortDirection"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="asc">Ascendant</option> <option value="asc">Ascendant</option>
<option value="desc">Descendant</option> <option value="desc">Descendant</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="component-catalog-per-page"
>
Par page
</label>
<select
id="component-catalog-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handlePerPageChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div> </div>
<p class="text-xs text-base-content/50 lg:text-right"> <p class="text-xs text-base-content/50 lg:text-right">
{{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }} {{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
</p> </p>
</div> </div>
@@ -83,54 +104,62 @@
Aucun composant n'a encore été créé. Aucun composant n'a encore été créé.
</p> </p>
<p v-else-if="!visibleComposants.length" class="text-sm text-base-content/70"> <p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
Aucun composant ne correspond à votre recherche. Aucun composant ne correspond à votre recherche.
</p> </p>
<div v-else class="overflow-x-auto"> <template v-else>
<table class="table table-sm md:table-md"> <div class="overflow-x-auto">
<thead> <table class="table table-sm md:table-md">
<tr> <thead>
<th class="w-24">Aperçu</th> <tr>
<th>Nom</th> <th class="w-24">Aperçu</th>
<th>Catégorie</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Actions</th> <th>Type de composant</th>
</tr> <th>Actions</th>
</thead> </tr>
<tbody> </thead>
<tr v-for="component in visibleComposants" :key="component.id"> <tbody>
<td class="align-middle"> <tr v-for="component in composantsList" :key="component.id">
<DocumentThumbnail <td class="align-middle">
:document="resolvePrimaryDocument(component)" <DocumentThumbnail
:alt="resolvePreviewAlt(component)" :document="resolvePrimaryDocument(component)"
/> :alt="resolvePreviewAlt(component)"
</td> />
<td>{{ component.name || 'Composant sans nom' }}</td> </td>
<td>{{ component.typeComposant?.name || '—' }}</td> <td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td> <td>{{ component.reference || '—' }}</td>
<td> <td>{{ resolveComponentType(component) }}</td>
<div class="flex items-center gap-2"> <td>
<NuxtLink <div class="flex items-center gap-2">
:to="`/component/${component.id}/edit`" <NuxtLink
class="btn btn-ghost btn-xs" :to="`/component/${component.id}/edit`"
> class="btn btn-ghost btn-xs"
Modifier >
</NuxtLink> Modifier
<button </NuxtLink>
type="button" <button
class="btn btn-error btn-xs" type="button"
:disabled="loadingComposants" class="btn btn-error btn-xs"
@click="handleDeleteComponent(component)" :disabled="loadingComposants"
> @click="handleDeleteComponent(component)"
Supprimer >
</button> Supprimer
</div> </button>
</td> </div>
</tr> </td>
</tbody> </tr>
</table> </tbody>
</div> </table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
@@ -140,19 +169,80 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { showError } = useToast() const { showError } = useToast()
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants() const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value) const loadingComposants = computed(() => loadingComposantsRef.value)
const composantsList = computed(() => composants.value || [])
const composantsTotal = computed(() => composantsList.value.length)
// Pagination state
const currentPage = ref(1)
const itemsPerPage = ref(30)
const composantsTotal = computed(() => total.value)
const composantsOnPage = computed(() => composants.value.length)
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
// Search state with debounce
const searchTerm = ref('') const searchTerm = ref('')
const sortField = ref<'name' | 'createdAt'>('name') let searchTimeout: ReturnType<typeof setTimeout> | null = null
const sortDirection = ref<'asc' | 'desc'>('asc')
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchComposants()
}, 300)
}
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'component-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les composants avec les types de composants complets
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
return {
...composant,
typeComposant: typeComposant || composant.typeComposant || null
}
})
})
const fetchComposants = async () => {
await loadComposants({
search: searchTerm.value,
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value
})
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchComposants()
}
const handleSortChange = () => {
currentPage.value = 1
fetchComposants()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchComposants()
}
const resolvePrimaryDocument = (component: Record<string, any>) => { const resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : [] const documents = Array.isArray(component?.documents) ? component.documents : []
@@ -180,6 +270,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)
@@ -204,60 +305,6 @@ const resolveDeleteGuard = (component: Record<string, any>) => {
} }
} }
const resolveComparableName = (component: Record<string, any>) => {
const toComparable = (value?: string | null) =>
(value ?? '').toString().trim().toLowerCase()
return (
toComparable(component?.name) ||
toComparable(component?.reference) ||
toComparable(component?.id)
)
}
const resolveComparableDate = (component: Record<string, any>) => {
const raw = component?.createdAt ?? component?.created_at ?? null
if (!raw) {
return 0
}
const parsed = new Date(raw).getTime()
return Number.isNaN(parsed) ? 0 : parsed
}
const visibleComposants = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const source = composantsList.value || []
const filtered = term
? source.filter((component) => {
const name = (component?.name || '').toLowerCase()
const reference = (component?.reference || '').toLowerCase()
const category = (component?.typeComposant?.name || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term) ||
category.includes(term)
)
})
: [...source]
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
resolveComparableName(a).localeCompare(
resolveComparableName(b),
'fr',
{ sensitivity: 'base' }
) * direction
)
}
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
})
})
const handleDeleteComponent = async (component: Record<string, any>) => { const handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component) const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
@@ -286,9 +333,14 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
} }
await deleteComposant(component.id) await deleteComposant(component.id)
// Reload current page after deletion
fetchComposants()
} }
onMounted(async () => { onMounted(async () => {
await loadComposants() await Promise.all([
fetchComposants(),
loadComponentTypes()
])
}) })
</script> </script>

View File

@@ -26,6 +26,8 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports' import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes' import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const route = useRoute() const route = useRoute()
@@ -48,6 +51,21 @@ const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null) const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isSubmitBlocked,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
})
const title = computed(() => const title = computed(() =>
initialData.value?.name initialData.value?.name
? `Modifier « ${initialData.value.name} »` ? `Modifier « ${initialData.value.name} »`
@@ -88,6 +106,8 @@ const loadCategory = async () => {
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined, structure: response.structure ?? undefined,
} }
await loadLinkedCount(id)
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
await navigateBackToList() await navigateBackToList()
@@ -101,6 +121,9 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id) const id = String(route.params.id)
saving.value = true saving.value = true
try { try {

View File

@@ -89,19 +89,20 @@
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..."
:initial-options="component?.constructeurs || []"
/> />
</div> </div>
</div> </div>
@@ -175,6 +176,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">
@@ -188,7 +201,7 @@
</div> </div>
<p <p
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length" v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureProducts(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
class="text-xs text-gray-500" class="text-xs text-gray-500"
> >
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut. Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
@@ -197,6 +210,50 @@
</details> </details>
</div> </div>
<div
v-if="structureSelections.hasAny"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Sélections actuelles</h2>
<p class="text-xs text-base-content/70">
Voici les pièces, produits et sous-composants réellement choisis pour ce composant.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-if="structureSelections.pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
</div>
<div v-if="structureSelections.products.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
</div>
<div v-if="structureSelections.components.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"> <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"> <header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2> <h2 class="font-semibold text-base-content">Champs personnalisés</h2>
@@ -377,6 +434,74 @@
</p> </p>
</div> </div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler Annuler
@@ -399,11 +524,19 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
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,16 +556,28 @@ 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()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { updateComposant } = useComposants() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { productTypes, loadProductTypes } = useProductTypes()
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
const { pieces, loadPieces } = usePieces()
const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast() const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
const component = ref<any | null>(null) const component = ref<any | null>(null)
const loading = ref(true) const loading = ref(true)
@@ -444,11 +589,92 @@ const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
structure: 'Structure',
typeComposant: 'Catégorie',
product: 'Produit lié',
constructeurIds: 'Fournisseurs',
}
const historyActionLabel = (action: string) => {
if (action === 'create') {
return 'Création'
}
if (action === 'delete') {
return 'Suppression'
}
return 'Modification'
}
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'medium',
timeStyle: 'short',
})
const formatHistoryDate = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return historyDateFormatter.format(date)
}
const formatHistoryValue = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '—'
}
return value.map((item) => formatHistoryValue(item)).join(', ')
}
if (typeof value === 'object') {
const maybeRecord = value as Record<string, unknown>
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
if (name && id) {
return `${name} (#${id})`
}
if (name) {
return name
}
if (id) {
return `#${id}`
}
try {
return JSON.stringify(value)
} catch (error) {
return String(value)
}
}
return String(value)
}
const historyDiffEntries = (entry: ComponentHistoryEntry) => {
const diff = entry.diff ?? {}
return Object.entries(diff).map(([field, change]) => {
const label = historyFieldLabels[field] ?? field
const fromLabel = formatHistoryValue(change?.from)
const toLabel = formatHistoryValue(change?.to)
return {
field,
label,
fromLabel,
toLabel,
}
})
}
const selectedTypeId = ref<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,
}) })
@@ -495,6 +721,46 @@ const documentPreviewSrc = (document: any) => {
} }
return document.path return document.path
} }
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() => ({
...Object.fromEntries(
(pieceTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
...fetchedPieceTypeMap.value,
}))
const fetchedProductTypeMap = ref<Record<string, string>>({})
const productTypeLabelMap = computed(() => ({
...Object.fromEntries(
(productTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
...fetchedProductTypeMap.value,
}))
const pieceCatalogMap = computed(() =>
new Map(
(pieces.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const productCatalogMap = computed(() =>
new Map(
(products.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const componentCatalogMap = computed(() =>
new Map(
(componentCatalogRef.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const documentThumbnailClass = (document: any) => { const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) { if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20' return 'h-24 w-20'
@@ -589,6 +855,15 @@ const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
return structure ? normalizeStructureForEditor(structure) : null return structure ? normalizeStructureForEditor(structure) : null
}) })
const refreshCustomFieldInputs = (
structureOverride?: ComponentModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? selectedTypeStructure.value ?? null
const values = valuesOverride ?? component.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { customFieldInputs.value.every((field) => {
if (!field.required) { if (!field.required) {
@@ -632,6 +907,13 @@ const fetchComponent = async () => {
if (result.success) { if (result.success) {
component.value = result.data component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
component.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await loadHistory(result.data.id)
} else { } else {
component.value = null component.value = null
componentDocuments.value = [] componentDocuments.value = []
@@ -647,17 +929,27 @@ watch(
return return
} }
selectedTypeId.value = currentComponent.typeComposantId || '' const resolvedTypeId = currentComponent.typeComposantId
|| extractRelationId(currentComponent.typeComposant)
|| ''
if (resolvedTypeId && !currentComponent.typeComposantId) {
currentComponent.typeComposantId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
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(
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : '' currentComponent,
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
customFieldInputs.value = buildCustomFieldInputs( currentComponent.constructeur ? [currentComponent.constructeur] : [],
currentStructure,
currentComponent.customFieldValues,
) )
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
initialized = true initialized = true
}, },
@@ -668,10 +960,7 @@ watch(selectedTypeStructure, (currentStructure) => {
if (!component.value) { if (!component.value) {
return return
} }
customFieldInputs.value = buildCustomFieldInputs( refreshCustomFieldInputs(currentStructure, component.value.customFieldValues)
currentStructure,
component.value.customFieldValues,
)
}) })
const submitEdition = async () => { const submitEdition = async () => {
@@ -691,12 +980,12 @@ 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)
if (!Number.isNaN(parsed)) { if (!Number.isNaN(parsed)) {
payload.prix = parsed payload.prix = String(parsed)
} }
} else { } else {
payload.prix = null payload.prix = null
@@ -751,6 +1040,7 @@ const buildCustomFieldInputs = (
...definition, ...definition,
customFieldId: definition.customFieldId || definition.id, customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null, customFieldValueId: null,
orderIndex: definition.orderIndex,
} }
} }
@@ -760,8 +1050,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 +1071,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 +1094,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 => {
@@ -970,6 +1268,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
@@ -978,6 +1280,9 @@ const getStructureSubcomponents = (structure: ComponentModelStructure | null) =>
return Array.isArray(legacy) ? legacy : [] return Array.isArray(legacy) ? legacy : []
} }
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0
const resolvePieceLabel = (piece: Record<string, any>) => { const resolvePieceLabel = (piece: Record<string, any>) => {
const parts: string[] = [] const parts: string[] = []
if (piece.role) { if (piece.role) {
@@ -987,6 +1292,8 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
parts.push(piece.typePiece.name) parts.push(piece.typePiece.name)
} else if (piece.typePieceLabel) { } else if (piece.typePieceLabel) {
parts.push(piece.typePieceLabel) parts.push(piece.typePieceLabel)
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
} else if (piece.typePiece?.code) { } else if (piece.typePiece?.code) {
parts.push(`Famille ${piece.typePiece.code}`) parts.push(`Famille ${piece.typePiece.code}`)
} else if (piece.familyCode) { } else if (piece.familyCode) {
@@ -997,6 +1304,91 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
return parts.length ? parts.join(' • ') : 'Pièce' return parts.length ? parts.join(' • ') : 'Pièce'
} }
const fetchPieceTypeNames = async (ids: string[]) => {
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
if (!missing.length) {
return
}
const results = await Promise.allSettled(
missing.map((id) => get(`/model_types/${id}`)),
)
const next = { ...fetchedPieceTypeMap.value }
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
next[missing[index]] = name
}
})
fetchedPieceTypeMap.value = next
}
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.typeProductId && productTypeLabelMap.value[product.typeProductId]) {
parts.push(productTypeLabelMap.value[product.typeProductId])
} 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 fetchProductTypeNames = async (ids: string[]) => {
const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
if (!missing.length) {
return
}
const results = await Promise.allSettled(
missing.map((id) => get(`/model_types/${id}`)),
)
const next = { ...fetchedProductTypeMap.value }
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
next[missing[index]] = name
}
})
fetchedProductTypeMap.value = next
}
watch(
selectedTypeStructure,
(structure) => {
const pieceIds = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (pieceIds.length) {
fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
}
const productIds = getStructureProducts(structure)
.map((product: any) => product?.typeProductId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (productIds.length) {
fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
}
},
{ immediate: true },
)
const resolveSubcomponentLabel = (node: Record<string, any>) => { const resolveSubcomponentLabel = (node: Record<string, any>) => {
const parts: string[] = [] const parts: string[] = []
if (node.alias) { if (node.alias) {
@@ -1023,6 +1415,104 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
return parts.length ? parts.join(' • ') : 'Sous-composant' return parts.length ? parts.join(' • ') : 'Sous-composant'
} }
type SelectionEntry = {
id: string
path: string
requirementLabel: string
resolvedName: string
}
const collectStructureSelections = (root: any): {
pieces: SelectionEntry[]
products: SelectionEntry[]
components: SelectionEntry[]
} => {
const piecesSelected: SelectionEntry[] = []
const productsSelected: SelectionEntry[] = []
const componentsSelected: SelectionEntry[] = []
if (!root || typeof root !== 'object') {
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
}
const visitNode = (node: any, fallbackPath = 'racine') => {
if (!node || typeof node !== 'object') {
return
}
const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
nodePieces.forEach((entry: any, index: number) => {
const selectedId = entry?.selectedPieceId
if (!isNonEmptyString(selectedId)) {
return
}
const definition = entry?.definition ?? entry
const catalogPiece = pieceCatalogMap.value.get(selectedId)
piecesSelected.push({
id: selectedId,
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
requirementLabel: resolvePieceLabel(definition),
resolvedName: catalogPiece?.name || selectedId,
})
})
const nodeProducts = Array.isArray(node.products) ? node.products : []
nodeProducts.forEach((entry: any, index: number) => {
const selectedId = entry?.selectedProductId
if (!isNonEmptyString(selectedId)) {
return
}
const definition = entry?.definition ?? entry
const catalogProduct = productCatalogMap.value.get(selectedId)
productsSelected.push({
id: selectedId,
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
requirementLabel: resolveProductLabel(definition),
resolvedName: catalogProduct?.name || selectedId,
})
})
const nodeChildren = Array.isArray(node.subcomponents)
? node.subcomponents
: Array.isArray(node.subComponents)
? node.subComponents
: []
nodeChildren.forEach((child: any, index: number) => {
const selectedId = child?.selectedComponentId
if (isNonEmptyString(selectedId)) {
const definition = child?.definition ?? child
const catalogComponent = componentCatalogMap.value.get(selectedId)
componentsSelected.push({
id: selectedId,
path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
requirementLabel: resolveSubcomponentLabel(definition),
resolvedName: catalogComponent?.name || selectedId,
})
}
visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
})
}
visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
}
const structureSelections = computed(() => {
const selections = collectStructureSelections(component.value?.structure)
const total =
selections.pieces.length + selections.products.length + selections.components.length
return {
...selections,
total,
hasAny: total > 0,
}
})
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({ const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name, customFieldName: field.name,
customFieldType: field.type, customFieldType: field.type,
@@ -1122,7 +1612,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
} }
onMounted(async () => { onMounted(async () => {
await Promise.allSettled([loadComponentTypes(), fetchComponent()]) await Promise.allSettled([
loadComponentTypes(),
loadPieceTypes(),
loadProductTypes(),
loadPieces({ itemsPerPage: 500 }),
loadProducts({ itemsPerPage: 500, force: true }),
loadComposants({ itemsPerPage: 500 }),
fetchComponent(),
])
loading.value = false loading.value = false
if (component.value?.id) { if (component.value?.id) {
await refreshDocuments() await refreshDocuments()

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,15 +201,20 @@
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"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/> />
<p v-else class="text-xs text-error"> <p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette. Impossible de générer les emplacements définis par le squelette.
@@ -335,12 +352,18 @@ 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 { usePieceTypes } from '~/composables/usePieceTypes'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useApi } from '~/composables/useApi'
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'
@@ -353,19 +376,24 @@ interface ComponentCatalogType extends ModelType {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const { const {
createComposant, createComposant,
composants: componentCatalogRef, composants: componentCatalogRef,
loadComposants,
loading: componentsLoading, loading: componentsLoading,
} = useComposants() } = useComposants()
const { const {
pieces: pieceCatalogRef, pieces: pieceCatalogRef,
loadPieces,
loading: piecesLoading, loading: piecesLoading,
} = usePieces() } = usePieces()
const {
products: productCatalogRef,
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 +404,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 +414,34 @@ 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,
)
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() => ({
...Object.fromEntries(
(pieceTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
...fetchedPieceTypeMap.value,
}))
const productTypeLabelMap = computed(() =>
Object.fromEntries(
(productTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
)
const componentTypeLabelMap = computed(() =>
Object.fromEntries(
(componentTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
) )
watch( watch(
@@ -485,6 +538,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 +563,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 +578,7 @@ const buildAssignmentNode = (
definition, definition,
selectedComponentId: '', selectedComponentId: '',
pieces, pieces,
products,
subcomponents, subcomponents,
} }
} }
@@ -521,7 +596,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 +613,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 +670,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 +700,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 +725,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 +738,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 +787,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
@@ -698,6 +808,8 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
parts.push(piece.typePiece.name) parts.push(piece.typePiece.name)
} else if (piece.typePieceLabel) { } else if (piece.typePieceLabel) {
parts.push(piece.typePieceLabel) parts.push(piece.typePieceLabel)
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
} else if (piece.typePiece?.code) { } else if (piece.typePiece?.code) {
parts.push(`Famille ${piece.typePiece.code}`) parts.push(`Famille ${piece.typePiece.code}`)
} else if (piece.familyCode) { } else if (piece.familyCode) {
@@ -708,6 +820,61 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
return parts.length ? parts.join(' • ') : 'Pièce' return parts.length ? parts.join(' • ') : 'Pièce'
} }
const fetchPieceTypeNames = async (ids: string[]) => {
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
if (!missing.length) {
return
}
const results = await Promise.allSettled(
missing.map((id) => get(`/model_types/${id}`)),
)
const next = { ...fetchedPieceTypeMap.value }
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
next[missing[index]] = name
}
})
fetchedPieceTypeMap.value = next
}
watch(
selectedTypeStructure,
(structure) => {
const ids = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (!ids.length) {
return
}
fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
},
{ immediate: true },
)
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 +904,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 +925,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'
@@ -771,12 +938,21 @@ const submitCreation = async () => {
if (rawPrice) { if (rawPrice) {
const parsed = Number(rawPrice) const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) { if (!Number.isNaN(parsed)) {
payload.prix = parsed payload.prix = String(parsed)
} }
} }
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
} }
@@ -826,8 +1002,8 @@ const submitCreation = async () => {
onMounted(async () => { onMounted(async () => {
await Promise.allSettled([ await Promise.allSettled([
loadComponentTypes(), loadComponentTypes(),
loadPieces(), loadPieceTypes(),
loadComposants(), loadProductTypes(),
]) ])
}) })
@@ -840,6 +1016,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 +1028,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 +1051,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 => {

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,13 +122,15 @@ 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 { usePersistedValue } from '~/composables/usePersistedValue'
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()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const searchTerm = ref('') const searchTerm = ref('')
const sortKey = ref('name') const sortKey = usePersistedValue('constructeurs-sort', 'name')
const modalOpen = ref(false) const modalOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const editingConstructeur = ref(null) const editingConstructeur = ref(null)
@@ -150,6 +152,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 +212,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)

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,10 +471,12 @@ 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'
import { extractRelationId } from '~/shared/apiRelations'
const { sites, loading, loadSites, createSite } = useSites() const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi() const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const { createMachineFromType, deleteMachine } = useMachines() const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
// Data // Data
const showAddSiteModal = ref(false) const showAddSiteModal = ref(false)
@@ -510,14 +518,64 @@ const categories = computed(() => {
return Array.from(cats) return Array.from(cats)
}) })
const machinesWithType = computed(() => {
return machines.value.map((machine) => {
const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
const resolvedTypeMachine = resolvedTypeMachineId
? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
: null
return {
...machine,
typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
typeMachine:
machine.typeMachine && typeof machine.typeMachine === 'object'
? machine.typeMachine
: resolvedTypeMachine
}
})
})
const machinesBySiteId = computed(() => {
const map = new Map()
machinesWithType.value.forEach((machine) => {
const siteId = machine.siteId || extractRelationId(machine.site)
if (!siteId) { return }
if (!map.has(siteId)) {
map.set(siteId, [])
}
map.get(siteId).push(machine)
})
return map
})
const sitesWithMachines = computed(() => {
return sites.value.map((site) => ({
...site,
machines: machinesBySiteId.value.get(site.id) || []
}))
})
const totalMachines = computed(() => { const totalMachines = computed(() => {
return sites.value.reduce((total, site) => { return sitesWithMachines.value.reduce((total, site) => {
return total + (site.machines?.length || 0) return total + (site.machines?.length || 0)
}, 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 = sitesWithMachines.value
// Filtrer par terme de recherche // Filtrer par terme de recherche
if (searchTerm.value) { if (searchTerm.value) {
@@ -536,9 +594,11 @@ const filteredSites = computed(() => {
}) })
const machineMatches = site.machines?.some( const machineMatches = site.machines?.some(
machine => machine => {
machine.name.toLowerCase().includes(lowerTerm) || const name = (machine.name || '').toLowerCase()
machine.reference?.toLowerCase().includes(lowerTerm) const reference = (machine.reference || '').toLowerCase()
return name.includes(lowerTerm) || reference.includes(lowerTerm)
}
) )
return siteMatches || machineMatches return siteMatches || machineMatches
@@ -622,6 +682,7 @@ const handleCreateMachine = async () => {
newMachine.typeMachineId = '' newMachine.typeMachineId = ''
newMachine.reference = '' newMachine.reference = ''
showAddMachineModal.value = false showAddMachineModal.value = false
await loadMachines()
} }
} }
@@ -656,6 +717,7 @@ const confirmDeleteMachine = async (machine) => {
const result = await deleteMachine(machine.id) const result = await deleteMachine(machine.id)
if (result.success) { if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`) showSuccess(`Machine "${machine.name}" supprimée avec succès`)
await loadMachines()
} else { } else {
showError(`Erreur lors de la suppression: ${result.error}`) showError(`Erreur lors de la suppression: ${result.error}`)
} }
@@ -683,6 +745,6 @@ const getCategoryBadgeClass = (category) => {
// Lifecycle // Lifecycle
onMounted(async () => { onMounted(async () => {
await Promise.all([loadSites(), loadMachineTypes()]) await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
}) })
</script> </script>

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();

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>
@@ -82,9 +86,11 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
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 +106,8 @@ const createEmptyType = () => ({
maintenanceFrequency: '', maintenanceFrequency: '',
customFields: [], customFields: [],
componentRequirements: [], componentRequirements: [],
pieceRequirements: [] pieceRequirements: [],
productRequirements: []
}) })
const draftType = ref(createEmptyType()) const draftType = ref(createEmptyType())
@@ -136,15 +143,32 @@ const parseOptions = (field = {}) => {
return [] return []
} }
const toModelTypeIri = (value) => {
if (!value) {
return undefined
}
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
return value
}
const relationId = extractRelationId(value)
if (relationId) {
return `/api/model_types/${relationId}`
}
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
}
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) {
@@ -156,9 +180,9 @@ const toIntegerOrNull = (value, fallback = null) => {
const normalizeComponentRequirements = (requirements = []) => const normalizeComponentRequirements = (requirements = []) =>
requirements requirements
.filter(req => req?.typeComposantId) .filter(req => req?.typeComposantId || req?.typeComposant)
.map((req, index) => ({ .map((req, index) => ({
typeComposantId: req.typeComposantId, typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
label: req.label?.trim() ? req.label.trim() : undefined, label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1), minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null), maxCount: toIntegerOrNull(req.maxCount, null),
@@ -171,9 +195,24 @@ const normalizeComponentRequirements = (requirements = []) =>
const normalizePieceRequirements = (requirements = []) => const normalizePieceRequirements = (requirements = []) =>
requirements requirements
.filter(req => req?.typePieceId) .filter(req => req?.typePieceId || req?.typePiece)
.map((req, index) => ({ .map((req, index) => ({
typePieceId: req.typePieceId, typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
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 normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId || req?.typeProduct)
.map((req, index) => ({
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
label: req.label?.trim() ? req.label.trim() : undefined, label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0), minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null), maxCount: toIntegerOrNull(req.maxCount, null),
@@ -191,7 +230,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 = () => {

File diff suppressed because it is too large Load Diff

View File

@@ -163,8 +163,21 @@ const categories = computed(() => {
return Array.from(cats) return Array.from(cats)
}) })
// Enrichir les machines avec les objets site et typeMachine complets
const enrichedMachines = computed(() => {
return machines.value.map((machine) => {
const site = sites.value.find(s => s.id === machine.siteId)
const typeMachine = machineTypes.value.find(t => t.id === machine.typeMachineId)
return {
...machine,
site: site || null,
typeMachine: typeMachine || null
}
})
})
const filteredMachines = computed(() => { const filteredMachines = computed(() => {
let filtered = machines.value let filtered = enrichedMachines.value
if (selectedSite.value) { if (selectedSite.value) {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value) filtered = filtered.filter(machine => machine.siteId === selectedSite.value)

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>
@@ -269,18 +273,19 @@
</label> </label>
<SearchSelect <SearchSelect
:model-value="entry.pieceId || ''" :model-value="entry.pieceId || ''"
:options="getPieceOptions(requirement, entry)" :options="getPieceOptions(requirement, entry, entryIndex)"
:loading="piecesLoading" :loading="piecesLoading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
size="sm" size="sm"
placeholder="Rechercher une pièce…" placeholder="Rechercher une pièce…"
empty-text="Aucune pièce disponible" empty-text="Aucune pièce disponible"
:option-label="pieceOptionLabel" :option-label="pieceOptionLabel"
:option-description="pieceOptionDescription" :option-description="pieceOptionDescription"
@search="(term) => fetchPieceOptions(requirement, entryIndex, term)"
@update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')" @update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')"
/> />
</div> </div>
<p <p
v-if="getPieceOptions(requirement, entry).length === 0" v-if="getPieceOptions(requirement, entry, entryIndex).length === 0"
class="text-xs text-error" class="text-xs text-error"
> >
Aucune pièce disponible pour cette famille. Aucune pièce disponible pour cette famille.
@@ -298,16 +303,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 +611,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 +743,12 @@ 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 { useApi } from '~/composables/useApi'
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'
@@ -561,11 +756,13 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2' import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
import IconLucideCircle from '~icons/lucide/circle' import IconLucideCircle from '~icons/lucide/circle'
const { createMachine, createMachineFromType } = useMachines() const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
const { sites, loadSites } = useSites() 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 { get } = useApi()
const toast = useToast() const toast = useToast()
const submitting = ref(false) const submitting = ref(false)
@@ -579,6 +776,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 +802,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 +833,96 @@ 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 pieceOptionsByKey = ref({})
const pieceLoadingByKey = ref({})
const extractCollection = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const getPieceKey = (requirement, entryIndex) => `${requirement?.id || 'req'}:${entryIndex}`
const findPieceInCachedOptions = (id) => {
if (!id) {
return null
}
const buckets = Object.values(pieceOptionsByKey.value || {})
for (const bucket of buckets) {
if (!Array.isArray(bucket)) {
continue
}
const found = bucket.find((piece) => piece?.id === id)
if (found) {
return found
}
}
return null
}
const cachePieceIfMissing = (piece) => {
if (!piece?.id) {
return
}
if (pieceById.value.has(piece.id)) {
return
}
const current = Array.isArray(pieces.value) ? pieces.value : []
pieces.value = [...current, piece]
}
const fetchPieceOptions = async (requirement, entryIndex, term = '') => {
const key = getPieceKey(requirement, entryIndex)
if (pieceLoadingByKey.value[key]) {
return
}
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
const params = new URLSearchParams()
params.set('itemsPerPage', '50')
if (term && term.trim()) {
params.set('name', term.trim())
}
if (requirementTypeId) {
params.set('typePiece', `/api/model_types/${requirementTypeId}`)
}
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
try {
const result = await get(`/pieces?${params.toString()}`)
if (result.success) {
pieceOptionsByKey.value = {
...pieceOptionsByKey.value,
[key]: extractCollection(result.data)
}
}
} finally {
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
}
}
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value) const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -866,7 +1159,12 @@ const getComponentOptions = (requirement, currentEntry) => {
}) })
} }
const getPieceOptions = (requirement, currentEntry) => { const getPieceOptions = (requirement, currentEntry, entryIndex) => {
const key = getPieceKey(requirement, entryIndex)
const cached = pieceOptionsByKey.value[key]
if (cached) {
return cached
}
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
const usedIds = new Set( const usedIds = new Set(
selectedPieceIds.value.filter((id) => id && (!currentEntry || id !== currentEntry.pieceId)), selectedPieceIds.value.filter((id) => id && (!currentEntry || id !== currentEntry.pieceId)),
@@ -904,6 +1202,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 +1232,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]
@@ -951,8 +1328,11 @@ const setPieceRequirementPiece = (requirement, index, pieceId) => {
if (!entry) return if (!entry) return
entry.pieceId = pieceId || null entry.pieceId = pieceId || null
if (pieceId) { if (pieceId) {
const piece = findPieceById(pieceId) const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
if (piece) {
cachePieceIfMissing(piece)
}
} else { } else {
entry.typePieceId = requirement?.typePieceId || null entry.typePieceId = requirement?.typePieceId || null
} }
@@ -969,7 +1349,14 @@ const findPieceById = (id) => {
if (!id) { if (!id) {
return null return null
} }
return pieceById.value.get(id) || null return pieceById.value.get(id) || findPieceInCachedOptions(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') {
@@ -1003,6 +1390,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 +1404,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 +1575,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) => {
@@ -1054,6 +1609,7 @@ const addPieceSelectionEntry = (requirement) => {
...entries, ...entries,
createPieceSelectionEntry(requirement), createPieceSelectionEntry(requirement),
] ]
fetchPieceOptions(requirement, entries.length).catch(() => {})
} }
const removePieceSelectionEntry = (requirementId, index) => { const removePieceSelectionEntry = (requirementId, index) => {
@@ -1061,6 +1617,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 +1714,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 +1818,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 +1878,7 @@ const validateRequirementSelections = (type) => {
valid: true, valid: true,
componentLinks: componentLinksPayload, componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload, pieceLinks: pieceLinksPayload,
productLinks: productLinksPayload,
} }
} }
@@ -1425,20 +2080,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 +2114,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 +2170,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)
@@ -1524,10 +2187,26 @@ const initializeRequirementSelections = (type) => {
const initialCount = Math.max(min, requirement.required ? 1 : 0) const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) { if (initialCount > 0) {
pieceRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createPieceSelectionEntry(requirement)) pieceRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createPieceSelectionEntry(requirement))
pieceRequirementSelections[requirement.id].forEach((_, index) => {
fetchPieceOptions(requirement, index).catch(() => {})
})
} else { } else {
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 +2232,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,23 +2249,25 @@ const finalizeMachineCreation = async () => {
} }
componentLinks = validationResult.componentLinks componentLinks = validationResult.componentLinks
pieceLinks = validationResult.pieceLinks pieceLinks = validationResult.pieceLinks
} productLinks = validationResult.productLinks
const payload = {
...baseMachineData,
...(hasRequirements
? {
componentLinks,
pieceLinks
}
: {})
} }
const result = hasRequirements const result = hasRequirements
? await createMachine(payload) ? await createMachine(baseMachineData)
: await createMachineFromType(baseMachineData, type) : await createMachineFromType(baseMachineData, type)
if (result.success) { if (result.success) {
if (hasRequirements && result.data?.id) {
const skeletonResult = await reconfigureSkeleton(result.data.id, {
componentLinks,
pieceLinks,
productLinks,
})
if (!skeletonResult.success) {
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
return
}
}
newMachine.name = '' newMachine.name = ''
newMachine.siteId = '' newMachine.siteId = ''
newMachine.typeMachineId = '' newMachine.typeMachineId = ''
@@ -1621,7 +2306,8 @@ onMounted(async () => {
loadSites(), loadSites(),
loadMachineTypes(), loadMachineTypes(),
loadComposants(), loadComposants(),
loadPieces() loadPieces(),
loadProducts()
]) ])
}) })
</script> </script>

View File

@@ -26,6 +26,8 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports' import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes' import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const route = useRoute() const route = useRoute()
@@ -48,6 +51,21 @@ const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null) const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isSubmitBlocked,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: {
singular: 'pièce',
plural: 'pièces',
verifying: 'Vérification des pièces liées en cours…',
},
})
const title = computed(() => const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce', initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
) )
@@ -86,6 +104,8 @@ const loadCategory = async () => {
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined, structure: response.structure ?? undefined,
} }
await loadLinkedCount(id)
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
await navigateBackToList() await navigateBackToList()
@@ -99,6 +119,9 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id) const id = String(route.params.id)
saving.value = true saving.value = true
try { try {

View File

@@ -33,7 +33,8 @@
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…"
@input="debouncedSearch"
/> />
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -47,6 +48,7 @@
id="piece-catalog-sort" id="piece-catalog-sort"
v-model="sortField" v-model="sortField"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="name">Nom</option> <option value="name">Nom</option>
<option value="createdAt">Date de création</option> <option value="createdAt">Date de création</option>
@@ -63,14 +65,33 @@
id="piece-catalog-dir" id="piece-catalog-dir"
v-model="sortDirection" v-model="sortDirection"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="asc">Ascendant</option> <option value="asc">Ascendant</option>
<option value="desc">Descendant</option> <option value="desc">Descendant</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="piece-catalog-per-page"
>
Par page
</label>
<select
id="piece-catalog-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handlePerPageChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div> </div>
<p class="text-xs text-base-content/50 lg:text-right"> <p class="text-xs text-base-content/50 lg:text-right">
{{ visiblePieces.length }} / {{ piecesTotal }} résultat{{ visiblePieces.length > 1 ? 's' : '' }} {{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
</p> </p>
</div> </div>
@@ -82,54 +103,85 @@
Aucune pièce n'a encore été créée. Aucune pièce n'a encore été créée.
</p> </p>
<p v-else-if="!visiblePieces.length" class="text-sm text-base-content/70"> <p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
Aucune pièce ne correspond à votre recherche. Aucune pièce ne correspond à votre recherche.
</p> </p>
<div v-else class="overflow-x-auto"> <template v-else>
<table class="table table-sm md:table-md"> <div class="overflow-x-auto">
<thead> <table class="table table-sm md:table-md">
<tr> <thead>
<th class="w-24">Aperçu</th> <tr>
<th>Nom</th> <th class="w-24">Aperçu</th>
<th>Catégorie</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Actions</th> <th>Fournisseurs</th>
</tr> <th>Type de pièce</th>
</thead> <th>Actions</th>
<tbody> </tr>
<tr v-for="piece in visiblePieces" :key="piece.id"> </thead>
<td class="align-middle"> <tbody>
<DocumentThumbnail <tr v-for="row in pieceRows" :key="row.piece.id">
:document="resolvePrimaryDocument(piece)" <td class="align-middle">
:alt="resolvePreviewAlt(piece)" <DocumentThumbnail
/> :document="resolvePrimaryDocument(row.piece)"
</td> :alt="resolvePreviewAlt(row.piece)"
<td>{{ piece.name || 'Pièce sans nom' }}</td> />
<td>{{ piece.typePiece?.name || '—' }}</td> </td>
<td>{{ piece.reference || '' }}</td> <td>{{ row.piece.name || 'Pièce sans nom' }}</td>
<td> <td>{{ row.piece.reference || '—' }}</td>
<div class="flex items-center gap-2"> <td>
<NuxtLink <div
:to="`/pieces/${piece.id}/edit`" v-if="row.suppliers.visible.length"
class="btn btn-ghost btn-xs" class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
> >
Modifier <span
</NuxtLink> v-for="supplier in row.suppliers.visible"
<button :key="supplier"
type="button" class="badge badge-ghost badge-sm whitespace-nowrap"
class="btn btn-error btn-xs" >
:disabled="loadingPieces" {{ supplier }}
@click="handleDeletePiece(piece)" </span>
> <span
Supprimer v-if="row.suppliers.overflow"
</button> class="badge badge-outline badge-sm"
</div> >
</td> +{{ row.suppliers.overflow }}
</tr> </span>
</tbody> </div>
</table> <span v-else></span>
</div> </td>
<td>{{ resolvePieceType(row.piece) }}</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/pieces/${row.piece.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
@@ -137,21 +189,82 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { showError } = useToast() const { showError } = useToast()
const { pieces, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces() const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value) const loadingPieces = computed(() => loadingPiecesRef.value)
const piecesList = computed(() => pieces.value || [])
const piecesTotal = computed(() => piecesList.value.length)
// Pagination state
const currentPage = ref(1)
const itemsPerPage = ref(30)
const piecesTotal = computed(() => total.value)
const piecesOnPage = computed(() => pieces.value.length)
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
// Search state with debounce
const searchTerm = ref('') const searchTerm = ref('')
const sortField = ref<'name' | 'createdAt'>('name') let searchTimeout: ReturnType<typeof setTimeout> | null = null
const sortDirection = ref<'asc' | 'desc'>('asc')
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchPieces()
}, 300)
}
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'pieces-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les pièces avec les types de pièces complets
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return {
...piece,
typePiece: typePiece || piece.typePiece || null
}
})
})
const fetchPieces = async () => {
await loadPieces({
search: searchTerm.value,
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value
})
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchPieces()
}
const handleSortChange = () => {
currentPage.value = 1
fetchPieces()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchPieces()
}
const resolvePrimaryDocument = (piece: Record<string, any>) => { const resolvePrimaryDocument = (piece: Record<string, any>) => {
const documents = Array.isArray(piece?.documents) ? piece.documents : [] const documents = Array.isArray(piece?.documents) ? piece.documents : []
@@ -182,6 +295,99 @@ 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 MAX_VISIBLE_SUPPLIERS = 3
const resolvePieceSuppliers = (piece: Record<string, any>) => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') {
return
}
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) {
return
}
const key = normalized.toLowerCase()
if (seen.has(key)) {
return
}
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) {
return
}
if (Array.isArray(value)) {
value.forEach(collectConstructeurs)
return
}
if (typeof value === 'string') {
pushName(value)
return
}
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) {
collectConstructeurs(record.constructeur)
}
if (Array.isArray(record?.constructeurs)) {
collectConstructeurs(record.constructeurs)
}
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') {
return
}
value
.split(/[,;\\/•·|]+/)
.map((part) => part.trim())
.filter(Boolean)
.forEach(pushName)
}
collectConstructeurs(piece?.constructeurs)
collectConstructeurs(piece?.constructeur)
collectConstructeurs(piece?.product?.constructeurs)
collectConstructeurs(piece?.product?.constructeur)
collectFromLabel(piece?.constructeursLabel)
collectFromLabel(piece?.supplierLabel)
collectFromLabel(piece?.product?.constructeursLabel)
collectFromLabel(piece?.product?.supplierLabel)
return names
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
const suppliers = resolvePieceSuppliers(piece)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return {
suppliers,
visible,
overflow,
tooltip: suppliers.length ? suppliers.join(', ') : '',
}
}
const 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)
@@ -206,59 +412,12 @@ const resolveDeleteGuard = (piece: Record<string, any>) => {
} }
} }
const resolveComparableName = (piece: Record<string, any>) => { const pieceRows = computed(() =>
const normalise = (value?: string | null) => piecesList.value.map((piece) => ({
(value ?? '').toString().trim().toLowerCase() piece,
suppliers: buildPieceSuppliersDisplay(piece),
return ( })),
normalise(piece?.name) || )
normalise(piece?.reference) ||
normalise(piece?.id)
)
}
const resolveComparableDate = (piece: Record<string, any>) => {
const raw = piece?.createdAt ?? piece?.created_at ?? null
if (!raw) {
return 0
}
const timestamp = new Date(raw).getTime()
return Number.isNaN(timestamp) ? 0 : timestamp
}
const visiblePieces = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const source = piecesList.value || []
const filtered = term
? source.filter((piece) => {
const name = (piece?.name || '').toLowerCase()
const reference = (piece?.reference || '').toLowerCase()
const category = (piece?.typePiece?.name || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term) ||
category.includes(term)
)
})
: [...source]
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
resolveComparableName(a).localeCompare(
resolveComparableName(b),
'fr',
{ sensitivity: 'base' }
) * direction
)
}
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
})
})
const handleDeletePiece = async (piece: Record<string, any>) => { const handleDeletePiece = async (piece: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece) const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
@@ -289,9 +448,14 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
} }
await deletePiece(piece.id) await deletePiece(piece.id)
// Reload current page after deletion
fetchPieces()
} }
onMounted(async () => { onMounted(async () => {
await loadPieces() await Promise.all([
fetchPieces(),
loadPieceTypes()
])
}) })
</script> </script>

View File

@@ -89,19 +89,20 @@
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..."
:initial-options="piece?.constructeurs || []"
/> />
</div> </div>
</div> </div>
@@ -123,26 +124,70 @@
</div> </div>
</div> </div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <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>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<div v-if="selectedType || resolvedStructure" 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>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2> <h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70"> <p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }} {{ selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
</p> </p>
</div> </div>
<span class="badge badge-outline">{{ formatPieceStructurePreview(selectedType.structure) }}</span> <span class="badge badge-outline">{{ formatPieceStructurePreview(resolvedStructure) }}</span>
</div> </div>
<details v-if="selectedType.structure" class="collapse collapse-arrow bg-base-100"> <details v-if="resolvedStructure" class="collapse collapse-arrow bg-base-100">
<summary class="collapse-title text-sm font-medium"> <summary class="collapse-title text-sm font-medium">
Consulter le détail du squelette Consulter le détail du squelette
</summary> </summary>
<div class="collapse-content space-y-2 text-sm text-base-content/80"> <div class="collapse-content space-y-2 text-sm text-base-content/80">
<div v-if="getStructureCustomFields(selectedType.structure).length" class="space-y-1"> <div v-if="getStructureCustomFields(resolvedStructure).length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3> <h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li v-for="field in getStructureCustomFields(selectedType.structure)" :key="field.name"> <li v-for="field in getStructureCustomFields(resolvedStructure)" :key="field.name">
<span class="font-medium">{{ field.name }}</span> <span class="font-medium">{{ field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span> <span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li> </li>
@@ -336,6 +381,74 @@
</p> </p>
</div> </div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler Annuler
@@ -356,17 +469,23 @@ 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'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations'
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'
import { getModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null structure: PieceModelStructure | null
@@ -382,6 +501,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()
@@ -389,9 +509,16 @@ const router = useRouter()
const { get } = useApi() const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces() const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast() const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
const piece = ref<any | null>(null) const piece = ref<any | null>(null)
const loading = ref(true) const loading = ref(true)
@@ -403,17 +530,113 @@ const pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
typePiece: 'Catégorie',
product: 'Produit lié',
productIds: 'Produits liés',
constructeurIds: 'Fournisseurs',
}
const historyActionLabel = (action: string) => {
if (action === 'create') {
return 'Création'
}
if (action === 'delete') {
return 'Suppression'
}
return 'Modification'
}
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'medium',
timeStyle: 'short',
})
const formatHistoryDate = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return historyDateFormatter.format(date)
}
const formatHistoryValue = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '—'
}
return value.map((item) => formatHistoryValue(item)).join(', ')
}
if (typeof value === 'object') {
const maybeRecord = value as Record<string, unknown>
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
if (name && id) {
return `${name} (#${id})`
}
if (name) {
return name
}
if (id) {
return `#${id}`
}
try {
return JSON.stringify(value)
} catch (error) {
return String(value)
}
}
return String(value)
}
const historyDiffEntries = (entry: PieceHistoryEntry) => {
const diff = entry.diff ?? {}
return Object.entries(diff).map(([field, change]) => {
const label = historyFieldLabels[field] ?? field
const fromLabel = formatHistoryValue(change?.from)
const toLabel = formatHistoryValue(change?.to)
return {
field,
label,
fromLabel,
toLabel,
}
})
}
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null)
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,
}) })
const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
const documentIcon = (doc: any) => const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType }) getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
)
const refreshCustomFieldInputs = (
structureOverride?: PieceModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? resolvedStructure.value ?? null
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const formatSize = (size: number | null | undefined) => { const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) { if (size === null || size === undefined) {
return '—' return '—'
@@ -540,6 +763,90 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
}) })
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const structureProducts = computed(() =>
getStructureProducts(resolvedStructure.value),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
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 ensureProductSelections = (count: number) => {
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
productSelections.value = next
}
let pendingProductIds: string[] = []
const productRequirementEntries = computed(() =>
structureProducts.value.map((requirement, index) => ({
index,
key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
label: describeProductRequirement(requirement, index),
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
})),
)
const productSelectionsFilled = computed(() =>
!requiresProductSelection.value ||
productRequirementEntries.value.every((entry) => {
const value = productSelections.value[entry.index]
return typeof value === 'string' && value.trim().length > 0
}),
)
const setProductSelection = (index: number, value: string | null) => {
const normalized = typeof value === 'string' ? value : null
const next = [...productSelections.value]
next[index] = normalized
productSelections.value = next
}
watch(structureProducts, (products) => {
ensureProductSelections(products.length)
if (!pendingProductIds.length || products.length === 0) {
return
}
const next = Array.from(
{ length: products.length },
(_, index) => pendingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
})
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { customFieldInputs.value.every((field) => {
if (!field.required) { if (!field.required) {
@@ -552,12 +859,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 &&
)) productSelectionsFilled.value &&
!saving.value,
),
)
const toFieldString = (value: unknown): string => { const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -583,12 +893,38 @@ const fetchPiece = async () => {
if (result.success) { if (result.success) {
piece.value = result.data piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
piece.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await loadPieceTypeDetails(result.data)
await loadHistory(result.data.id)
} else { } else {
piece.value = null piece.value = null
pieceDocuments.value = [] pieceDocuments.value = []
} }
} }
const loadPieceTypeDetails = async (currentPiece: any) => {
const typeId = currentPiece?.typePieceId
|| extractRelationId(currentPiece?.typePiece)
|| ''
if (!typeId) {
pieceTypeDetails.value = null
return
}
try {
const type = await getModelType(typeId)
if (type && typeof type === 'object') {
pieceTypeDetails.value = type
refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null)
}
} catch (error) {
pieceTypeDetails.value = null
}
}
let initialized = false let initialized = false
watch( watch(
@@ -598,17 +934,43 @@ watch(
return return
} }
selectedTypeId.value = currentPiece.typePieceId || '' const resolvedTypeId = currentPiece.typePieceId
|| extractRelationId(currentPiece.typePiece)
|| ''
if (resolvedTypeId && !currentPiece.typePieceId) {
currentPiece.typePieceId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
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(
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : '' currentPiece,
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
customFieldInputs.value = buildCustomFieldInputs( currentPiece.constructeur ? [currentPiece.constructeur] : [],
currentType?.structure ?? null,
currentPiece.customFieldValues,
) )
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
? currentPiece.productIds.map((id: unknown) => String(id))
: currentPiece.product?.id || currentPiece.productId
? [String(currentPiece.product?.id || currentPiece.productId)]
: []
pendingProductIds = existingProductIds
ensureProductSelections(structureProducts.value.length)
if (existingProductIds.length && structureProducts.value.length) {
const next = Array.from(
{ length: structureProducts.value.length },
(_, index) => existingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
}
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
initialized = true initialized = true
}, },
@@ -619,10 +981,17 @@ watch(selectedType, (currentType) => {
if (!piece.value || !currentType) { if (!piece.value || !currentType) {
return return
} }
customFieldInputs.value = buildCustomFieldInputs( if (!pieceTypeDetails.value) {
currentType.structure, refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
piece.value.customFieldValues, }
) })
watch(resolvedStructure, (currentStructure) => {
if (!piece.value) {
return
}
ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
}) })
const submitEdition = async () => { const submitEdition = async () => {
@@ -630,24 +999,39 @@ const submitEdition = async () => {
return return
} }
if (!productSelectionsFilled.value) {
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
? '' ? ''
: String(editionForm.prix).trim() : String(editionForm.prix).trim()
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: editionForm.name.trim(), name: editionForm.name.trim(),
constructeurIds,
} }
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
const normalizedProductIds = productRequirementEntries.value
.map((entry) => productSelections.value[entry.index])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0] || null
if (rawPrice) { if (rawPrice) {
const parsed = Number(rawPrice) const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) { if (!Number.isNaN(parsed)) {
payload.prix = parsed payload.prix = String(parsed)
} }
} else { } else {
payload.prix = null payload.prix = null
@@ -701,6 +1085,7 @@ const buildCustomFieldInputs = (
...definition, ...definition,
customFieldId: definition.customFieldId || definition.id, customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null, customFieldValueId: null,
orderIndex: definition.orderIndex,
} }
} }
@@ -710,8 +1095,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 +1116,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 +1142,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,8 +1219,6 @@ 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 buildCustomFieldMetadata = (field: CustomFieldInput) => ({ const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name, customFieldName: field.name,
customFieldType: field.type, customFieldType: field.type,

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,50 @@
</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>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</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 +297,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 +305,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,9 +329,10 @@ 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 productSelections = ref<(string | null)[]>([])
const lastSuggestedName = ref('') const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
@@ -331,6 +378,79 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
}) })
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
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 ensureProductSelections = (count: number) => {
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
productSelections.value = next
}
const productRequirementEntries = computed(() =>
structureProducts.value.map((requirement, index) => ({
index,
key: `piece-create-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
label: describeProductRequirement(requirement, index),
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
})),
)
const productSelectionsFilled = computed(() =>
!requiresProductSelection.value ||
productRequirementEntries.value.every((entry) => {
const value = productSelections.value[entry.index]
return typeof value === 'string' && value.trim().length > 0
}),
)
const setProductSelection = (index: number, value: string | null) => {
const normalized = typeof value === 'string' ? value : null
const next = [...productSelections.value]
next[index] = normalized
productSelections.value = next
}
watch(structureProducts, (products) => {
ensureProductSelections(products.length)
})
watch(selectedType, (type) => { watch(selectedType, (type) => {
if (!type) { if (!type) {
clearCreationForm() clearCreationForm()
@@ -342,6 +462,7 @@ watch(selectedType, (type) => {
} }
lastSuggestedName.value = creationForm.name lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure) customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
}) })
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
@@ -356,12 +477,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 &&
)) productSelectionsFilled.value &&
!submitting.value,
),
)
const toFieldString = (value: unknown): string => { const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -376,13 +500,12 @@ const toFieldString = (value: unknown): string => {
return '' return ''
} }
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurId = null creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
productSelections.value = []
lastSuggestedName.value = '' lastSuggestedName.value = ''
} }
@@ -391,6 +514,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 (!productSelectionsFilled.value) {
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 +530,15 @@ const submitCreation = async () => {
payload.reference = reference payload.reference = reference
} }
if (creationForm.constructeurId) { payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
payload.constructeurId = creationForm.constructeurId
const normalizedProductIds = productRequirementEntries.value
.map((entry) => productSelections.value[entry.index])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
if (normalizedProductIds.length) {
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0]
} }
const rawPrice = typeof creationForm.prix === 'string' const rawPrice = typeof creationForm.prix === 'string'
@@ -414,7 +550,7 @@ const submitCreation = async () => {
if (rawPrice) { if (rawPrice) {
const parsed = Number(rawPrice) const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) { if (!Number.isNaN(parsed)) {
payload.prix = parsed payload.prix = String(parsed)
} }
} }
@@ -466,6 +602,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 +614,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 +640,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 => {

View File

@@ -0,0 +1,405 @@
<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="row in productRows" :key="row.product.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product)"
:alt="resolvePreviewAlt(row.product)"
/>
</td>
<td class="font-medium">{{ row.product.name }}</td>
<td>{{ row.product.reference || '—' }}</td>
<td>{{ row.product.typeProduct?.name || '—' }}</td>
<td>
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else class="text-sm text-base-content/50"></span>
</td>
<td class="text-right">
{{ formatPrice(row.product.supplierPrice) }}
</td>
<td class="text-right space-x-2">
<NuxtLink
:to="`/product/${row.product.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.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 { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort'
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 { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const searchTerm = ref('')
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'product-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les produits avec les types de produits complets
const normalizedProducts = computed(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return {
...product,
typeProduct: typeProduct || product.typeProduct || null
}
})
})
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 MAX_VISIBLE_SUPPLIERS = 3
const resolveProductSuppliers = (product: Record<string, any>) => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') {
return
}
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) {
return
}
const key = normalized.toLowerCase()
if (seen.has(key)) {
return
}
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) {
return
}
if (Array.isArray(value)) {
value.forEach(collectConstructeurs)
return
}
if (typeof value === 'string') {
pushName(value)
return
}
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) {
collectConstructeurs(record.constructeur)
}
if (Array.isArray(record?.constructeurs)) {
collectConstructeurs(record.constructeurs)
}
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') {
return
}
value
.split(/[,;\\/•·|]+/)
.map((part) => part.trim())
.filter(Boolean)
.forEach(pushName)
}
collectConstructeurs(product?.constructeurs)
collectConstructeurs(product?.constructeur)
collectFromLabel(product?.constructeursLabel)
collectFromLabel(product?.supplierLabel)
collectFromLabel(product?.suppliers)
return names
}
const buildSuppliersDisplay = (product: Record<string, any>) => {
const suppliers = resolveProductSuppliers(product)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return {
suppliers,
visible,
overflow,
tooltip: suppliers.length ? suppliers.join(', ') : '',
}
}
const productRows = computed(() =>
filteredProducts.value.map((product) => ({
product,
suppliers: buildSuppliersDisplay(product),
})),
)
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 Promise.all([
loadProducts(),
loadProductTypes()
])
})
</script>

View File

@@ -0,0 +1,145 @@
<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"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
@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 { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
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 {
isSubmitBlocked,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/products',
filterKey: 'typeProduct',
labels: {
singular: 'produit',
plural: 'produits',
verifying: 'Vérification des produits liés en cours…',
},
})
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,
}
await loadLinkedCount(id)
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (guardSubmitOrNotify()) {
return
}
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>

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>

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>

View File

@@ -0,0 +1,936 @@
<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..."
:initial-options="product?.constructeurs || []"
/>
</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>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<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 { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
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, getCustomFieldValuesByEntity } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
} = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
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 historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
supplierPrice: 'Prix fournisseur',
typeProduct: 'Catégorie',
constructeurIds: 'Fournisseurs',
}
const historyActionLabel = (action: string) => {
if (action === 'create') {
return 'Création'
}
if (action === 'delete') {
return 'Suppression'
}
return 'Modification'
}
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'medium',
timeStyle: 'short',
})
const formatHistoryDate = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return historyDateFormatter.format(date)
}
const formatHistoryValue = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '—'
}
return value.map((item) => formatHistoryValue(item)).join(', ')
}
if (typeof value === 'object') {
const maybeRecord = value as Record<string, unknown>
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
if (name && id) {
return `${name} (#${id})`
}
if (name) {
return name
}
if (id) {
return `#${id}`
}
try {
return JSON.stringify(value)
} catch (error) {
return String(value)
}
}
return String(value)
}
const historyDiffEntries = (entry: ProductHistoryEntry) => {
const diff = entry.diff ?? {}
return Object.entries(diff).map(([field, change]) => {
const label = historyFieldLabels[field] ?? field
const fromLabel = formatHistoryValue(change?.from)
const toLabel = formatHistoryValue(change?.to)
return {
field,
label,
fromLabel,
toLabel,
}
})
}
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
}
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()
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
product.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await hydrateForm()
await refreshDocuments()
await loadHistory(result.data.id)
} 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 = async () => {
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)
: ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
if (editionForm.constructeurIds.length) {
await ensureConstructeurs(editionForm.constructeurIds)
}
}
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 constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const payload: Record<string, any> = {
name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null,
constructeurIds,
}
const rawPrice = typeof editionForm.supplierPrice === 'string'
? editionForm.supplierPrice.trim()
: editionForm.supplierPrice
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
? Number.isNaN(Number(rawPrice))
? null
: String(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
}
const metadata = field.customFieldId
? undefined
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
metadata,
)
if (!result.success) {
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || field.customFieldId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
return failed
}
onMounted(async () => {
await loadProduct()
})
</script>

View File

@@ -0,0 +1,530 @@
<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
}
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
const rawPrice = typeof creationForm.supplierPrice === 'string'
? creationForm.supplierPrice.trim()
: creationForm.supplierPrice
if (rawPrice !== '' && rawPrice !== null && rawPrice !== undefined) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.supplierPrice = String(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.name) {
continue
}
const value = field.value ?? ''
const metadata = field.customFieldId
? undefined
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
metadata,
)
if (!result.success) {
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || field.customFieldId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
return failed
}
onMounted(async () => {
await loadProductTypes()
if (selectedTypeId.value && !selectedType.value) {
await router.replace({
path: route.path,
query: { ...route.query, typeId: undefined },
}).catch(() => {})
}
})
</script>

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) {
@@ -154,6 +187,14 @@ onMounted(async () => {
const typeId = route.params.id const typeId = route.params.id
console.log('=== TYPE DETAIL PAGE LOADING ===') console.log('=== TYPE DETAIL PAGE LOADING ===')
console.log('Loading type with ID:', typeId) console.log('Loading type with ID:', typeId)
console.log('Full route params:', route.params)
if (!typeId) {
console.error('No type ID provided in route')
showError('Aucun identifiant de type fourni')
loading.value = false
return
}
const result = await getMachineTypeById(typeId) const result = await getMachineTypeById(typeId)
console.log('API Result:', result) console.log('API Result:', result)

View File

@@ -52,6 +52,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -70,7 +71,8 @@ const editedType = ref({
maintenanceFrequency: '', maintenanceFrequency: '',
customFields: [], customFields: [],
componentRequirements: [], componentRequirements: [],
pieceRequirements: [] pieceRequirements: [],
productRequirements: []
}) })
const parseOptions = (field = {}) => { const parseOptions = (field = {}) => {
@@ -89,15 +91,32 @@ const parseOptions = (field = {}) => {
return [] return []
} }
const toModelTypeIri = (value) => {
if (!value) {
return undefined
}
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
return value
}
const relationId = extractRelationId(value)
if (relationId) {
return `/api/model_types/${relationId}`
}
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
}
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) {
@@ -109,9 +128,9 @@ const toIntegerOrNull = (value, fallback = null) => {
const normalizeComponentRequirements = (requirements = []) => const normalizeComponentRequirements = (requirements = []) =>
requirements requirements
.filter(req => req?.typeComposantId) .filter(req => req?.typeComposantId || req?.typeComposant)
.map((req, index) => ({ .map((req, index) => ({
typeComposantId: req.typeComposantId, typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
label: req.label?.trim() ? req.label.trim() : undefined, label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1), minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null), maxCount: toIntegerOrNull(req.maxCount, null),
@@ -124,9 +143,24 @@ const normalizeComponentRequirements = (requirements = []) =>
const normalizePieceRequirements = (requirements = []) => const normalizePieceRequirements = (requirements = []) =>
requirements requirements
.filter(req => req?.typePieceId) .filter(req => req?.typePieceId || req?.typePiece)
.map((req, index) => ({ .map((req, index) => ({
typePieceId: req.typePieceId, typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
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 normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId || req?.typeProduct)
.map((req, index) => ({
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
label: req.label?.trim() ? req.label.trim() : undefined, label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0), minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null), maxCount: toIntegerOrNull(req.maxCount, null),
@@ -148,7 +182,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)
@@ -174,7 +209,7 @@ onMounted(async () => {
console.log('=== EDIT TYPE PAGE LOADING ===') console.log('=== EDIT TYPE PAGE LOADING ===')
console.log('Loading type with ID:', typeId) console.log('Loading type with ID:', typeId)
const result = await getMachineTypeById(typeId) const result = await getMachineTypeById(typeId, true)
console.log('API Result:', result) console.log('API Result:', result)
if (result.success) { if (result.success) {
@@ -189,7 +224,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)

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;
@@ -34,6 +47,9 @@ export interface ModelType extends BaseModelTypePayload {
updatedAt: string; updatedAt: string;
category: ModelCategory; category: ModelCategory;
structure: ModelTypeStructure; structure: ModelTypeStructure;
componentSkeleton?: ComponentModelStructure | null;
pieceSkeleton?: PieceModelStructure | null;
productSkeleton?: ProductModelStructure | null;
} }
export interface ModelTypeListParams { export interface ModelTypeListParams {
@@ -52,7 +68,7 @@ export interface ModelTypeListResponse {
limit: number; limit: number;
} }
const ENDPOINT = '/api/model-types'; const ENDPOINT = '/model_types';
function resolveBaseUrl() { function resolveBaseUrl() {
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
@@ -67,12 +83,52 @@ function createOptions<T>(options: FetchOptions<T> = {}) {
}; };
} }
export function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) { const normalizeModelType = (item: any): ModelType => {
if (!item || typeof item !== 'object') {
return item as ModelType;
}
if (!item.structure) {
if (item.category === 'COMPONENT' && item.componentSkeleton) {
item.structure = item.componentSkeleton;
} else if (item.category === 'PIECE' && item.pieceSkeleton) {
item.structure = item.pieceSkeleton;
} else if (item.category === 'PRODUCT' && item.productSkeleton) {
item.structure = item.productSkeleton;
}
}
return item as ModelType;
};
const mapStructureToSkeleton = <T extends Record<string, any>>(payload: T): T => {
if (!payload || typeof payload !== 'object') {
return payload;
}
if (!('structure' in payload)) {
return payload;
}
const structure = (payload as any).structure;
if (!structure) {
return payload;
}
const category = (payload as any).category;
const next = { ...payload } as Record<string, any>;
if (category === 'COMPONENT') {
next.componentSkeleton = structure;
} else if (category === 'PIECE') {
next.pieceSkeleton = structure;
} else if (category === 'PRODUCT') {
next.productSkeleton = structure;
}
delete next.structure;
return next as T;
};
export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const query: Record<string, string | number> = {}; const query: Record<string, string | number> = {};
if (params.q) { if (params.q) {
query.q = params.q; query.name = params.q;
} }
if (params.category) { if (params.category) {
query.category = params.category; query.category = params.category;
@@ -83,36 +139,84 @@ export function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?
if (params.dir) { if (params.dir) {
query.dir = params.dir; query.dir = params.dir;
} }
if (typeof params.limit === 'number') { const hasCategoryFilter = Boolean(params.category);
query.limit = params.limit; const effectiveLimit = typeof params.limit === 'number' ? params.limit : undefined;
} const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
if (typeof params.offset === 'number') {
query.offset = params.offset; if (hasCategoryFilter) {
// Fetch enough items to allow client-side category filtering + pagination.
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200);
query.offset = 0;
} else {
if (typeof params.limit === 'number') {
query.itemsPerPage = params.limit;
}
if (typeof params.offset === 'number') {
query.offset = params.offset;
}
} }
return requestFetch<ModelTypeListResponse>(ENDPOINT, createOptions({ const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
method: 'GET', method: 'GET',
query, query,
signal: opts.signal, signal: opts.signal,
})); }));
const rawItems = Array.isArray(payload?.member)
? payload.member
: Array.isArray(payload?.['hydra:member'])
? payload['hydra:member']
: Array.isArray(payload?.items)
? payload.items
: [];
const filteredItems = params.category
? rawItems.filter((item: any) => item?.category === params.category)
: rawItems;
const total = params.category
? filteredItems.length
: typeof payload?.totalItems === 'number'
? payload.totalItems
: Array.isArray(payload?.items)
? payload.items.length
: rawItems.length;
const items = (params.category && typeof effectiveLimit === 'number'
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
: filteredItems).map(normalizeModelType);
return {
items,
total,
offset: effectiveOffset,
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length,
} satisfies ModelTypeListResponse;
} }
export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) { export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(ENDPOINT, createOptions({ return requestFetch<ModelType>(ENDPOINT, createOptions({
method: 'POST', method: 'POST',
body: payload, headers: {
'Content-Type': 'application/ld+json',
Accept: 'application/ld+json',
},
body: mappedPayload,
signal: opts.signal, signal: opts.signal,
})); })).then(normalizeModelType);
} }
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) { export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({ return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
method: 'PATCH', method: 'PATCH',
body: payload, headers: {
'Content-Type': 'application/merge-patch+json',
Accept: 'application/ld+json',
},
body: mappedPayload,
signal: opts.signal, signal: opts.signal,
})); })).then(normalizeModelType);
} }
export function deleteModelType(id: string, opts: { signal?: AbortSignal } = {}) { export function deleteModelType(id: string, opts: { signal?: AbortSignal } = {}) {
@@ -128,5 +232,5 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({ return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
method: 'GET', method: 'GET',
signal: opts.signal, signal: opts.signal,
})); })).then(normalizeModelType);
} }

View File

@@ -0,0 +1,57 @@
export const RELATION_ID_MAP: Record<string, { key: string; path: string }> = {
siteId: { key: 'site', path: 'sites' },
machineId: { key: 'machine', path: 'machines' },
composantId: { key: 'composant', path: 'composants' },
pieceId: { key: 'piece', path: 'pieces' },
productId: { key: 'product', path: 'products' },
typeMachineId: { key: 'typeMachine', path: 'type_machines' },
typeComposantId: { key: 'typeComposant', path: 'model_types' },
typePieceId: { key: 'typePiece', path: 'model_types' },
typeProductId: { key: 'typeProduct', path: 'model_types' },
};
export const toIri = (path: string, id: string): string => `/api/${path}/${id}`;
export const extractRelationId = (value: unknown): string | null => {
if (!value) {
return null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.includes('/')) {
const parts = trimmed.split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : null;
}
return trimmed;
}
if (typeof value === 'object' && 'id' in (value as Record<string, any>)) {
const id = (value as Record<string, any>).id;
return typeof id === 'string' ? id : null;
}
return null;
};
export const normalizeRelationIds = <T extends Record<string, any>>(payload: T): T => {
if (!payload || typeof payload !== 'object') {
return payload;
}
const next: Record<string, any> = { ...payload };
Object.entries(RELATION_ID_MAP).forEach(([sourceKey, config]) => {
const raw = next[sourceKey];
if (typeof raw !== 'string') {
return;
}
const trimmed = raw.trim();
if (!trimmed) {
return;
}
next[config.key] = toIri(config.path, trimmed);
delete next[sourceKey];
});
return next as T;
};

View File

@@ -0,0 +1,164 @@
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();
if (!trimmed) {
return null;
}
if (trimmed.includes('/')) {
const parts = trimmed.split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : null;
}
return trimmed;
};
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);
}
// Only extract ID if this looks like a constructeur object (has @type or recognizable fields)
// Don't extract ID from component/piece/product objects that happen to be passed in
if (typeof value.id === 'string' && !value.name && !value.typeComposant && !value.typePiece && !value.typeProduct) {
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 & { constructeurs?: string[] } => {
const collected = new Set(uniqueConstructeurIds(
payload?.constructeurIds,
payload?.constructeurId,
payload?.constructeur,
payload?.constructeurs,
));
if (!collected.size) {
const fallbackLists = [
payload?.constructeurIds,
payload?.constructeurs,
];
fallbackLists.forEach((list) => {
if (!Array.isArray(list)) {
return;
}
list.forEach((item) => {
if (typeof item === 'string') {
const id = toStringId(item);
if (id) {
collected.add(id);
}
return;
}
if (isObject(item) && typeof item.id === 'string') {
collected.add(item.id);
}
});
});
}
const ids = Array.from(collected);
const next = { ...payload } as Record<string, any>;
delete next.constructeurId;
delete next.constructeur;
delete next.constructeurs;
delete next.constructeurIds;
if (ids.length) {
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
}
return next as T & { constructeurs?: string[] };
};

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)

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: [],
}) })

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

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}$/

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

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>
`) `)

View File

@@ -1,9 +1,28 @@
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// Lire la version depuis le fichier VERSION à la racine du projet parent
const getAppVersion = (): string => {
try {
const __dirname = dirname(fileURLToPath(import.meta.url))
const versionPath = resolve(__dirname, '..', 'VERSION')
return readFileSync(versionPath, 'utf-8').trim()
} catch {
return '0.0.0'
}
}
const appVersion = process.env.NUXT_PUBLIC_APP_VERSION || getAppVersion()
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement)
devtools: { enabled: true }, devtools: { enabled: true },
devServer: { devServer: {
port: 3001 host: '0.0.0.0',
port: 3000
}, },
modules: [ modules: [
[ [
@@ -18,11 +37,14 @@ export default defineNuxtConfig({
] ]
], ],
runtimeConfig: { runtimeConfig: {
apiBaseUrl: process.env.NUXT_API_BASE_URL
|| process.env.NUXT_PUBLIC_API_BASE_URL
|| 'http://localhost/api',
public: { public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api',
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001', appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System', appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
appVersion: process.env.NUXT_PUBLIC_APP_VERSION || '0.1.0', appVersion: appVersion,
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000', apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000', requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true', enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',

3
package-lock.json generated
View File

@@ -3754,7 +3754,6 @@
"integrity": "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==", "integrity": "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.0", "@eslint-community/eslint-utils": "^4.9.0",
"@typescript-eslint/types": "^8.44.0", "@typescript-eslint/types": "^8.44.0",
@@ -11673,7 +11672,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -12483,6 +12481,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",