10 Commits

Author SHA1 Message Date
Matthieu
34af59d054 feat: show product thumbnails in catalogue list
Display the primary product document (image/pdf) as the leading column in the catalogue table for quicker visual identification.
2025-11-05 15:38:44 +01:00
Matthieu
d860f24e69 feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
2025-11-05 15:35:02 +01:00
Matthieu
3af6c50892 feat: retire la colonne catégorie des catalogues 2025-10-31 10:04:40 +01:00
Matthieu
dc2bc6c70a feat: afficher fournisseur dans les libellés front 2025-10-31 10:02:27 +01:00
Matthieu
ef9a8b5b7b fix: format plain french numbers with dot grouping 2025-10-30 17:35:44 +01:00
Matthieu
53dab13489 feat: standardize contact formatting 2025-10-30 11:35:20 +01:00
Matthieu
f59255e684 fix: de-duplicate constructeur ids before machine update 2025-10-30 11:34:58 +01:00
Matthieu
76cd3fac98 feat: improve piece structure editor UX 2025-10-30 11:34:19 +01:00
Matthieu
4c714b3647 feat: drag & drop des champs personnalisés 2025-10-28 18:08:14 +01:00
Matthieu
b752fba69a feat: gérer les constructeurs multiples 2025-10-28 16:37:10 +01:00
56 changed files with 7795 additions and 589 deletions

View File

@@ -114,6 +114,61 @@
</ul>
</Transition>
</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">
<button
type="button"
@@ -233,7 +288,7 @@
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Constructeurs
Fournisseurs
</NuxtLink>
</li>
</ul>
@@ -356,6 +411,67 @@
</ul>
</Transition>
</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
class="relative"
@mouseenter="setDropdown('component-desktop')"
@@ -488,7 +604,7 @@
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Constructeurs
Fournisseurs
</NuxtLink>
</li>
</ul>

View File

@@ -32,8 +32,22 @@
Défini dans le catalogue
</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="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
<span
v-if="component.typeMachineComponentRequirement"
class="badge badge-outline badge-sm"
@@ -90,19 +104,117 @@
</div>
</div>
<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
v-if="isEditMode"
class="w-full"
:model-value="component.constructeurId || component.constructeur?.id || null"
:model-value="componentConstructeurIds"
@update:model-value="handleConstructeurChange"
/>
<div v-else class="input input-bordered input-sm bg-base-200">
<div class="flex flex-col">
<span class="font-medium">{{ component.constructeur?.name || 'Non défini' }}</span>
<span class="text-xs text-gray-500">
{{ [component.constructeur?.email, component.constructeur?.phone].filter(Boolean).join(' • ') }}
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in componentConstructeursDisplay"
: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
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>
@@ -331,6 +443,12 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { useConstructeurs } from '~/composables/useConstructeurs'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
const props = defineProps({
component: {
@@ -406,6 +524,132 @@ const childComponents = computed(() => {
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) => {
if (!structure || typeof structure !== 'object') {
return []
@@ -415,16 +659,39 @@ const extractStructureCustomFields = (structure) => {
}
function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name.trim() : ''
const normalizedType = typeof type === 'string' ? type : ''
const normalizedName =
typeof name === 'string' ? name.trim().toLowerCase() : ''
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : ''
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) {
const result = []
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') {
return
}
@@ -444,6 +711,7 @@ function deduplicateFieldDefinitions(definitions) {
if (key) {
seen.add(key)
}
field.orderIndex = resolveOrderIndex(field)
result.push(field)
})
@@ -484,10 +752,16 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
if (!matchedValue) {
return {
...field,
value: field?.value ?? ''
value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
}
}
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
)
return {
...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
@@ -497,7 +771,8 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
fieldId ??
null,
customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? ''
value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
}
})
@@ -537,23 +812,30 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
required: entry.customField?.required ?? false,
options: entry.customField?.options ?? [],
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) {
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 result = []
fields.forEach((field) => {
const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
@@ -571,12 +853,14 @@ function dedupeMergedFields(fields) {
const key = fieldId || nameKey
if (!key) {
field.orderIndex = resolveOrderIndex(field)
result.push(field)
return
}
const existing = seen.get(key)
if (!existing) {
field.orderIndex = resolveOrderIndex(field)
seen.set(key, field)
result.push(field)
return
@@ -594,11 +878,15 @@ function dedupeMergedFields(fields) {
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field)
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
)
seen.set(key, existing)
}
})
return result
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
}
const componentDefinitionSources = computed(() => {
@@ -686,7 +974,17 @@ watch(
)
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()
}
@@ -723,7 +1021,10 @@ const toggleCollapse = () => {
}
const updateComponent = () => {
emit('update', props.component)
emit('update', {
...props.component,
constructeurIds: componentConstructeurIds.value,
})
}
function resolveFieldKey(field, index) {

View File

@@ -5,6 +5,7 @@
:depth="0"
:component-types="availableComponentTypes"
:piece-types="availablePieceTypes"
:product-types="availableProductTypes"
:lock-type="lockRootType"
:locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents"
@@ -24,6 +25,7 @@ import {
} from '~/shared/modelUtils'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
defineOptions({ name: 'ComponentModelStructureEditor' })
@@ -62,9 +64,11 @@ const previousLockedLabel = ref(props.rootTypeLabel || '')
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
const availableComponentTypes = computed(() => componentTypes.value ?? [])
const availableProductTypes = computed(() => productTypes.value ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
@@ -187,9 +191,10 @@ const syncFromProps = (value: any) => {
return
}
const hydrated = hydrateStructureForEditor(value)
localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces
localStructure.subcomponents = hydrated.subcomponents
localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces
localStructure.products = hydrated.products
localStructure.subcomponents = hydrated.subcomponents
localStructure.typeComposantId = hydrated.typeComposantId
localStructure.typeComposantLabel = hydrated.typeComposantLabel
localStructure.modelId = hydrated.modelId
@@ -243,6 +248,9 @@ onMounted(async () => {
if (!availableComponentTypes.value.length) {
loaders.push(loadComponentTypes())
}
if (!availableProductTypes.value.length) {
loaders.push(loadProductTypes())
}
if (loaders.length) {
await Promise.allSettled(loaders)
}

View File

@@ -66,6 +66,44 @@
</div>
</section>
<section v-if="assignment.products.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Produits requis par le squelette' : 'Produits associés à ce sous-composant' }}
</h4>
<p class="text-xs text-base-content/70">
Sélectionnez les produits catalogue à lier sur chaque position définie.
</p>
</header>
<div
v-for="productAssignment in assignment.products"
:key="productAssignment.path"
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describeProductRequirement(productAssignment.definition) }}
</p>
<p v-if="!getProductOptions(productAssignment.definition).length" class="text-[11px] text-error">
Aucun produit disponible pour cette catégorie.
</p>
</div>
<SearchSelect
:model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment.definition)"
:loading="productsLoading"
size="xs"
placeholder="Rechercher un produit..."
:empty-text="getProductOptions(productAssignment.definition).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel"
:option-description="productOptionDescription"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.subcomponents.length" class="space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
@@ -81,9 +119,11 @@
:key="subAssignment.path"
:assignment="subAssignment"
:pieces="pieces"
:products="products"
:components="components"
:components-loading="componentsLoading"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:depth="depth + 1"
/>
</section>
@@ -95,6 +135,7 @@ import { computed, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue';
import type {
ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode,
} from '~/shared/types/inventory';
@@ -122,17 +163,36 @@ interface PieceOption {
} | 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 {
path: string;
definition: ComponentModelPiece;
selectedPieceId: string;
}
export interface StructureProductAssignment {
path: string;
definition: ComponentModelProduct;
selectedProductId: string;
}
export interface StructureAssignmentNode {
path: string;
definition: ComponentModelStructureNode;
selectedComponentId: string;
pieces: StructurePieceAssignment[];
products: StructureProductAssignment[];
subcomponents: StructureAssignmentNode[];
}
@@ -140,17 +200,21 @@ const props = withDefaults(
defineProps<{
assignment: StructureAssignmentNode;
pieces: PieceOption[] | null;
products: ProductOption[] | null;
components: ComponentOption[] | null;
depth?: number;
componentsLoading?: boolean;
piecesLoading?: boolean;
productsLoading?: boolean;
}>(),
{
depth: 0,
pieces: () => [],
products: () => [],
components: () => [],
componentsLoading: false,
piecesLoading: false,
productsLoading: false,
},
);
@@ -269,6 +333,102 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
};
const getProductOptions = (definition: ComponentModelProduct) => {
const requiredTypeId =
definition.typeProductId ||
(definition as any).typeProduct?.id ||
definition.familyCode ||
null;
return (props.products || []).filter((product) => {
if (!product || typeof product !== 'object') {
return false;
}
if (!requiredTypeId) {
return true;
}
if (definition.typeProductId || (definition as any).typeProduct?.id) {
return (
product.typeProductId === requiredTypeId ||
product.typeProduct?.id === requiredTypeId
);
}
if (definition.familyCode) {
return (
product.typeProduct?.code === requiredTypeId ||
product.typeProductId === requiredTypeId
);
}
return false;
});
};
const productOptionLabel = (product?: ProductOption | null) => {
if (!product) {
return 'Produit';
}
return product.name || product.reference || 'Produit';
};
const productOptionDescription = (product?: ProductOption | null) => {
if (!product) {
return '';
}
const parts: string[] = [];
const typeLabel =
product.typeProduct?.name || product.typeProduct?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (product.reference) {
parts.push(`Ref. ${product.reference}`);
}
return parts.join(' • ');
};
const describeProductRequirement = (definition: ComponentModelProduct) => {
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed);
}
};
const options = getProductOptions(definition);
const fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role);
addPart(
definition.typeProductLabel ||
(definition as any).typeProduct?.name ||
fallbackType?.name,
);
const family =
definition.familyCode ||
(definition as any).typeProduct?.code ||
fallbackType?.code ||
null;
if (family) {
addPart(`Famille ${family}`);
}
if (parts.length === 0) {
addPart(fallbackType?.name);
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`);
}
}
if (parts.length === 0 && definition.typeProductId) {
addPart(`#${definition.typeProductId}`);
}
return parts.length ? parts.join(' • ') : 'Produit du squelette';
};
const requirementLabel = computed(() => {
const definition = props.assignment.definition || {};
const alias = definition.alias || definition.typeComposantLabel;
@@ -377,4 +537,20 @@ watch(
},
{ deep: true, immediate: true },
);
watch(
() => [props.products, props.assignment.products],
() => {
for (const productAssignment of props.assignment.products) {
const options = getProductOptions(productAssignment.definition);
if (
productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = '';
}
}
},
{ deep: true, immediate: true },
);
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="space-y-2 constructeur-select">
<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">
<input
v-model="searchTerm"
@@ -26,20 +26,24 @@
v-if="options.length === 0"
class="px-3 py-2 text-xs text-gray-500"
>
Aucun constructeur trouvé
Aucun fournisseur trouvé
</div>
<button
v-for="option in options"
:key="option.id"
type="button"
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">
<span class="font-medium">{{ option.name }}</span>
<span class="text-xs text-gray-500">
{{ [option.email, option.phone].filter(Boolean).join(' • ') || '—' }}
</span>
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col">
<span class="font-medium">{{ option.name }}</span>
<span class="text-xs text-gray-500">
{{ formatConstructeurContact(option) || '—' }}
</span>
</div>
<IconLucideCheck v-if="isSelected(option.id)" class="w-4 h-4 text-primary" aria-hidden="true" />
</div>
</button>
</div>
@@ -49,16 +53,31 @@
</button>
</div>
<div v-if="selectedConstructeur" class="text-xs text-gray-500">
<span class="font-medium">{{ selectedConstructeur.name }}</span>
<span v-if="selectedConstructeur.email"> {{ selectedConstructeur.email }}</span>
<span v-if="selectedConstructeur.phone"> {{ selectedConstructeur.phone }}</span>
<div class="flex flex-wrap gap-2 min-h-[1.5rem]">
<span v-if="!selectedConstructeurs.length" class="text-sm text-gray-500">
Aucun fournisseur sélectionné
</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>
<dialog class="modal" :class="{ 'modal-open': openCreateModal }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Nouveau constructeur
Nouveau fournisseur
</h3>
<form @submit.prevent="handleCreate">
<div class="form-control mb-3">
@@ -69,7 +88,7 @@
v-model="createForm.email"
class="mb-3"
label="Email"
placeholder="ex: contact@constructeur.com"
placeholder="ex: contact@fournisseur.com"
autocomplete="email"
/>
<FieldPhone
@@ -94,89 +113,131 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import type { PropType } from 'vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
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({
modelValue: {
type: String,
default: null
type: Array as PropType<string[]>,
default: () => [],
},
label: {
type: String,
default: ''
default: '',
},
placeholder: {
type: String,
default: 'Sélectionner ou créer un constructeur...'
}
default: 'Sélectionner ou créer un fournisseur...',
},
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const { constructeurs, searchConstructeurs, createConstructeur } = useConstructeurs()
const searchTerm = ref('')
const openDropdown = ref(false)
const openCreateModal = ref(false)
const creating = ref(false)
const options = ref([])
let searchTimeout = null
const options = ref<ConstructeurSummary[]>([])
const selectedIds = ref<string[]>([])
let searchTimeout: ReturnType<typeof setTimeout> | null = null
let lastSearchTerm = ''
const applyOptions = (items = []) => {
const selectedId = props.modelValue
const cloned = [...items]
const limited = cloned.slice(0, 10)
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)
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
const seen = new Map<string, ConstructeurSummary>()
items.forEach((item) => {
if (item && typeof item === 'object' && typeof item.id === 'string') {
seen.set(item.id, item)
}
}
})
return Array.from(seen.values())
}
options.value = limited
const applyOptions = (items: ConstructeurSummary[] = []) => {
const normalized = uniqueOptions(items)
const limited = normalized.slice(0, 10)
selectedIds.value.forEach((id) => {
if (!limited.some((item) => item.id === id)) {
const match =
normalized.find((item) => item.id === id) ||
constructeurs.value.find((item) => item.id === id)
if (match) {
if (limited.length >= 10) {
limited.pop()
}
limited.unshift(match)
}
}
})
options.value = uniqueOptions(limited)
}
const createForm = ref({
name: '',
email: '',
phone: ''
phone: '',
})
const selectedConstructeur = computed(() =>
constructeurs.value.find(item => item.id === props.modelValue) || null
)
const optionLookup = computed(() => {
const map = new Map<string, ConstructeurSummary>()
constructeurs.value.forEach((item: ConstructeurSummary) => {
map.set(item.id, item)
})
options.value.forEach((item) => {
map.set(item.id, item)
})
return map
})
watch(
() => props.modelValue,
(newValue) => {
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 }
)
const selectedConstructeurs = computed<ConstructeurSummary[]>(() => {
if (!selectedIds.value.length) {
return []
}
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) {
applyOptions(constructeurs.value)
applyOptions(constructeurs.value as ConstructeurSummary[])
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)
if (result.success) {
applyOptions(result.data || [])
@@ -186,14 +247,18 @@ async function ensureOptionsLoaded (force = false) {
const onSearch = () => {
openDropdown.value = true
clearTimeout(searchTimeout)
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(async () => {
if (!searchTerm.value && constructeurs.value.length) {
applyOptions(constructeurs.value)
applyOptions(constructeurs.value as ConstructeurSummary[])
lastSearchTerm = ''
return
}
if (searchTerm.value === lastSearchTerm) { return }
if (searchTerm.value === lastSearchTerm) {
return
}
const result = await searchConstructeurs(searchTerm.value)
if (result.success) {
applyOptions(result.data || [])
@@ -202,10 +267,18 @@ const onSearch = () => {
}, 250)
}
const selectOption = (option) => {
emit('update:modelValue', option.id)
openDropdown.value = false
searchTerm.value = option.name
const toggleOption = (option: ConstructeurSummary) => {
const ids = new Set(selectedIds.value)
if (ids.has(option.id)) {
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 = () => {
@@ -216,31 +289,24 @@ const closeCreateModal = () => {
const handleCreate = async () => {
creating.value = true
const payload = { ...createForm.value }
if (!payload.phone) { delete payload.phone }
if (!payload.email) { delete payload.email }
if (!payload.phone) {
delete payload.phone
}
if (!payload.email) {
delete payload.email
}
const result = await createConstructeur(payload)
creating.value = false
if (result.success) {
emit('update:modelValue', result.data.id)
searchTerm.value = result.data.name
emitSelection([...selectedIds.value, result.data.id])
searchTerm.value = ''
closeCreateModal()
await ensureOptionsLoaded(true)
}
}
watch(
constructeurs,
(list) => {
applyOptions(list || [])
if (!searchTerm.value) {
lastSearchTerm = ''
}
},
{ immediate: true }
)
const clickHandler = (event) => {
const element = event.target
const clickHandler = (event: Event) => {
const element = event.target as HTMLElement | null
if (element && element.closest) {
if (
element.closest('.menu') ||
@@ -254,6 +320,39 @@ const clickHandler = (event) => {
openDropdown.value = false
}
watch(
() => props.modelValue,
(newValue) => {
selectedIds.value = uniqueConstructeurIds(newValue)
},
{ immediate: true },
)
watch(
selectedIds,
async (ids) => {
if (!ids.length) {
return
}
const missing = ids.some((id) => !optionLookup.value.get(id))
if (missing) {
await ensureOptionsLoaded(true)
}
},
{ immediate: true },
)
watch(
constructeurs,
(list) => {
applyOptions((list as ConstructeurSummary[]) || [])
if (!searchTerm.value) {
lastSearchTerm = ''
}
},
{ immediate: true },
)
onMounted(() => {
window.addEventListener('click', clickHandler)
ensureOptionsLoaded()
@@ -261,6 +360,24 @@ onMounted(() => {
onBeforeUnmount(() => {
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>

View File

@@ -5,7 +5,7 @@
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in customFields"
v-for="field in sortedCustomFields"
:key="field.id"
class="form-control"
>
@@ -81,7 +81,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, onMounted, watch, computed } from 'vue'
const props = defineProps({
customFields: {
@@ -101,6 +101,17 @@ const props = defineProps({
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
const fieldValues = reactive({})

View File

@@ -32,7 +32,7 @@
<div>
<p class="font-medium">Informations générales</p>
<p class="text-xs text-base-content/60">
Nom, site et constructeur de la machine.
Nom, site et fournisseur de la machine.
</p>
</div>
</label>

View File

@@ -48,6 +48,12 @@
>
Rattachée à {{ piece.parentComponentName }}
</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div>
@@ -67,23 +73,34 @@
}}</span>
</div>
<div>
<span class="font-medium">Constructeur:</span>
<span v-if="!isEditMode" class="ml-2">
<span class="font-medium">{{
piece.constructeur?.name || "Non défini"
}}</span>
<span v-if="piece.constructeur" class="block text-xs text-gray-500">
{{
[piece.constructeur?.email, piece.constructeur?.phone]
.filter(Boolean)
.join(" ")
}}
<span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2">
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in pieceConstructeursDisplay"
: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>
</span>
</div>
<ConstructeurSelect
v-else
class="w-full"
:model-value="piece.constructeurId || piece.constructeur?.id || null"
:model-value="pieceConstructeurIds"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange"
/>
</div>
@@ -102,6 +119,120 @@
pieceData.prix ? `${pieceData.prix}` : "Non défini"
}}</span>
</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>
<!-- Champs personnalisés de la pièce -->
@@ -353,6 +484,8 @@
<script setup>
import { reactive, onMounted, watch, ref, computed } from "vue";
import ConstructeurSelect from "./ConstructeurSelect.vue";
import ProductSelect from "~/components/ProductSelect.vue";
import { useConstructeurs } from "~/composables/useConstructeurs";
import { useCustomFields } from "~/composables/useCustomFields";
import { useToast } from "~/composables/useToast";
import { useDocuments } from "~/composables/useDocuments";
@@ -361,6 +494,12 @@ import { canPreviewDocument, isImageDocument, isPdfDocument } from "~/utils/docu
import DocumentUpload from "~/components/DocumentUpload.vue";
import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue";
import IconLucidePackage from "~icons/lucide/package";
import { useProducts } from "~/composables/useProducts";
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
} from "~/shared/constructeurUtils";
const props = defineProps({
piece: {
@@ -384,6 +523,7 @@ const pieceData = reactive({
name: props.piece.name || "",
reference: props.piece.reference || "",
prix: props.piece.prix || "",
productId: props.piece.product?.id || props.piece.productId || null,
});
const selectedFiles = ref([]);
@@ -439,16 +579,36 @@ const extractStructureCustomFields = (structure) => {
return Array.isArray(customFields) ? customFields : [];
};
function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name : '';
const normalizedType = typeof type === 'string' ? type : '';
const normalizedName =
typeof name === 'string' ? name.trim().toLowerCase() : '';
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : '';
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) {
const result = [];
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') {
return;
}
@@ -465,6 +625,7 @@ function deduplicateFieldDefinitions(definitions) {
if (key) {
seen.add(key);
}
field.orderIndex = resolveOrderIndex(field);
result.push(field);
});
@@ -512,9 +673,15 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
return {
...field,
value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
};
}
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
);
return {
...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
@@ -525,6 +692,7 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
null,
customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
};
});
@@ -571,22 +739,31 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
options: entry.customField?.options ?? [],
value: entry.value ?? '',
customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
});
}
});
return merged;
return merged.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
}
function dedupeMergedFields(fields) {
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 result = [];
fields.forEach((field) => {
const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') {
return;
}
@@ -615,12 +792,14 @@ function dedupeMergedFields(fields) {
const key = fieldId || nameKey;
if (!key) {
field.orderIndex = resolveOrderIndex(field);
result.push(field);
return;
}
const existing = seen.get(key);
if (!existing) {
field.orderIndex = resolveOrderIndex(field);
seen.set(key, field);
result.push(field);
return;
@@ -637,11 +816,17 @@ function dedupeMergedFields(fields) {
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field);
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
);
seen.set(key, existing);
}
});
return result;
return result.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
}
const pieceDefinitionSources = computed(() => {
@@ -716,8 +901,271 @@ const candidateCustomFields = computed(() => {
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) => {
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();
};
@@ -967,11 +1415,25 @@ const setCustomFieldValue = (fieldValueId, value, field) => {
const updatePiece = () => {
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", {
...props.piece,
...pieceData,
prix: prixValue && prixValue !== "" ? parseFloat(prixValue) : null,
constructeurId: props.piece.constructeurId || null,
prix: parsedPrice,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
});
};

View File

@@ -1,7 +1,69 @@
<template>
<div class="space-y-4">
<div class="space-y-6">
<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">
Champs personnalisés
</h3>
@@ -9,173 +71,348 @@
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</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.
</p>
<div v-else class="space-y-2">
<div
v-for="(field, index) in localFields"
:key="`custom-field-${index}`"
class="border border-base-200 rounded-md p-3 space-y-2"
<ul v-else class="space-y-2" role="list">
<li
v-for="(field, index) in fields"
:key="field.uid"
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-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<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-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
Obligatoire
</div>
<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 class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<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>
<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 class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
Obligatoire
</div>
<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>
<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>
</li>
</ul>
</section>
</div>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus'
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({
modelValue: {
type: Object,
default: () => ({ customFields: [] })
}
})
defineOptions({ name: 'PieceModelStructureEditor' })
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 {
return JSON.parse(JSON.stringify(input ?? fallback))
} catch (error) {
return JSON.parse(JSON.stringify(fallback))
return JSON.parse(JSON.stringify(value ?? fallback)) as T
} catch {
return JSON.parse(JSON.stringify(fallback)) as T
}
}
const extractRest = (structure = {}) => {
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(structure).filter(([key]) => key !== 'customFields')
const entries = Object.entries(structure).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return safeClone(Object.fromEntries(entries), {})
}
const toEditorField = (input = {}) => ({
name: typeof input.name === 'string' ? input.name : '',
type: typeof input.type === 'string' && input.type ? input.type : 'text',
required: Boolean(input.required),
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)
let uidCounter = 0
const createUid = (scope: 'field' | 'product'): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
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) => {
return ensureArray(fields)
.map((field) => {
const name = typeof field.name === 'string' ? field.name.trim() : ''
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
const source = Array.isArray(structure?.products) ? structure?.products : []
return source.map((product) => toEditorProduct(product))
}
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) {
return null
}
const type = field.type || 'text'
const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required)
let options
if (type === 'select') {
const raw = typeof field.optionsText === 'string' ? field.optionsText : ''
const parsed = raw
.split(/\r?\n/)
.map(option => option.trim())
.filter(option => option.length > 0)
options = parsed.length > 0 ? parsed : undefined
const payload: PieceModelCustomField = {
name,
type,
required,
orderIndex: index,
}
const normalized = { name, type, required }
if (options) {
normalized.options = options
if (type === 'select') {
const options = normalizeLineEndings(field.optionsText)
.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({
...clone(extraState.rest, {}),
customFields: normalizeFields(props.modelValue?.customFields)
})
const serializeStructure = (structure?: PieceModelStructure | null): string => {
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
}
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => {
const customFields = normalizeFields(localFields.value)
const payload = {
...clone(extraState.rest, {}),
customFields
}
const payload = buildPayload(fields.value, products.value, restState.value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
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(
() => props.modelValue,
(value) => {
localFields.value = hydrateFields(value)
extraState.rest = clone(extractRest(value), {})
lastEmitted = JSON.stringify({
...clone(extraState.rest, {}),
customFields: normalizeFields(value?.customFields)
})
const incomingSerialized = serializeStructure(value)
if (incomingSerialized === lastEmitted) {
return
}
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 = () => {
localFields.value = [...localFields.value, toEditorField()]
const next = fields.value.slice()
next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
}
const removeField = (index) => {
localFields.value = localFields.value.filter((_, i) => i !== index)
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
</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
v-for="(field, index) in node.customFields"
: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-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="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
@@ -121,6 +139,69 @@
</div>
</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">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">
@@ -233,6 +314,7 @@
:depth="depth + 1"
:component-types="componentTypes"
:piece-types="pieceTypes"
:product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
@remove="removeSubComponent(index)"
@@ -250,7 +332,7 @@ import { computed, ref, watch } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
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' })
@@ -263,6 +345,7 @@ type ModelTypeOption = {
type EditableStructureNode = ComponentModelStructureNode & {
customFields?: any[]
pieces?: ComponentModelPiece[]
products?: ComponentModelProduct[]
}
const props = withDefaults(defineProps<{
@@ -270,6 +353,7 @@ const props = withDefaults(defineProps<{
depth?: number
componentTypes?: ModelTypeOption[]
pieceTypes?: ModelTypeOption[]
productTypes?: ModelTypeOption[]
isRoot?: boolean
lockType?: boolean
lockedTypeLabel?: string
@@ -279,6 +363,7 @@ const props = withDefaults(defineProps<{
depth: 0,
componentTypes: () => [],
pieceTypes: () => [],
productTypes: () => [],
isRoot: false,
lockType: false,
lockedTypeLabel: '',
@@ -290,6 +375,7 @@ const emit = defineEmits(['remove'])
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
@@ -354,6 +440,16 @@ const pieceTypeMap = computed(() => {
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) => {
if (!id) return ''
return formatModelTypeOption(componentTypeMap.value.get(id))
@@ -364,16 +460,26 @@ const getPieceTypeLabel = (id?: string) => {
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) =>
formatModelTypeOption(type)
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
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 (key === 'subcomponents') {
props.node.subcomponents = []
} else if (key === 'products') {
props.node.products = []
} else {
(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[]) => {
if (!Array.isArray(pieces)) {
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) => {
syncComponentType(component)
}
@@ -506,20 +652,116 @@ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>)
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 = () => {
ensureArray('customFields')
const nextIndex = Array.isArray(props.node.customFields)
? props.node.customFields.length
: 0
props.node.customFields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
orderIndex: nextIndex,
})
reindexCustomFields()
}
const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1)
reindexCustomFields()
}
const addPiece = () => {
@@ -538,6 +780,20 @@ const removePiece = (index: number) => {
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 = () => {
if (!canManageSubcomponents.value) {
return
@@ -560,6 +816,8 @@ const removeSubComponent = (index: number) => {
const draggingPieceIndex = ref<number | null>(null)
const pieceDropTargetIndex = ref<number | null>(null)
const draggingProductIndex = ref<number | null>(null)
const productDropTargetIndex = ref<number | null>(null)
const draggingSubcomponentIndex = ref<number | null>(null)
const subcomponentDropTargetIndex = ref<number | null>(null)
@@ -581,6 +839,11 @@ const resetPieceDragState = () => {
pieceDropTargetIndex.value = null
}
const resetProductDragState = () => {
draggingProductIndex.value = null
productDropTargetIndex.value = null
}
const onPieceDragStart = (index: number, event: DragEvent) => {
draggingPieceIndex.value = index
pieceDropTargetIndex.value = index
@@ -633,6 +896,58 @@ const pieceReorderClass = (index: number) => {
return ''
}
const onProductDragStart = (index: number, event: DragEvent) => {
draggingProductIndex.value = index
productDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onProductDragEnter = (index: number) => {
if (draggingProductIndex.value === null) {
return
}
productDropTargetIndex.value = index
}
const onProductDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onProductDrop = (index: number) => {
if (!Array.isArray(props.node.products)) {
resetProductDragState()
return
}
const from = draggingProductIndex.value
const to = index
if (from === null || to === null) {
resetProductDragState()
return
}
moveItemInPlace(props.node.products, from, to)
resetProductDragState()
}
const onProductDragEnd = () => {
resetProductDragState()
}
const productReorderClass = (index: number) => {
if (draggingProductIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingProductIndex.value !== null &&
productDropTargetIndex.value === index &&
draggingProductIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const resetSubcomponentDragState = () => {
draggingSubcomponentIndex.value = null
subcomponentDropTargetIndex.value = null
@@ -723,6 +1038,34 @@ watch(
{ 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(
() => [props.lockedTypeLabel, props.lockType],
() => {

View File

@@ -24,11 +24,26 @@
<div v-if="expanded" class="space-y-4">
<div
v-for="(field, fieldIndex) in fields"
:key="fieldIndex"
class="border border-gray-200 rounded-lg p-4 bg-gray-50"
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
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 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
type="button"
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 IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
const props = defineProps({
modelValue: {
@@ -183,8 +199,57 @@ const fields = computed({
set: value => emit('update:modelValue', value)
})
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const expanded = ref(false)
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(
() => props.expandAllTrigger,
@@ -223,26 +288,25 @@ const toggleField = (index) => {
}
const addField = () => {
fields.value = [
...fields.value,
{
name: '',
type: '',
required: false,
optionsText: ''
}
]
const next = Array.isArray(fields.value) ? fields.value.slice() : []
next.push(createEmptyField())
fields.value = applyOrderIndex(next)
expandedFields.value.push(true)
expanded.value = true
}
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)
}
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) => {
@@ -250,4 +314,43 @@ const updateOptions = (index, value) => {
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>

View File

@@ -26,6 +26,11 @@
@update:model-value="(value) => (formData.pieceRequirements = value)"
/>
<TypeEditProductRequirementsSection
:model-value="formData.productRequirements"
@update:model-value="(value) => (formData.productRequirements = value)"
/>
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
</form>
</template>
@@ -38,6 +43,7 @@ import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSectio
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
const props = defineProps({
modelValue: {
@@ -54,14 +60,34 @@ const emit = defineEmits(['update:modelValue', 'submit'])
const deepClone = value => JSON.parse(JSON.stringify(value))
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const withNormalizedOrder = (items = []) => {
if (!Array.isArray(items)) { return [] }
return items
.map((item, index) => {
const clone = deepClone(item)
const currentOrder =
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
clone.orderIndex = currentOrder
if (typeof clone?.__key !== 'string' || !clone.__key) {
clone.__key = createFieldKey()
}
return clone
})
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((item, index) => ({ ...item, orderIndex: index }))
}
const createDefaultForm = (source = {}) => ({
name: source.name || '',
description: source.description || '',
category: source.category || '',
maintenanceFrequency: source.maintenanceFrequency || '',
customFields: deepClone(source.customFields || []),
componentRequirements: deepClone(source.componentRequirements || []),
pieceRequirements: deepClone(source.pieceRequirements || [])
customFields: withNormalizedOrder(source.customFields || []),
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
productRequirements: withNormalizedOrder(source.productRequirements || []),
})
const formData = reactive(createDefaultForm(props.modelValue))

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>Familles de composants:</strong> {{ type.componentRequirements?.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">
<strong>Description:</strong> {{ type.description }}
</p>

View File

@@ -51,7 +51,7 @@ import {
import { useToast } from "~/composables/useToast";
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(
defineProps<{
@@ -210,8 +210,15 @@ const onOffsetChange = (value: number) => {
}
};
const resolveCategoryBasePath = (category: ModelCategory) =>
category === "COMPONENT" ? "/component-category" : "/piece-category";
const resolveCategoryBasePath = (category: ModelCategory) => {
if (category === "COMPONENT") {
return "/component-category";
}
if (category === "PIECE") {
return "/piece-category";
}
return "/product-category";
};
const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value);

View File

@@ -32,6 +32,7 @@
>
<option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option>
<option value="PRODUCT">Produits</option>
</select>
</div>
@@ -84,7 +85,7 @@
</div>
<div
v-else
v-else-if="form.category === 'PIECE'"
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
@@ -93,6 +94,17 @@
</p>
<PieceModelStructureEditor v-model="pieceStructure" />
</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>
</section>
@@ -114,12 +126,16 @@ import ComponentModelStructureEditor from '~/components/ComponentModelStructureE
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
import {
clonePieceStructure,
cloneProductStructure,
cloneStructure,
defaultPieceStructure,
defaultProductStructure,
defaultStructure,
formatPieceStructurePreview,
formatProductStructurePreview,
formatStructurePreview,
normalizePieceStructureForSave,
normalizeProductStructureForSave,
normalizeStructureForEditor,
normalizeStructureForSave,
} from '~/shared/modelUtils'
@@ -171,6 +187,7 @@ const nameInput = ref<HTMLInputElement | null>(null)
const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
const productStructure = ref(normalizeProductStructureForSave(defaultProductStructure()))
const generateCodeFromName = (name: string) => {
const fallback = 'type'
@@ -196,10 +213,19 @@ const resetStructures = (incomingStructure: ModelTypePayload['structure'], categ
return
}
pieceStructure.value = normalizePieceStructureForSave(
incomingStructure && props.initialData?.category === 'PIECE'
? incomingStructure
: defaultPieceStructure(),
if (category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(
incomingStructure && props.initialData?.category === 'PIECE'
? incomingStructure
: defaultPieceStructure(),
)
return
}
productStructure.value = normalizeProductStructureForSave(
incomingStructure && props.initialData?.category === 'PRODUCT'
? cloneProductStructure(incomingStructure)
: defaultProductStructure(),
)
}
@@ -263,10 +289,19 @@ const handleSubmit = () => {
return
}
if (form.category === 'PIECE') {
emit('submit', {
...common,
category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
})
return
}
emit('submit', {
...common,
category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
category: 'PRODUCT',
structure: normalizeProductStructureForSave(cloneProductStructure(productStructure.value)),
})
}
@@ -311,11 +346,16 @@ watch(
if (category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
}
if (category === 'PRODUCT') {
productStructure.value = normalizeProductStructureForSave(defaultProductStructure())
}
},
)
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
const productStructurePreview = computed(() => formatProductStructurePreview(productStructure.value))
onMounted(() => {
nextTick(() => nameInput.value?.focus())

View File

@@ -33,7 +33,6 @@
<thead>
<tr class="text-base-content/70">
<th scope="col">Nom</th>
<th scope="col">Catégorie</th>
<th scope="col">Notes</th>
<th scope="col" class="w-32 text-right">Actions</th>
</tr>
@@ -41,7 +40,6 @@
<tbody>
<tr v-for="item in items" :key="item.id">
<td class="font-medium">{{ item.name }}</td>
<td>{{ categoryLabel(item.category) }}</td>
<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-else class="text-base-content/50"></span>
@@ -133,6 +131,7 @@ const emit = defineEmits<{
const categoryDictionary: Record<ModelCategory, string> = {
COMPONENT: 'Composants',
PIECE: 'Pièces',
PRODUCT: 'Produits',
};
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;

View File

@@ -78,12 +78,13 @@
import { computed } from 'vue';
import IconLucidePlus from '~icons/lucide/plus';
import IconLucideSearch from '~icons/lucide/search';
import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc';
const props = defineProps<{
category: 'COMPONENT' | 'PIECE';
category: ModelCategory;
search: string;
sort: SortField;
dir: SortDirection;
@@ -92,16 +93,17 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: 'update:category', value: 'COMPONENT' | 'PIECE'): void;
(e: 'update:category', value: ModelCategory): void;
(e: 'update:search', value: string): void;
(e: 'update:sort', value: SortField): void;
(e: 'update:dir', value: SortDirection): void;
(e: 'create'): void;
}>();
const categories: Array<{ label: string; value: 'COMPONENT' | 'PIECE' }> = [
const categories: Array<{ label: string; value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' },
{ label: 'Pièces', value: 'PIECE' },
{ label: 'Produits', value: 'PRODUCT' },
];
const onSearch = (event: Event) => {

View File

@@ -18,7 +18,7 @@
<div class="flex items-center gap-2 text-gray-600">
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
<span>{{ site.contactPhone }}</span>
<span>{{ formattedContactPhone }}</span>
</div>
<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 IconLucidePhone from '~icons/lucide/phone'
import IconLucideUser from '~icons/lucide/user'
import { formatPhone } from '~/utils/formatters/phone'
const props = defineProps({
site: {
@@ -64,4 +65,9 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete'])
const machineCount = computed(() => props.site?.machines?.length || 0)
const formattedContactPhone = computed(() => {
const value = props.site?.contactPhone ?? ''
const formatted = formatPhone(value)
return formatted || value || '—'
})
</script>

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
const composants = ref([])
const loading = ref(false)
@@ -27,7 +28,7 @@ const loadComposants = async () => {
const createComposant = async (composantData) => {
loading.value = true
try {
const result = await post('/composants', composantData)
const result = await post('/composants', buildConstructeurRequestPayload(composantData))
if (result.success) {
composants.value.push(result.data)
const displayName = result.data?.name
@@ -48,7 +49,7 @@ const loadComposants = async () => {
const updateComposantData = async (id, composantData) => {
loading.value = true
try {
const result = await patch(`/composants/${id}`, composantData)
const result = await patch(`/composants/${id}`, buildConstructeurRequestPayload(composantData))
if (result.success) {
const updated = result.data
const index = composants.value.findIndex(comp => comp.id === id)

View File

@@ -19,7 +19,7 @@ export function useConstructeurs () {
}
return result
} 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 }
} finally {
loading.value = false
@@ -36,14 +36,14 @@ export function useConstructeurs () {
const result = await post('/constructeurs', data)
if (result.success) {
constructeurs.value = [result.data, ...constructeurs.value]
showSuccess(`Constructeur "${result.data.name}" créé`)
showSuccess(`Fournisseur "${result.data.name}" créé`)
} else if (result.error) {
showError(result.error)
}
return result
} catch (error) {
console.error('Erreur lors de la création du constructeur:', error)
showError('Impossible de créer le constructeur')
console.error('Erreur lors de la création du fournisseur:', error)
showError('Impossible de créer le fournisseur')
return { success: false, error: error.message }
} finally {
loading.value = false
@@ -59,14 +59,14 @@ export function useConstructeurs () {
if (index !== -1) {
constructeurs.value[index] = result.data
}
showSuccess(`Constructeur "${result.data.name}" mis à jour`)
showSuccess(`Fournisseur "${result.data.name}" mis à jour`)
} else if (result.error) {
showError(result.error)
}
return result
} catch (error) {
console.error('Erreur lors de la mise à jour du constructeur:', error)
showError('Impossible de mettre à jour le constructeur')
console.error('Erreur lors de la mise à jour du fournisseur:', error)
showError('Impossible de mettre à jour le fournisseur')
return { success: false, error: error.message }
} finally {
loading.value = false
@@ -79,14 +79,14 @@ export function useConstructeurs () {
const result = await del(`/constructeurs/${id}`)
if (result.success) {
constructeurs.value = constructeurs.value.filter(item => item.id !== id)
showSuccess('Constructeur supprimé')
showSuccess('Fournisseur supprimé')
} else if (result.error) {
showError(result.error)
}
return result
} catch (error) {
console.error('Erreur lors de la suppression du constructeur:', error)
showError('Impossible de supprimer le constructeur')
console.error('Erreur lors de la suppression du fournisseur:', error)
showError('Impossible de supprimer le fournisseur')
return { success: false, error: error.message }
} finally {
loading.value = false

View File

@@ -60,6 +60,11 @@ export function useDocuments () {
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 = {}) => {
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
@@ -140,6 +145,7 @@ export function useDocuments () {
loadDocumentsByMachine,
loadDocumentsByComponent,
loadDocumentsByPiece,
loadDocumentsByProduct,
uploadDocuments,
deleteDocument
}

View File

@@ -5,6 +5,20 @@ import { useApi } from './useApi'
const machineTypes = ref([])
const loading = ref(false)
const normalizeRequirementList = (value) => (Array.isArray(value) ? value : [])
const normalizeMachineType = (type) => {
if (!type || typeof type !== 'object') {
return type
}
return {
...type,
componentRequirements: normalizeRequirementList(type.componentRequirements),
pieceRequirements: normalizeRequirementList(type.pieceRequirements),
productRequirements: normalizeRequirementList(type.productRequirements),
}
}
export function useMachineTypesApi () {
const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi()
@@ -14,7 +28,9 @@ export function useMachineTypesApi () {
try {
const result = await get('/types/machines')
if (result.success) {
machineTypes.value = result.data
machineTypes.value = Array.isArray(result.data)
? result.data.map(normalizeMachineType)
: []
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
}
} catch (error) {
@@ -29,7 +45,7 @@ export function useMachineTypesApi () {
try {
const result = await post('/types/machines', typeData)
if (result.success) {
machineTypes.value.push(result.data)
machineTypes.value.push(normalizeMachineType(result.data))
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
}
return result
@@ -46,9 +62,10 @@ export function useMachineTypesApi () {
try {
const result = await patch(`/types/machines/${id}`, typeData)
if (result.success) {
const normalized = normalizeMachineType(result.data)
const index = machineTypes.value.findIndex(type => type.id === id)
if (index !== -1) {
machineTypes.value[index] = result.data
machineTypes.value[index] = normalized
}
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
}
@@ -91,7 +108,7 @@ export function useMachineTypesApi () {
const result = await get(`/types/machines/${id}`)
if (result.success) {
// Ajouter au cache local
machineTypes.value.push(result.data)
machineTypes.value.push(normalizeMachineType(result.data))
}
return result
} catch (error) {

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
const machines = ref([])
const loading = ref(false)
@@ -76,7 +77,7 @@ export function useMachines () {
const createMachine = async (machineData) => {
loading.value = true
try {
const result = await post('/machines', machineData)
const result = await post('/machines', buildConstructeurRequestPayload(machineData))
if (result.success) {
const createdMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) ||
@@ -105,13 +106,13 @@ export function useMachines () {
// Les composants et pièces seront créés automatiquement
}
return await createMachine(machineWithStructure)
return await createMachine(buildConstructeurRequestPayload(machineWithStructure))
}
const updateMachineData = async (id, machineData) => {
loading.value = true
try {
const result = await patch(`/machines/${id}`, machineData)
const result = await patch(`/machines/${id}`, buildConstructeurRequestPayload(machineData))
if (result.success) {
const updatedMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) ||

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
const pieces = ref([])
const loading = ref(false)
@@ -27,7 +28,7 @@ export function usePieces () {
const createPiece = async (pieceData) => {
loading.value = true
try {
const result = await post('/pieces', pieceData)
const result = await post('/pieces', buildConstructeurRequestPayload(pieceData))
if (result.success) {
pieces.value.push(result.data)
const displayName = result.data?.name
@@ -48,7 +49,7 @@ export function usePieces () {
const updatePieceData = async (id, pieceData) => {
loading.value = true
try {
const result = await patch(`/pieces/${id}`, pieceData)
const result = await patch(`/pieces/${id}`, buildConstructeurRequestPayload(pieceData))
if (result.success) {
const updated = result.data
const index = pieces.value.findIndex(piece => piece.id === id)

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,184 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
const products = ref([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const error = ref(null)
const replaceInCache = (item) => {
if (!item?.id) {
return false
}
const index = products.value.findIndex((product) => product.id === item.id)
if (index === -1) {
products.value.unshift(item)
return true
}
const clone = products.value.slice()
clone[index] = item
products.value = clone
return false
}
export function useProducts () {
const { showError } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadProducts = async (options = {}) => {
if (loading.value) {
return {
success: true,
data: { items: products.value, total: total.value },
}
}
if (loaded.value && !options.force) {
return {
success: true,
data: { items: products.value, total: total.value },
}
}
loading.value = true
error.value = null
try {
const result = await get('/products?limit=100')
if (result.success) {
const items = Array.isArray(result.data?.items) ? result.data.items : []
products.value = items
total.value = typeof result.data?.total === 'number' ? result.data.total : items.length
loaded.value = true
} else if (result.error) {
error.value = result.error
showError(`Impossible de charger les produits: ${result.error}`)
}
return result
} catch (err) {
console.error('Erreur lors du chargement des produits:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(`Impossible de charger les produits: ${message}`)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const createProduct = async (payload) => {
loading.value = true
error.value = null
try {
const result = await post('/products', payload)
if (result.success && result.data) {
const added = replaceInCache(result.data)
if (added) {
total.value += 1
}
} else if (result.error) {
error.value = result.error
showError(result.error)
}
return result
} catch (err) {
console.error('Erreur lors de la création du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(message)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const updateProduct = async (id, payload) => {
loading.value = true
error.value = null
try {
const result = await patch(`/products/${id}`, payload)
if (result.success && result.data) {
replaceInCache(result.data)
} else if (result.error) {
error.value = result.error
showError(result.error)
}
return result
} catch (err) {
console.error('Erreur lors de la mise à jour du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(message)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const deleteProduct = async (id) => {
loading.value = true
error.value = null
try {
const result = await del(`/products/${id}`)
if (result.success) {
const removed = products.value.find((product) => product.id === id)
products.value = products.value.filter((product) => product.id !== id)
total.value = Math.max(0, total.value - 1)
} else if (result.error) {
error.value = result.error
showError(result.error)
}
return result
} catch (err) {
console.error('Erreur lors de la suppression du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
error.value = message
showError(message)
return { success: false, error: message }
} finally {
loading.value = false
}
}
const getProduct = async (id, options = {}) => {
if (!options.force) {
const cached = products.value.find((product) => product.id === id)
if (cached) {
return { success: true, data: cached }
}
}
try {
const result = await get(`/products/${id}`)
if (result.success && result.data) {
replaceInCache(result.data)
}
return result
} catch (err) {
console.error('Erreur lors du chargement du produit:', err)
const message = err?.message ?? 'Erreur inconnue'
return { success: false, error: message }
}
}
const clearProductsCache = () => {
products.value = []
total.value = 0
loaded.value = false
error.value = null
}
return {
products,
total,
loading,
loaded,
error,
loadProducts,
createProduct,
updateProduct,
deleteProduct,
getProduct,
clearProductsCache,
}
}

View File

@@ -34,7 +34,7 @@
v-model="searchTerm"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, référence ou catégorie…"
placeholder="Nom ou référence…"
/>
</label>
<div class="flex items-center gap-2">
@@ -93,8 +93,8 @@
<tr>
<th class="w-24">Aperçu</th>
<th>Nom</th>
<th>Catégorie</th>
<th>Référence</th>
<th>Type de composant</th>
<th>Actions</th>
</tr>
</thead>
@@ -107,8 +107,8 @@
/>
</td>
<td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.typeComposant?.name || '—' }}</td>
<td>{{ component.reference || '—' }}</td>
<td>{{ resolveComponentType(component) }}</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
@@ -180,6 +180,17 @@ const resolvePreviewAlt = (component: Record<string, any>) => {
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 blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks)
@@ -232,11 +243,9 @@ const visibleComposants = computed(() => {
? 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)
reference.includes(term)
)
})
: [...source]

View File

@@ -89,19 +89,19 @@
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
placeholder="Référence interne ou constructeur"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Constructeur</span>
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurId"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
placeholder="Rechercher un constructeur..."
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
@@ -404,6 +404,7 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { getFileIcon } from '~/utils/fileIcons'
@@ -423,6 +424,7 @@ interface CustomFieldInput {
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const route = useRoute()
@@ -443,12 +445,11 @@ const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurId: null as string | null,
constructeurIds: [] as string[],
prix: '' as string,
})
@@ -651,7 +652,11 @@ watch(
editionForm.name = currentComponent.name || ''
editionForm.reference = currentComponent.reference || ''
editionForm.constructeurId = currentComponent.constructeur?.id || currentComponent.constructeurId || null
editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent,
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
currentComponent.constructeur ? [currentComponent.constructeur] : [],
)
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
customFieldInputs.value = buildCustomFieldInputs(
@@ -691,7 +696,7 @@ const submitEdition = async () => {
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
payload.constructeurId = editionForm.constructeurId || null
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
if (rawPrice) {
const parsed = Number(rawPrice)
@@ -751,6 +756,7 @@ const buildCustomFieldInputs = (
...definition,
customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null,
orderIndex: definition.orderIndex,
}
}
@@ -760,8 +766,14 @@ const buildCustomFieldInputs = (
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null,
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
}
@@ -775,11 +787,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field) => normalizeCustomField(field))
.map((field, index) => normalizeCustomField(field, index))
.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') {
return null
}
@@ -797,7 +810,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId
: 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 => {

View File

@@ -62,19 +62,19 @@
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Référence interne ou constructeur"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Constructeur</span>
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurId"
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="submitting || !selectedType"
placeholder="Rechercher un constructeur..."
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
@@ -148,6 +148,18 @@
</ul>
</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">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1">
@@ -189,14 +201,16 @@
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
>
<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>
<ComponentStructureAssignmentNode
v-else-if="structureAssignments"
:assignment="structureAssignments"
:pieces="availablePieces"
:products="availableProducts"
:components="availableComponents"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
/>
<p v-else class="text-xs text-error">
@@ -335,12 +349,15 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type {
ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructure,
ComponentModelStructureNode,
} from '~/shared/types/inventory'
@@ -366,6 +383,11 @@ const {
loadPieces,
loading: piecesLoading,
} = usePieces()
const {
products: productCatalogRef,
loadProducts,
loading: productsLoading,
} = useProducts()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
@@ -376,7 +398,7 @@ const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
reference: '' as string,
constructeurId: null as string | null,
constructeurIds: [] as string[],
prix: '' as string,
})
const lastSuggestedName = ref('')
@@ -386,9 +408,10 @@ const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
const availableProducts = computed(() => productCatalogRef.value ?? [])
const availableComponents = computed(() => componentCatalogRef.value ?? [])
const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value,
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
)
watch(
@@ -485,6 +508,21 @@ const extractPiecesFromNode = (
)
}
const extractProductsFromNode = (
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
): ComponentModelProduct[] => {
if (!definition || typeof definition !== 'object') {
return []
}
const raw = Array.isArray((definition as any).products)
? (definition as any).products
: []
return raw.filter(
(item: unknown): item is ComponentModelProduct =>
!!item && typeof item === 'object',
)
}
const buildAssignmentNode = (
definition: ComponentModelStructureNode | ComponentModelStructure,
path: string,
@@ -495,6 +533,12 @@ const buildAssignmentNode = (
selectedPieceId: '',
}))
const products = extractProductsFromNode(definition).map((product, index) => ({
path: `${path}:product-${index}`,
definition: product,
selectedProductId: '',
}))
const subcomponents = extractSubcomponents(definition).map(
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
)
@@ -504,6 +548,7 @@ const buildAssignmentNode = (
definition,
selectedComponentId: '',
pieces,
products,
subcomponents,
}
}
@@ -521,7 +566,7 @@ const hasAssignments = (node: StructureAssignmentNode | null): boolean => {
if (!node) {
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 node.subcomponents.some((child) => hasAssignments(child))
@@ -538,13 +583,21 @@ const isAssignmentNodeComplete = (
const piecesComplete = node.pieces.every(
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
)
const productsComplete = node.products.every(
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
)
const subcomponentsComplete = node.subcomponents.every(
(child) =>
!!child.selectedComponentId &&
child.selectedComponentId.length > 0 &&
isAssignmentNodeComplete(child, false),
)
return piecesComplete && subcomponentsComplete && (isRootNode || !!node.selectedComponentId)
return (
piecesComplete &&
productsComplete &&
subcomponentsComplete &&
(isRootNode || !!node.selectedComponentId)
)
}
const structureSelectionsComplete = computed(() => {
@@ -587,6 +640,15 @@ const sanitizePieceDefinition = (definition: ComponentModelPiece) =>
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 = (
root: StructureAssignmentNode | null,
) => {
@@ -608,6 +670,16 @@ const serializeStructureAssignments = (
}),
)
const serializedProducts = assignment.products
.filter((product) => !!product.selectedProductId)
.map((product) =>
stripNullish({
path: product.path,
definition: sanitizeProductDefinition(product.definition),
selectedProductId: product.selectedProductId,
}),
)
const serializedSubcomponents = assignment.subcomponents
.map((child) => serializeNode(child, false))
.filter((child) => Object.keys(child).length > 0)
@@ -623,6 +695,9 @@ const serializeStructureAssignments = (
if (serializedPieces.length) {
base.pieces = serializedPieces
}
if (serializedProducts.length) {
base.products = serializedProducts
}
if (serializedSubcomponents.length) {
base.subcomponents = serializedSubcomponents
}
@@ -633,6 +708,7 @@ const serializeStructureAssignments = (
const serializedRoot = serializeNode(root, true)
if (
(!serializedRoot.pieces || serializedRoot.pieces.length === 0) &&
(!serializedRoot.products || serializedRoot.products.length === 0) &&
(!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
) {
return null
@@ -681,6 +757,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.pieces) ? structure.pieces : []
}
const getStructureProducts = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.products) ? structure.products : []
}
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
if (Array.isArray(structure?.subcomponents)) {
return structure.subcomponents
@@ -708,6 +788,25 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
return parts.length ? parts.join(' • ') : 'Pièce'
}
const resolveProductLabel = (product: Record<string, any>) => {
const parts: string[] = []
if (product.role) {
parts.push(product.role)
}
if (product.typeProduct?.name) {
parts.push(product.typeProduct.name)
} else if (product.typeProductLabel) {
parts.push(product.typeProductLabel)
} else if (product.typeProduct?.code) {
parts.push(`Catégorie ${product.typeProduct.code}`)
} else if (product.familyCode) {
parts.push(`Catégorie ${product.familyCode}`)
} else if (product.typeProductId) {
parts.push(`#${product.typeProductId}`)
}
return parts.length ? parts.join(' • ') : 'Produit'
}
const resolveSubcomponentLabel = (node: Record<string, any>) => {
const parts: string[] = []
if (node.alias) {
@@ -737,7 +836,7 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
const clearCreationForm = () => {
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurId = null
creationForm.constructeurIds = []
creationForm.prix = ''
lastSuggestedName.value = ''
structureAssignments.value = null
@@ -758,8 +857,8 @@ const submitCreation = async () => {
payload.reference = reference
}
if (creationForm.constructeurId) {
payload.constructeurId = creationForm.constructeurId
if (creationForm.constructeurIds.length) {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
const rawPrice = typeof creationForm.prix === 'string'
@@ -775,8 +874,17 @@ const submitCreation = async () => {
}
}
const rootProductSelection =
structureAssignments.value?.products?.find(
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
) ?? null
if (rootProductSelection?.selectedProductId) {
payload.productId = rootProductSelection.selectedProductId.trim()
}
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
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
}
@@ -828,6 +936,7 @@ onMounted(async () => {
loadComponentTypes(),
loadPieces(),
loadComposants(),
loadProducts(),
])
})
@@ -840,6 +949,7 @@ interface CustomFieldInput {
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const fieldKey = (field: CustomFieldInput, index: number) =>
@@ -851,11 +961,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field) => normalizeCustomField(field))
.map((field, index) => normalizeCustomField(field, index))
.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') {
return null
}
@@ -873,7 +984,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId
: 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 => {

View File

@@ -3,15 +3,15 @@
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">
Constructeurs
Fournisseurs
</h1>
<p class="text-sm text-gray-500">
Gérez les constructeurs et leurs coordonnées.
Gérez les fournisseurs et leurs coordonnées.
</p>
</div>
<button class="btn btn-primary" @click="openCreateModal">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Nouveau constructeur
Nouveau fournisseur
</button>
</div>
@@ -46,11 +46,11 @@
<div v-if="loading" class="py-16 text-center text-sm text-gray-500">
<span class="loading loading-spinner loading-lg mb-2" />
Chargement des constructeurs...
Chargement des fournisseurs...
</div>
<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 v-else class="overflow-x-auto">
@@ -69,7 +69,7 @@
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
<td>{{ constructeur.name }}</td>
<td>{{ constructeur.email || '—' }}</td>
<td>{{ constructeur.phone || '—' }}</td>
<td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
@@ -90,7 +90,7 @@
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} constructeur
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
</h3>
<form class="space-y-4" @submit.prevent="saveConstructeur">
<div class="form-control">
@@ -122,6 +122,7 @@ import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useToast } from '~/composables/useToast'
import { formatPhone } from '~/utils/formatters/phone'
import IconLucidePlus from '~icons/lucide/plus'
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
@@ -150,6 +151,14 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 300)
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
function debounce (fn, delay) {
let timeout
return (...args) => {
@@ -202,7 +211,7 @@ const saveConstructeur = async () => {
}
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)
if (!result.success && result.error) {
showError(result.error)

View File

@@ -151,7 +151,7 @@
class="w-4 h-4 text-secondary"
aria-hidden="true"
/>
<span>{{ site.contactPhone }}</span>
<span>{{ formatPhoneDisplay(site.contactPhone) }}</span>
</div>
<div class="flex items-start gap-2">
<IconLucideMapPinned
@@ -423,6 +423,12 @@
selectedMachineType.pieceRequirements?.length || 0
}}</span>
</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">
<span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{
@@ -465,6 +471,7 @@ import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone'
const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
@@ -516,6 +523,14 @@ const totalMachines = computed(() => {
}, 0)
})
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
const filteredSites = computed(() => {
let filtered = sites.value

View File

@@ -58,6 +58,13 @@
pièces</span
>
</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 class="card-actions justify-end mt-4">
<button
@@ -99,6 +106,7 @@ import { useToast } from "~/composables/useToast";
import IconLucidePlus from "~icons/lucide/plus";
import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box";
const { machineTypes, loading, loadMachineTypes, deleteMachineType } =
useMachineTypesApi();

View File

@@ -65,6 +65,10 @@
<IconLucideList class="h-4 w-4" aria-hidden="true" />
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
</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>
</article>
@@ -85,6 +89,7 @@ import { useToast } from '~/composables/useToast'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideList from '~icons/lucide/list'
import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast()
@@ -100,7 +105,8 @@ const createEmptyType = () => ({
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
pieceRequirements: []
pieceRequirements: [],
productRequirements: []
})
const draftType = ref(createEmptyType())
@@ -139,12 +145,15 @@ const parseOptions = (field = {}) => {
const normalizeCustomFields = (fields = []) =>
fields
.filter(field => field?.name && field.name.trim() !== '')
.map(field => ({
.map((field, index) => ({
name: field.name,
type: field.type || '',
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) => {
if (value === '' || value === undefined || value === null) {
@@ -184,6 +193,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId)
.map((req, index) => ({
typeProductId: req.typeProductId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const buildPayload = typeData => ({
name: typeData.name,
description: typeData.description,
@@ -191,7 +215,8 @@ const buildPayload = typeData => ({
maintenanceFrequency: typeData.maintenanceFrequency,
customFields: normalizeCustomFields(typeData.customFields),
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements)
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
productRequirements: normalizeProductRequirements(typeData.productRequirements)
})
const resetForm = () => {

File diff suppressed because it is too large Load Diff

View File

@@ -90,13 +90,17 @@
<span class="font-medium">Groupes de pièces :</span>
<span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</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="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span>
</span>
</div>
<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"
>
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 || "—" }}
</div>
<div>
Constructeur :
Fournisseur :
{{ findComponentById(entry.composantId)?.constructeur?.name || findComponentById(entry.composantId)?.constructeurName || "—" }}
</div>
@@ -298,16 +302,136 @@
Référence : {{ findPieceById(entry.pieceId)?.reference || "—" }}
</div>
<div>
Constructeur :
Fournisseur :
{{ findPieceById(entry.pieceId)?.constructeur?.name || findPieceById(entry.pieceId)?.constructeurName || "—" }}
</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 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="p-4 space-y-4">
<div class="flex items-center justify-between gap-3">
@@ -486,6 +610,73 @@
Aucun groupe de pièces à configurer pour ce type.
</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
v-if="machinePreview.issues.length && machinePreview.status !== 'ready'"
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
@@ -551,9 +742,11 @@ import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import SearchSelect from '~/components/common/SearchSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
import IconLucideEye from '~icons/lucide/eye'
@@ -566,6 +759,7 @@ const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
const { composants, loadComposants, loading: composantsLoading } = useComposants()
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
const { products, loadProducts, loading: productsLoading } = useProducts()
const toast = useToast()
const submitting = ref(false)
@@ -579,6 +773,7 @@ const newMachine = reactive({
const componentRequirementSelections = reactive({})
const pieceRequirementSelections = reactive({})
const productRequirementSelections = reactive({})
const selectedMachineType = computed(() => {
if (!newMachine.typeMachineId) {
@@ -604,7 +799,12 @@ const machineTypeDescription = (type) => {
}
const componentCount = type.componentRequirements?.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(' • ')
}
@@ -630,6 +830,17 @@ const pieceById = computed(() => {
const componentInventory = computed(() => composants.value || [])
const pieceInventory = computed(() => pieces.value || [])
const productInventory = computed(() => products.value || [])
const productById = computed(() => {
const map = new Map()
;(productInventory.value || []).forEach((product) => {
if (product?.id) {
map.set(product.id, product)
}
})
return map
})
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -904,6 +1115,11 @@ const componentOptionDescription = (component) => {
if (machineAssignments.length) {
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(' • ')
}
@@ -929,9 +1145,83 @@ const pieceOptionDescription = (piece) => {
if (componentAssignments.length) {
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(' • ')
}
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 entries = getComponentRequirementEntries(requirement.id)
const entry = entries[index]
@@ -971,6 +1261,13 @@ const findPieceById = (id) => {
}
return pieceById.value.get(id) || null
}
const findProductById = (id) => {
if (!id) {
return null
}
return productById.value.get(id) || null
}
const getStatusBadgeClass = (status) => {
if (status === 'ready') {
return 'badge-success'
@@ -1003,6 +1300,7 @@ const resolvePieceRequirementTypeLabel = (requirement, entry) => {
const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || []
const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || []
const getProductRequirementEntries = requirementId => productRequirementSelections[requirementId] || []
const createComponentSelectionEntry = (requirement, source = null) => ({
typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null,
@@ -1016,6 +1314,170 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
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 = () => {
Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key]
@@ -1023,6 +1485,9 @@ const clearRequirementSelections = () => {
Object.keys(pieceRequirementSelections).forEach((key) => {
delete pieceRequirementSelections[key]
})
Object.keys(productRequirementSelections).forEach((key) => {
delete productRequirementSelections[key]
})
}
const addComponentSelectionEntry = (requirement) => {
@@ -1061,6 +1526,51 @@ const removePieceSelectionEntry = (requirementId, 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) => {
if (!isPlainObject(source)) {
return {}
@@ -1113,6 +1623,7 @@ const validateRequirementSelections = (type) => {
const errors = []
const componentLinksPayload = []
const pieceLinksPayload = []
const productLinksPayload = []
for (const requirement of type.componentRequirements || []) {
const entries = getComponentRequirementEntries(requirement.id)
@@ -1216,6 +1727,58 @@ const validateRequirementSelections = (type) => {
})
}
const { stats: productStats } = buildProductRequirementStats(type)
for (const requirement of type.productRequirements || []) {
const entries = getProductRequirementEntries(requirement.id)
const max = requirement.maxCount ?? null
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
}
entries.forEach((entry) => {
if (!entry.productId) {
errors.push(`Sélectionner un produit pour "${requirement.label || requirement.typeProduct?.name || 'Produits'}".`)
return
}
const product = findProductById(entry.productId)
if (!product) {
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
return
}
const requiredTypeId = requirement.typeProductId || requirement.typeProduct?.id || null
const productTypeId =
product.typeProductId ||
product.typeProduct?.id ||
entry.typeProductId ||
null
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
return
}
const payload = {
requirementId: requirement.id,
productId: entry.productId,
}
Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry))
productLinksPayload.push(payload)
})
}
productStats.forEach((stat) => {
stat.issues
.filter((issue) => issue.kind === 'error')
.forEach((issue) => {
errors.push(issue.message)
})
})
if (errors.length > 0) {
return { valid: false, error: errors[0] }
}
@@ -1224,6 +1787,7 @@ const validateRequirementSelections = (type) => {
valid: true,
componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload,
productLinks: productLinksPayload,
}
}
@@ -1425,20 +1989,24 @@ const machinePreview = computed(() => {
issues,
completed,
total: entries.length,
status
}
})
status
}
})
const { stats: productGroups } = buildProductRequirementStats(type)
const aggregatedIssues = [
...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })),
...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 = [
baseStatus,
...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')
@@ -1455,11 +2023,14 @@ const machinePreview = computed(() => {
},
componentGroups,
pieceGroups,
productGroups,
type: {
name: type.name,
category: type.category || null,
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,
ready: overallStatus === 'ready',
@@ -1508,6 +2079,7 @@ const handleIssueClick = (issue) => {
const initializeRequirementSelections = (type) => {
const componentRequirements = type.componentRequirements || []
const pieceRequirements = type.pieceRequirements || []
const productRequirements = type.productRequirements || []
componentRequirements.forEach((requirement) => {
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
@@ -1528,6 +2100,19 @@ const initializeRequirementSelections = (type) => {
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 () => {
@@ -1553,10 +2138,14 @@ const finalizeMachineCreation = async () => {
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 pieceLinks = []
let productLinks = []
if (hasRequirements) {
const validationResult = validateRequirementSelections(type)
@@ -1566,6 +2155,7 @@ const finalizeMachineCreation = async () => {
}
componentLinks = validationResult.componentLinks
pieceLinks = validationResult.pieceLinks
productLinks = validationResult.productLinks
}
const payload = {
@@ -1573,7 +2163,8 @@ const finalizeMachineCreation = async () => {
...(hasRequirements
? {
componentLinks,
pieceLinks
pieceLinks,
productLinks
}
: {})
}
@@ -1621,7 +2212,8 @@ onMounted(async () => {
loadSites(),
loadMachineTypes(),
loadComposants(),
loadPieces()
loadPieces(),
loadProducts()
])
})
</script>

View File

@@ -33,7 +33,7 @@
v-model="searchTerm"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, référence ou catégorie…"
placeholder="Nom ou référence…"
/>
</label>
<div class="flex items-center gap-2">
@@ -92,8 +92,8 @@
<tr>
<th class="w-24">Aperçu</th>
<th>Nom</th>
<th>Catégorie</th>
<th>Référence</th>
<th>Type de pièce</th>
<th>Actions</th>
</tr>
</thead>
@@ -106,8 +106,8 @@
/>
</td>
<td>{{ piece.name || 'Pièce sans nom' }}</td>
<td>{{ piece.typePiece?.name || '—' }}</td>
<td>{{ piece.reference || '—' }}</td>
<td>{{ resolvePieceType(piece) }}</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
@@ -182,6 +182,17 @@ const resolvePreviewAlt = (piece: Record<string, any>) => {
return 'Aperçu du document'
}
const resolvePieceType = (piece: Record<string, any>) => {
const type = piece?.typePiece
if (type?.name) {
return type.name
}
if (piece?.typePieceLabel) {
return piece.typePieceLabel
}
return '—'
}
const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks)
@@ -234,11 +245,9 @@ const visiblePieces = computed(() => {
? 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)
reference.includes(term)
)
})
: [...source]

View File

@@ -89,19 +89,19 @@
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
placeholder="Référence interne ou constructeur"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Constructeur</span>
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurId"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
placeholder="Rechercher un constructeur..."
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
@@ -123,6 +123,36 @@
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<ProductSelect
v-model="editionForm.productId"
:disabled="saving"
:type-product-id="primaryProductRequirement?.typeProductId || null"
helper-text="Un produit valide est requis pour cette pièce."
/>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
@@ -356,6 +386,7 @@ import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
@@ -365,7 +396,8 @@ import { useDocuments } from '~/composables/useDocuments'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
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'
interface PieceCatalogType extends ModelType {
@@ -382,6 +414,7 @@ interface CustomFieldInput {
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const route = useRoute()
@@ -407,8 +440,9 @@ const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurId: null as string | null,
constructeurIds: [] as string[],
prix: '' as string,
productId: null as string | null,
})
const customFieldInputs = ref<CustomFieldInput[]>([])
@@ -540,6 +574,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
@@ -552,12 +622,15 @@ const requiredCustomFieldsFilled = computed(() =>
}),
)
const canSubmit = computed(() => Boolean(
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
!saving.value,
))
const canSubmit = computed(() =>
Boolean(
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
(!requiresProductSelection.value || editionForm.productId) &&
!saving.value,
),
)
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
@@ -602,8 +675,13 @@ watch(
editionForm.name = currentPiece.name || ''
editionForm.reference = currentPiece.reference || ''
editionForm.constructeurId = currentPiece.constructeur?.id || currentPiece.constructeurId || null
editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece,
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
customFieldInputs.value = buildCustomFieldInputs(
currentType?.structure ?? null,
@@ -630,6 +708,11 @@ const submitEdition = async () => {
return
}
if (requiresProductSelection.value && !editionForm.productId) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
@@ -642,7 +725,13 @@ const submitEdition = async () => {
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
payload.constructeurId = editionForm.constructeurId || null
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const selectedProductId =
typeof editionForm.productId === 'string'
? editionForm.productId.trim()
: ''
payload.productId = selectedProductId || null
if (rawPrice) {
const parsed = Number(rawPrice)
@@ -701,6 +790,7 @@ const buildCustomFieldInputs = (
...definition,
customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null,
orderIndex: definition.orderIndex,
}
}
@@ -710,8 +800,14 @@ const buildCustomFieldInputs = (
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null,
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
}
@@ -725,11 +821,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field) => normalizeCustomField(field))
.map((field, index) => normalizeCustomField(field, index))
.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') {
return null
}
@@ -750,7 +847,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
? rawField.customFieldValueId
: 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 => {
@@ -825,7 +924,11 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
return String(defaultValue)
}
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,

View File

@@ -62,19 +62,19 @@
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Référence interne ou constructeur"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Constructeur</span>
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurId"
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="submitting || !selectedType"
placeholder="Rechercher un constructeur..."
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
@@ -96,6 +96,36 @@
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<ProductSelect
v-model="creationForm.productId"
:disabled="submitting || !selectedType"
:type-product-id="primaryProductRequirement?.typeProductId || null"
helper-text="Un produit est requis pour cette pièce."
/>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
@@ -253,6 +283,7 @@ 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 ProductSelect from '~/components/ProductSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
@@ -260,7 +291,8 @@ import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
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'
interface PieceCatalogType extends ModelType {
@@ -283,8 +315,9 @@ const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
reference: '' as string,
constructeurId: null as string | null,
constructeurIds: [] as string[],
prix: '' as string,
productId: null as string | null,
})
const lastSuggestedName = ref('')
@@ -331,6 +364,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
@@ -342,6 +411,7 @@ watch(selectedType, (type) => {
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
creationForm.productId = null
})
const requiredCustomFieldsFilled = computed(() =>
@@ -356,12 +426,15 @@ const requiredCustomFieldsFilled = computed(() =>
}),
)
const canSubmit = computed(() => Boolean(
selectedType.value &&
creationForm.name &&
requiredCustomFieldsFilled.value &&
!submitting.value,
))
const canSubmit = computed(() =>
Boolean(
selectedType.value &&
creationForm.name &&
requiredCustomFieldsFilled.value &&
(!requiresProductSelection.value || creationForm.productId) &&
!submitting.value,
),
)
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
@@ -376,13 +449,18 @@ const toFieldString = (value: unknown): string => {
return ''
}
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const clearCreationForm = () => {
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurId = null
creationForm.constructeurIds = []
creationForm.prix = ''
creationForm.productId = null
lastSuggestedName.value = ''
}
@@ -391,6 +469,12 @@ const submitCreation = async () => {
toast.showError('Sélectionnez une catégorie de pièce.')
return
}
if (requiresProductSelection.value && !creationForm.productId) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typePieceId: selectedType.value.id,
@@ -401,8 +485,16 @@ const submitCreation = async () => {
payload.reference = reference
}
if (creationForm.constructeurId) {
payload.constructeurId = creationForm.constructeurId
if (creationForm.constructeurIds.length) {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
const selectedProductId =
typeof creationForm.productId === 'string'
? creationForm.productId.trim()
: ''
if (selectedProductId) {
payload.productId = selectedProductId
}
const rawPrice = typeof creationForm.prix === 'string'
@@ -466,6 +558,7 @@ interface CustomFieldInput {
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const fieldKey = (field: CustomFieldInput, index: number) =>
@@ -477,11 +570,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field) => normalizeCustomField(field))
.map((field, index) => normalizeCustomField(field, index))
.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') {
return null
}
@@ -502,7 +596,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
? rawField.customFieldValueId
: 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 => {

View File

@@ -0,0 +1,292 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des produits</h1>
<p class="text-sm text-base-content/70">
Retrouvez l'ensemble des produits du catalogue, leurs informations fournisseurs et leurs catégories.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/product/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un produit
</NuxtLink>
<NuxtLink to="/product-category" class="btn btn-outline btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
/>
</label>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
<select
id="product-sort"
v-model="sortField"
class="select select-bordered select-sm"
>
<option value="name">Nom</option>
<option value="createdAt">Date de création</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-dir">Ordre</label>
<select
id="product-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/60 lg:text-right">
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-10">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<div
v-else-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
<span class="font-semibold">Impossible de charger les produits</span>
<span class="text-sm">{{ errorMessage }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
Réessayer
</button>
</div>
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
Chargement du catalogue…
</p>
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70">
Aucun produit n'a encore été enregistré.
</p>
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
Aucun produit ne correspond à votre recherche.
</p>
<div v-else class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-16">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Type de produit</th>
<th>Fournisseurs</th>
<th class="text-right">Prix indicatif</th>
<th class="w-32 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="product in filteredProducts" :key="product.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(product)"
:alt="resolvePreviewAlt(product)"
/>
</td>
<td class="font-medium">{{ product.name }}</td>
<td>{{ product.reference || '—' }}</td>
<td>{{ product.typeProduct?.name || '—' }}</td>
<td>
<span v-if="product.constructeurs?.length" class="text-sm">
{{ formatConstructeurs(product.constructeurs) }}
</span>
<span v-else class="text-sm text-base-content/50"></span>
</td>
<td class="text-right">
{{ formatPrice(product.supplierPrice) }}
</td>
<td class="text-right space-x-2">
<NuxtLink
:to="`/product/${product.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(product)"
>
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead } from '#imports'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
useHead(() => ({
title: 'Catalogue des produits',
}))
const {
products,
total,
loading,
loaded,
error,
loadProducts,
deleteProduct,
} = useProducts()
const toast = useToast()
const searchTerm = ref('')
const sortField = ref<'name' | 'createdAt'>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
const normalizedProducts = computed(() => (Array.isArray(products.value) ? products.value : []))
const hasLoaded = computed(() => loaded.value)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const filteredProducts = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const items = normalizedProducts.value.slice()
const filtered = term
? items.filter((product) => {
const name = (product?.name || '').toLowerCase()
const reference = (product?.reference || '').toLowerCase()
const typeName = (product?.typeProduct?.name || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term) ||
typeName.includes(term)
)
})
: items
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
) * direction
}
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
return (dateA - dateB) * direction
})
})
const filteredCount = computed(() => filteredProducts.value.length)
const totalCount = computed(() => {
const reported = Number(total.value)
if (!Number.isFinite(reported) || reported < 0) {
return normalizedProducts.value.length
}
return reported
})
const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') {
return '—'
}
const number = Number(value)
if (Number.isNaN(number)) {
return '—'
}
return priceFormatter.format(number)
}
const formatConstructeurs = (constructeurs: Array<Record<string, any>>) =>
constructeurs
.map((constructeur) => constructeur?.name)
.filter((name): name is string => Boolean(name))
.join(', ')
const resolvePrimaryDocument = (product: Record<string, any>) => {
const documents = Array.isArray(product?.documents) ? product.documents : []
if (!documents.length) {
return null
}
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path)
if (!withPath.length) {
return normalized[0] ?? null
}
const images = withPath.filter((doc) => isImageDocument(doc))
if (images.length) {
return images[0]
}
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
return withPath[0]
}
const resolvePreviewAlt = (product: Record<string, any>) => {
const parts = [product?.name, product?.reference].filter(Boolean)
if (parts.length) {
return `Aperçu du document de ${parts.join(' ')}`
}
return 'Aperçu du document'
}
const reload = async () => {
await loadProducts({ force: true })
}
const confirmDelete = async (product: Record<string, any>) => {
const confirmed = window.confirm(
`Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
)
if (!confirmed) {
return
}
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${product.name}" supprimé`)
}
}
onMounted(async () => {
await loadProducts()
})
</script>

View File

@@ -0,0 +1,122 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
<p class="text-base text-base-content/70">
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/product-category">
Retour au catalogue
</NuxtLink>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<div v-if="loading" class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie</span>
</div>
<ModelTypeForm
v-else
mode="edit"
initial-category="PRODUCT"
:initial-data="initialData"
:lock-category="true"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
)
useHead(() => ({
title: title.value,
}))
const navigateBackToList = async () => {
await router.push('/product-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const normalizeError = (error: any) => {
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
return Array.isArray(message) ? message[0] : message
}
const loadCategory = async () => {
loading.value = true
try {
const id = String(route.params.id)
const response = await getModelType(id)
if (response.category !== 'PRODUCT') {
showError("Cette catégorie n'est pas un produit.")
await navigateBackToList()
return
}
initialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined,
}
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
const id = String(route.params.id)
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} catch (error) {
showError(normalizeError(error))
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategory()
})
</script>

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,747 @@
<template>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@close="closePreview"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du produit</p>
</div>
<div v-else-if="!product" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Produit introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
Retour au catalogue
</NuxtLink>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du produit et ses champs personnalisés.
</p>
</div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
Retour au catalogue
</NuxtLink>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
>
</div>
</div>
<div v-if="structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="saving"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
</div>
</div>
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce produit.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents
</p>
<div v-else-if="productDocuments.length" class="space-y-2">
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments || saving"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
Aucun document n'est associé à ce produit pour le moment.
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
} = useDocuments()
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
)
const canSubmit = computed(() =>
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) {
return '—'
}
if (size === 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
const shouldInlinePdf = (document: any) => {
if (!document || !isPdfDocument(document) || !document.path) {
return false
}
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
return false
}
return true
}
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src || ''
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`
}
return `${src}#toolbar=0&navpanes=0`
}
const documentPreviewSrc = (document: any) => {
if (!document?.path) {
return ''
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path)
}
return document.path
}
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
}
return 'h-16 w-16'
}
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const downloadDocument = (doc: any) => {
if (!doc?.path) {
return
}
const target = String(doc.path)
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(target, '_blank')
}
const loadProduct = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
product.value = null
loading.value = false
return
}
const result = await getProduct(id)
if (result.success) {
product.value = result.data
productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
await loadProductType()
hydrateForm()
await refreshDocuments()
} else {
product.value = null
}
loading.value = false
}
const refreshDocuments = async () => {
if (!product.value?.id) {
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
if (result.success) {
productDocuments.value = Array.isArray(result.data) ? result.data : []
}
} finally {
loadingDocuments.value = false
}
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteProductDocument(documentId, { updateStore: false })
if (result.success) {
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
toast.showSuccess('Document supprimé')
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !product.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadProductDocuments(
{
files,
context: { productId: product.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
toast.showSuccess('Document(s) ajouté(s)')
} else if (result.error) {
toast.showError(result.error)
}
} finally {
uploadingDocuments.value = false
}
}
const loadProductType = async () => {
if (!product.value?.typeProductId) {
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
return
}
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
}
}
const hydrateForm = () => {
if (!product.value) {
return
}
editionForm.name = product.value.name || ''
editionForm.reference = product.value.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
product.value,
Array.isArray(product.value.constructeurs) ? product.value.constructeurs : [],
)
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
? String(product.value.supplierPrice)
: ''
customFieldInputs.value = buildCustomFieldInputs(structure.value, product.value.customFieldValues)
}
watch(
() => product.value?.documents,
(docs) => {
if (Array.isArray(docs)) {
productDocuments.value = docs
}
},
{ immediate: true },
)
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
const buildCustomFieldInputs = (
productStructure: ProductModelStructure | null,
values: any[] | null | undefined,
): CustomFieldInput[] => {
if (!productStructure || typeof productStructure !== 'object') {
return []
}
const definitions = Array.isArray(productStructure.customFields) ? productStructure.customFields : []
const valueList = Array.isArray(values) ? values : []
const byId = new Map<string, any>()
const byName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) {
byId.set(fieldId, entry)
}
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) {
byName.set(fieldName, entry)
}
})
return definitions
.map((definition, index) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? byId.get(definitionId) : null) || byName.get(definition.name)
const type = typeof definition.type === 'string' ? definition.type : 'text'
const options = Array.isArray(definition.options) ? definition.options : []
const required = !!definition.required
const orderIndex = typeof definition.orderIndex === 'number' ? definition.orderIndex : index
if (!matched) {
return {
id: definition.id ?? null,
name: definition.name,
type,
required,
options,
value: '',
customFieldId: definition.customFieldId || definition.id || null,
customFieldValueId: null,
orderIndex,
}
}
const resolvedValue = matched.value ?? ''
return {
id: definition.id ?? null,
name: definition.name,
type,
required,
options,
value: formatDefaultValue(type, resolvedValue),
customFieldId: matched.customField?.id || definition.customFieldId || definition.id || null,
customFieldValueId: matched.id ?? null,
orderIndex,
}
})
.filter((field): field is CustomFieldInput => !!field?.name)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const formatDefaultValue = (type: string, value: any): string => {
if (value === null || value === undefined) {
return ''
}
if (type === 'boolean') {
return String(value === true || String(value).toLowerCase() === 'true')
}
return String(value)
}
const submitEdition = async () => {
if (!product.value) {
return
}
const payload: Record<string, any> = {
name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null,
constructeurIds: uniqueConstructeurIds(editionForm.constructeurIds),
}
const rawPrice = editionForm.supplierPrice.trim()
payload.supplierPrice = rawPrice
? Number.isNaN(Number(rawPrice))
? null
: Number(rawPrice)
: null
saving.value = true
try {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await saveCustomFieldValues(result.data.id)
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return
}
toast.showSuccess('Produit mis à jour avec succès')
await router.push('/product-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
} finally {
saving.value = false
}
}
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
const value = field.value ?? ''
if (field.customFieldValueId) {
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
failed.push(field.name)
}
continue
}
if (!field.customFieldId) {
continue
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
)
if (!result.success) {
failed.push(field.name)
}
}
return failed
}
onMounted(async () => {
await loadProduct()
})
</script>

View File

@@ -0,0 +1,518 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
</p>
</div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
Retour au catalogue
</NuxtLink>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="submitting"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="submitting"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
>
</div>
</div>
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
<span v-if="submitting" class="loading loading-spinner loading-sm mr-2"></span>
Créer le produit
</button>
</div>
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import { useProductTypes } from '~/composables/useProductTypes'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ProductModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface ProductCatalogType extends ModelType {
structure: ProductModelStructure | null
customFields?: Array<Record<string, any>>
}
const route = useRoute()
const router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
orderIndex: number
}
const customFieldInputs = ref<CustomFieldInput[]>([])
const loadingTypes = computed(() => loadingProductTypes.value)
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],
)
const typeOptionLabel = (type?: ProductCatalogType) => type?.name || 'Catégorie'
const typeOptionDescription = (type?: ProductCatalogType) =>
type?.description ? String(type.description) : ''
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
watch(
() => route.query.typeId,
(value) => {
if (typeof value === 'string') {
selectedTypeId.value = value
}
},
)
watch(selectedTypeId, (id) => {
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
if ((id || '') === current) {
return
}
const nextQuery = { ...route.query }
if (id) {
nextQuery.typeId = id
} else {
delete nextQuery.typeId
}
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
watch(selectedType, (type) => {
if (!type) {
clearForm()
customFieldInputs.value = []
return
}
if (!creationForm.name) {
creationForm.name = type.name
}
customFieldInputs.value = normalizeCustomFieldInputs(normalizeProductStructureForSave(type.structure))
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
)
const canSubmit = computed(() => Boolean(
selectedType.value &&
creationForm.name.trim().length >= 2 &&
requiredCustomFieldsFilled.value &&
!submitting.value,
))
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: ProductModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {
return []
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') {
return null
}
const name = typeof rawField.name === 'string' ? rawField.name.trim() : ''
if (!name) {
return null
}
const type = typeof rawField.type === 'string' ? rawField.type : 'text'
const required = !!rawField.required
const options = Array.isArray(rawField.options)
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
: []
const defaultSource = rawField.defaultValue ?? rawField.value ?? rawField.default ?? null
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, orderIndex }
}
const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) {
return ''
}
if (type === 'boolean') {
return String(defaultValue === true || String(defaultValue).toLowerCase() === 'true')
}
return String(defaultValue)
}
const clearForm = () => {
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.supplierPrice = ''
}
const buildPayload = () => {
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typeProductId: selectedType.value?.id,
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
}
if (creationForm.constructeurIds.length) {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
const rawPrice = creationForm.supplierPrice.trim()
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.supplierPrice = parsed
}
}
return payload
}
const submitCreation = async () => {
if (!selectedType.value) {
toast.showError('Sélectionnez une catégorie de produit.')
return
}
submitting.value = true
try {
const payload = buildPayload()
const result = await createProduct(payload)
if (result.success && result.data?.id) {
const productId = result.data.id
const failedFields = await saveCustomFieldValues(result.data.id)
if (failedFields.length) {
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
await router.push(`/product/${result.data.id}/edit`)
return
}
if (selectedDocuments.value.length) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
context: { productId },
},
{ updateStore: false },
)
if (!uploadResult.success) {
const message = uploadResult.error
? `Documents non ajoutés : ${uploadResult.error}`
: 'Documents non ajoutés : une erreur est survenue.'
toast.showError(message)
} else {
selectedDocuments.value = []
}
}
toast.showSuccess('Produit créé avec succès')
await router.push('/product-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du produit')
} finally {
submitting.value = false
uploadingDocuments.value = false
}
}
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
if (!field.customFieldId || !field.name) {
continue
}
const value = field.value ?? ''
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
)
if (!result.success) {
failed.push(field.name)
}
}
return failed
}
onMounted(async () => {
await loadProductTypes()
if (selectedTypeId.value && !selectedType.value) {
await router.replace({
path: route.path,
query: { ...route.query, typeId: undefined },
}).catch(() => {})
}
})
</script>

View File

@@ -93,6 +93,38 @@
</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>
@@ -141,6 +173,7 @@ const typePageTitle = computed(() => {
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
const toDisplayCount = (value, fallback) => {
if (value === null || value === undefined) {

View File

@@ -70,7 +70,8 @@ const editedType = ref({
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
pieceRequirements: []
pieceRequirements: [],
productRequirements: []
})
const parseOptions = (field = {}) => {
@@ -92,12 +93,15 @@ const parseOptions = (field = {}) => {
const normalizeCustomFields = (fields = []) =>
fields
.filter(field => field?.name && field.name.trim() !== '')
.map(field => ({
.map((field, index) => ({
name: field.name,
type: field.type || '',
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) => {
if (value === '' || value === undefined || value === null) {
@@ -137,6 +141,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId)
.map((req, index) => ({
typeProductId: req.typeProductId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const saveChanges = async () => {
try {
saving.value = true
@@ -148,7 +167,8 @@ const saveChanges = async () => {
...currentEditedType,
customFields: normalizeCustomFields(currentEditedType.customFields),
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements)
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
}
const result = await updateMachineType(type.value.id, updatedType)
@@ -189,7 +209,8 @@ onMounted(async () => {
maintenanceFrequency: type.value.maintenanceFrequency || '',
customFields: type.value.customFields || [],
componentRequirements: type.value.componentRequirements || [],
pieceRequirements: type.value.pieceRequirements || []
pieceRequirements: type.value.pieceRequirements || [],
productRequirements: type.value.productRequirements || [],
}
} else {
console.error('Failed to load type:', result.error)

View File

@@ -3,11 +3,16 @@ import type { FetchOptions } from 'ofetch';
import type {
ComponentModelStructure,
PieceModelStructure,
ProductModelStructure,
} 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 {
name: string;
@@ -26,7 +31,15 @@ export interface PieceModelTypePayload extends BaseModelTypePayload {
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 {
id: string;

View File

@@ -0,0 +1,125 @@
import { formatPhone } from '~/utils/formatters/phone';
export interface ConstructeurSummary {
id: string;
name?: string | null;
email?: string | null;
phone?: string | null;
}
const isObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const toStringId = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
export const uniqueConstructeurIds = (...sources: unknown[]): string[] => {
const ids = new Set<string>();
const pushId = (value: unknown) => {
const id = toStringId(value);
if (id) {
ids.add(id);
}
};
const explore = (value: unknown): void => {
if (!value) {
return;
}
if (Array.isArray(value)) {
value.forEach(explore);
return;
}
if (typeof value === 'string') {
pushId(value);
return;
}
if (isObject(value)) {
if (Array.isArray(value.constructeurIds)) {
value.constructeurIds.forEach(pushId);
}
if (value.constructeurId) {
pushId(value.constructeurId);
}
if (Array.isArray(value.constructeurs)) {
value.constructeurs.forEach(explore);
}
if (value.constructeur) {
explore(value.constructeur);
}
if (typeof value.id === 'string') {
pushId(value.id);
}
return;
}
};
sources.forEach(explore);
return Array.from(ids);
};
export const resolveConstructeurs = (
ids: string[],
...candidatePools: Array<ConstructeurSummary[] | null | undefined>
): ConstructeurSummary[] => {
if (!Array.isArray(ids) || ids.length === 0) {
return [];
}
const index = new Map<string, ConstructeurSummary>();
const register = (pool?: ConstructeurSummary[] | null) => {
if (!Array.isArray(pool)) {
return;
}
pool.forEach((entry) => {
if (entry && typeof entry === 'object' && typeof entry.id === 'string') {
index.set(entry.id, entry);
}
});
};
candidatePools.forEach(register);
return ids
.map((id) => index.get(id))
.filter((item): item is ConstructeurSummary => Boolean(item))
.map((item) => ({ ...item }));
};
export const formatConstructeurContact = (
constructeur?: ConstructeurSummary | null,
): string => {
if (!constructeur) {
return '';
}
const formattedPhone = formatPhone(constructeur.phone);
const phone = formattedPhone || constructeur.phone || null;
return [constructeur.email, phone].filter(Boolean).join(' • ');
};
export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
payload: T,
): T & { constructeurIds: string[] } => {
const ids = uniqueConstructeurIds(
payload?.constructeurIds,
payload?.constructeurId,
payload?.constructeur,
payload?.constructeurs,
);
const next = { ...payload } as Record<string, any>;
next.constructeurIds = ids;
delete next.constructeurId;
delete next.constructeur;
delete next.constructeurs;
return next as T & { constructeurIds: string[] };
};

View File

@@ -3,14 +3,19 @@ import {
type ComponentModelCustomFieldType,
type ComponentModelCustomField,
type ComponentModelPiece,
type ComponentModelProduct,
type ComponentModelStructure,
type ComponentModelStructureNode,
type PieceModelCustomField,
type PieceModelProduct,
type PieceModelStructure,
type PieceModelStructureEditorField,
type PieceModelStructureForEditor,
type ProductModelStructure,
createEmptyProductModelStructure,
createEmptyPieceModelStructure,
} from './types/inventory'
import { uniqueConstructeurIds } from './constructeurUtils'
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
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 {
customFields: number
pieces: number
products: number
subcomponents: number
}
@@ -36,6 +42,7 @@ const ensureStructureShape = (input: any): ComponentModelStructure => {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
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)
? (input as any).subcomponents
: Array.isArray((input as any).subComponents)
@@ -93,7 +100,7 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
}
return fields
.map((field) => {
.map((field, index) => {
const rawName =
typeof field?.name === 'string'
? field.name
@@ -172,6 +179,8 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (customFieldId) {
result.customFieldId = customFieldId
}
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result
})
.filter((field): field is ComponentModelCustomField => !!field)
@@ -237,6 +246,66 @@ const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
.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[] => {
if (!Array.isArray(components)) {
return []
@@ -328,6 +397,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
const result: ComponentModelStructure = {
customFields: customFields as ComponentModelCustomField[],
pieces: sanitizePieces(source.pieces),
products: sanitizeProducts(source.products),
subcomponents: hydrateSubcomponents(source.subcomponents),
}
@@ -395,6 +465,20 @@ export const normalizeStructureForSave = (input: any): any => {
return payload
}) 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 payload: Record<string, any> = {}
if (subcomponent.typeComposantId) {
@@ -420,6 +504,7 @@ export const normalizeStructureForSave = (input: any): any => {
const result: ComponentModelStructure = {
customFields: backendCustomFields,
pieces: backendPieces,
products: backendProducts,
subcomponents: backendSubcomponents,
}
@@ -447,7 +532,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
return []
}
return fields.map((field) => {
return fields.map((field, index) => {
const valueObject = extractFieldValueObject(field)
const name = typeof field?.name === 'string'
? field.name
@@ -512,6 +597,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
const id = typeof field?.id === 'string' ? field.id : undefined
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
return {
name,
@@ -522,6 +608,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
defaultValue,
id,
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[] => {
if (!Array.isArray(components)) {
return []
@@ -564,6 +665,7 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure =
return {
customFields: hydrateCustomFields(source.customFields),
pieces: hydratePieces(source.pieces),
products: hydrateProducts(source.products),
subcomponents: hydrateSubcomponents(
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
),
@@ -579,7 +681,7 @@ const mapComponentCustomFields = (fields: any[]) => {
if (!Array.isArray(fields)) {
return []
}
return hydrateCustomFields(fields).map((field) => {
return hydrateCustomFields(fields).map((field, index) => {
const defaultValue =
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
? field.defaultValue
@@ -596,6 +698,7 @@ const mapComponentCustomFields = (fields: any[]) => {
typeof (field as any)?.customFieldId === 'string'
? (field as any).customFieldId
: 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[] => {
if (!Array.isArray(components)) {
return []
@@ -639,6 +755,7 @@ export const extractStructureFromComponent = (component: any) => {
const raw = {
customFields: mapComponentCustomFields(component.customFields),
pieces: mapComponentPieces(component.pieces),
products: mapComponentProducts(component.products),
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
@@ -656,12 +773,13 @@ export const extractStructureFromComponent = (component: any) => {
export const computeStructureStats = (structure: any): ModelStructurePreview => {
if (!structure || typeof structure !== 'object') {
return { customFields: 0, pieces: 0, subcomponents: 0 }
return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
}
return {
customFields: Array.isArray(structure.customFields) ? structure.customFields.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)
? structure.subcomponents.length
: Array.isArray(structure.subComponents)
@@ -672,13 +790,14 @@ export const computeStructureStats = (structure: any): ModelStructurePreview =>
export const formatStructurePreview = (structure: any) => {
const stats = computeStructureStats(structure)
if (!stats.customFields && !stats.pieces && !stats.subcomponents) {
if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
return 'Structure vide'
}
const segments: string[] = []
if (stats.customFields) segments.push(`${stats.customFields} champ(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)`)
return segments.join(' • ')
}
@@ -686,7 +805,7 @@ export const formatStructurePreview = (structure: any) => {
export interface DefinitionOverridePayload {
name?: string
reference?: string
constructeurId?: string | null
constructeurIds?: string[]
prix?: number
}
@@ -711,8 +830,14 @@ export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverride
}
}
if (definition.constructeurId !== undefined && definition.constructeurId !== null && definition.constructeurId !== '') {
payload.constructeurId = definition.constructeurId
const constructeurIds = uniqueConstructeurIds(
definition.constructeurIds,
definition.constructeurId,
definition.constructeur,
definition.constructeurs,
)
if (constructeurIds.length) {
payload.constructeurIds = constructeurIds
}
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
@@ -729,6 +854,10 @@ export const defaultPieceStructure = (): PieceModelStructure => ({
...createEmptyPieceModelStructure(),
})
export const defaultProductStructure = (): ProductModelStructure => ({
...createEmptyProductModelStructure(),
})
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const base = createEmptyPieceModelStructure()
if (!isPlainObject(input)) {
@@ -738,10 +867,11 @@ const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const clone: PieceModelStructure = {
...base,
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>)) {
if (key === 'customFields') {
if (key === 'customFields' || key === 'products') {
continue
}
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[] => {
if (!Array.isArray(fields)) {
return []
}
return fields
.map((field) => {
.map((field, index) => {
const name = typeof field?.name === 'string' ? field.name.trim() : ''
if (!name) {
return null
@@ -792,17 +926,25 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (options) {
result.options = options
}
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result
})
.filter((field): field is PieceModelCustomField => !!field)
}
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
return sanitizeProducts(products) as PieceModelProduct[]
}
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
const source = clonePieceStructure(input)
const restEntries = Object.entries(source).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return {
...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields'),
),
...Object.fromEntries(restEntries),
products: sanitizePieceProducts(source.products),
customFields: sanitizePieceCustomFields(source.customFields),
}
}
@@ -812,7 +954,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
return []
}
return fields.map((field) => ({
return fields.map((field, index) => ({
name: field?.name ?? '',
type: field?.type ?? 'text',
required: !!field?.required,
@@ -822,6 +964,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
: Array.isArray(field?.options)
? 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 payload: PieceModelStructureForEditor = {
...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),
}
return payload
@@ -844,10 +988,30 @@ export const formatPieceStructurePreview = (structure: any) => {
const customFields = Array.isArray((structure as any).customFields)
? (structure as any).customFields.length
: 0
const products = Array.isArray((structure as any).products)
? (structure as any).products.length
: 0
if (!customFields) {
return 'Aucun champ personnalisé'
if (!customFields && !products) {
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
id?: string
customFieldId?: string
orderIndex?: number
}
export interface ComponentModelPiece {
@@ -19,18 +20,28 @@ export interface ComponentModelPiece {
role?: string
}
export interface ComponentModelProduct {
typeProductId?: string
typeProductLabel?: string
reference?: string
familyCode?: string
role?: string
}
export interface ComponentModelStructureNode {
typeComposantId?: string
typeComposantLabel?: string
modelId?: string
familyCode?: string
alias?: string
products?: ComponentModelProduct[]
subcomponents: ComponentModelStructureNode[]
}
export interface ComponentModelStructure extends ComponentModelStructureNode {
customFields: ComponentModelCustomField[]
pieces: ComponentModelPiece[]
products: ComponentModelProduct[]
}
export type PieceModelCustomFieldType = ComponentModelCustomFieldType
@@ -40,10 +51,20 @@ export interface PieceModelCustomField {
type: PieceModelCustomFieldType
required: boolean
options?: string[]
orderIndex?: number
}
export interface PieceModelProduct {
typeProductId?: string
typeProductLabel?: string
reference?: string
familyCode?: string
role?: string
}
export interface PieceModelStructure {
customFields: PieceModelCustomField[]
products?: PieceModelProduct[]
[key: string]: unknown
}
@@ -53,9 +74,13 @@ export interface PieceModelStructureEditorField extends PieceModelCustomField {
export interface PieceModelStructureForEditor {
customFields: PieceModelStructureEditorField[]
products?: PieceModelProduct[]
[key: string]: unknown
}
export type ProductModelCustomField = PieceModelCustomField
export type ProductModelStructure = PieceModelStructure
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
@@ -250,9 +275,15 @@ export const componentModelStructureValidator = {
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
customFields: [],
pieces: [],
products: [],
subcomponents: [],
})
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
customFields: [],
products: [],
})
export const createEmptyProductModelStructure = (): ProductModelStructure => ({
customFields: [],
})

View File

@@ -1,9 +1,9 @@
import { normalizeEmail } from '~/utils/formatters/email'
export const EMAIL_INPUT_PATTERN = '[^\s@]+'
const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const EMAIL_INPUT_PATTERN = EMAIL_VALIDATION_PATTERN.source
export type EmailValidationResult = {
valid: boolean
error?: string

View File

@@ -1,7 +1,7 @@
import { normalizePhone } from '~/utils/formatters/phone'
/** 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}$/

View File

@@ -11,8 +11,8 @@ const PHONE_CHAR_PATTERN = /[^+\d]/g
* Normalises a phone number by trimming whitespace, removing spacing/separators and
* converting international prefixes written with `00` to their `+` variant.
*/
export const normalizePhone = (rawValue: string): string => {
const trimmed = (rawValue || '').trim()
export const normalizePhone = (rawValue: string | null | undefined): string => {
const trimmed = typeof rawValue === 'string' ? rawValue.trim() : ''
if (!trimmed) {
return ''
}
@@ -26,30 +26,53 @@ export const normalizePhone = (rawValue: string): string => {
}
/**
* Formats a phone number by grouping digits by two while keeping any international
* prefix. The function remains tolerant to partially entered numbers.
* Formats a phone number by grouping digits by two and joining them with dots while
* 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)
if (!normalized) {
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 prefix = hasInternationalPrefix ? normalized.slice(0, 1) : ''
const digits = hasInternationalPrefix ? normalized.slice(1) : normalized
const groups = digits.match(/.{1,2}/g) ?? []
const grouped = groups.join(' ')
const groups = digits.match(/\d{1,2}/g) ?? []
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 ·.
* 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)
if (!normalized) {
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) => {
if (size === undefined || size === null) { return '—' }
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 = (
pieces = [],
title = 'Pièces indépendantes',
@@ -59,9 +125,12 @@ const renderPrintPieces = (
const cards = pieces
.map((piece, idx) => {
const indexLabel = piece.indexPath ? piece.indexPath.join('.') : `${idx + 1}`
const constructeurBadge = piece.constructeur?.name
? `<span class="print-badge print-badge--subtle">Constructeur: ${piece.constructeur.name}</span>`
: ''
const constructeurBadges = (piece.constructeurs || [])
.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 || [])
.filter(field => field.value && field.value !== '—' && field.value !== '')
@@ -85,6 +154,8 @@ const renderPrintPieces = (
.join('')}</ul></div>`
: ''
const productBlock = renderPrintProductSummary(piece.product, 'Produit catalogue')
return `
<div class="print-piece-card">
<div class="print-piece-header">
@@ -93,19 +164,27 @@ const renderPrintPieces = (
<div class="print-piece-title">${piece.name}</div>
<div class="print-piece-subtitle">${piece.reference || 'Référence non définie'}</div>
</div>
${constructeurBadge}
${constructeurBadges}
</div>
${piece.description ? `<p class="print-piece-description">${piece.description}</p>` : ''}
<div class="print-piece-meta">
<div class="print-field-mini">
<label>Constructeur</label>
<span>${piece.constructeur?.name || '—'}</span>
<label>Fournisseur(s)</label>
<span>${piece.constructeurs?.length
? piece.constructeurs.map(constructeur => constructeur.name).join(', ')
: '—'}</span>
</div>
<div class="print-field-mini">
<label>Contact</label>
<span>${piece.constructeur?.contact || '—'}</span>
<label>Contact(s)</label>
<span>${piece.constructeurs?.length
? piece.constructeurs
.map(constructeur => constructeur.contact)
.filter(Boolean)
.join(' • ') || '—'
: '—'}</span>
</div>
</div>
${productBlock}
${customFieldsBlock}
${documentsBlock}
</div>
@@ -128,12 +207,17 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
return components
.map((component, idx) => {
const badges = []
if (component.constructeur?.name) {
badges.push(`Constructeur: ${component.constructeur.name}`)
if (component.constructeurs?.length) {
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 currentIndex = [...indexPath, idx + 1]
const indexLabel = currentIndex.join('.')
const productBlock = renderPrintProductSummary(component.product, 'Produit catalogue', 'print-section print-subsection print-section--product')
return `
<div class="${sectionClass}">
<h3>
@@ -142,6 +226,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
</h3>
${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>` : ''}
${productBlock}
${renderPrintCustomFields(
component.customFields,
'Champs personnalisés',
@@ -183,33 +268,115 @@ const normalizeCustomFields = (values = []) => {
const normalizeConstructeur = (constructeur) => {
if (!constructeur) { return null }
const contact = formatConstructeurContact(constructeur)
return {
id: constructeur.id || null,
name: constructeur.name || '—',
contact: [constructeur.email, constructeur.phone].filter(Boolean).join(' • ') || '—'
contact: contact || '—'
}
}
const normalizePiece = piece => ({
id: piece.id,
name: piece.name || 'Pièce sans nom',
description: piece.description || '',
reference: piece.reference || '',
customFields: normalizeCustomFields(piece.customFieldValues || []),
documents: normalizeDocuments(piece.documents || []),
constructeur: normalizeConstructeur(piece.constructeur),
indexPath: piece.indexPath || null
})
const normalizeConstructeurList = (...sources) => {
const ids = uniqueConstructeurIds(...sources)
const pools = sources
.flatMap((source) => {
if (Array.isArray(source)) {
if (source.length && typeof source[0] === 'object') {
return [source]
}
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 => ({
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),
constructeur: normalizeConstructeur(component.constructeur)
})
const normalizeProduct = (product) => {
if (!product) { return null }
const constructeurs = normalizeConstructeurList(
product.constructeurs,
product.constructeur,
product.constructeurIds,
product.constructeurId,
)
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 = ({
machine,
@@ -255,6 +422,24 @@ export const buildMachinePrintContext = ({
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
.map(normalizePiece)
.filter(piece => isPieceSelected(piece.id))
@@ -300,7 +485,10 @@ export const buildMachinePrintContext = ({
site: machine?.site?.name || '',
category: machine?.typeMachine?.category || '',
badges: machineBadges,
constructeur: normalizeConstructeur(machine?.constructeur),
constructeurs: machineConstructeurs,
constructeur: machineConstructeurs[0] || null,
constructeurNames: machineConstructeurNames,
constructeurContacts: machineConstructeurContacts,
includeInfo: includeMachineInfo,
customFields: includeMachineCustomFields
? normalizeCustomFields(machine?.customFieldValues || [])
@@ -342,11 +530,11 @@ export const buildMachinePrintHtml = (context, styles) => {
<div class="print-section print-section--machine">
<h3>Informations générales</h3>
<div class="print-grid">
${renderPrintField('Nom', context.machine.name)}
${renderPrintField('Référence', context.machine.reference, 'Non définie')}
${renderPrintField('Site', context.machine.site, 'Non défini')}
${renderPrintField('Constructeur', context.machine.constructeur?.name, 'Non défini')}
${renderPrintField('Contact Constructeur', context.machine.constructeur?.contact, 'Non défini')}
${renderPrintField('Nom', context.machine.name)}
${renderPrintField('Référence', context.machine.reference, 'Non définie')}
${renderPrintField('Site', context.machine.site, 'Non défini')}
${renderPrintField('Constructeur(s)', context.machine.constructeurNames, 'Non défini')}
${renderPrintField('Contact(s) Constructeur(s)', context.machine.constructeurContacts, 'Non défini')}
</div>
</div>
`)