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
This commit is contained in:
Matthieu
2025-11-05 15:35:02 +01:00
parent 3af6c50892
commit d860f24e69
42 changed files with 6052 additions and 142 deletions

View File

@@ -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'
@@ -366,7 +397,7 @@ import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType {
@@ -411,6 +442,7 @@ const editionForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
productId: null as string | null,
})
const customFieldInputs = ref<CustomFieldInput[]>([])
@@ -542,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) {
@@ -554,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) {
@@ -610,6 +681,7 @@ watch(
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,
@@ -636,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
@@ -650,6 +727,12 @@ const submitEdition = async () => {
payload.reference = reference ? reference : 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)
if (!Number.isNaN(parsed)) {
@@ -841,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

@@ -71,10 +71,10 @@
<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 fournisseur..."
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'
@@ -261,7 +292,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType {
@@ -286,6 +317,7 @@ const creationForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
productId: null as string | null,
})
const lastSuggestedName = ref('')
@@ -332,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()
@@ -343,6 +411,7 @@ watch(selectedType, (type) => {
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
creationForm.productId = null
})
const requiredCustomFieldsFilled = computed(() =>
@@ -357,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) {
@@ -377,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.constructeurIds = []
creationForm.prix = ''
creationForm.productId = null
lastSuggestedName.value = ''
}
@@ -392,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,
@@ -406,6 +489,14 @@ const submitCreation = async () => {
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'
? creationForm.prix.trim()
: creationForm.prix === null || creationForm.prix === undefined