refactor(machines) : remove TypeMachine skeleton system, simplify machine creation

- Remove TypeEdit*, TypeInfoDisplay, MachineSkeletonSummary, MachineCreatePreview components
- Remove machine-skeleton pages and type pages
- Remove useMachineTypesApi, useMachineSkeletonEditor, useMachineCreateSelections composables
- Add AddEntityToMachineModal for direct entity linking
- Update machine detail/create pages for direct custom fields
- Fix SearchSelect, category display, and ipartial search filters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-05 17:25:23 +01:00
parent 6f1bac381d
commit 32d03b480d
49 changed files with 1058 additions and 6093 deletions

View File

@@ -0,0 +1,207 @@
<template>
<div v-if="open" class="modal modal-open">
<div class="modal-box max-w-xl w-full" style="overflow: visible">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
@click="handleClose"
>
&times;
</button>
<h3 class="font-bold text-lg mb-6">
{{ title }}
</h3>
<!-- Step 1: Choose category -->
<div class="form-control mb-5" style="position: relative; z-index: 20">
<label class="label pb-1">
<span class="label-text font-medium">Catégorie</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="types"
:loading="loadingTypes"
:max-visible="8"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="(t: any) => t.name"
:option-description="(t: any) => t.code"
/>
</div>
<!-- Step 2: Choose entity (visible only after category selected) -->
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
<label class="label pb-1">
<span class="label-text font-medium">{{ entityLabel }}</span>
</label>
<SearchSelect
v-model="selectedEntityId"
:options="entities"
:loading="loadingEntities"
:max-visible="8"
:placeholder="`Rechercher ${entityLabelLower}...`"
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
:option-label="entityOptionLabel"
:option-description="entityOptionDescription"
/>
</div>
<!-- Summary of selection -->
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
Réf : {{ selectedEntitySummary.reference }}
</p>
</div>
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
<button type="button" class="btn btn-ghost" @click="handleClose">
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedEntityId"
@click="handleConfirm"
>
Ajouter
</button>
</div>
</div>
<div class="modal-backdrop" @click="handleClose" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
type EntityKind = 'component' | 'piece' | 'product'
const props = defineProps<{
open: boolean
entityKind: EntityKind
}>()
const emit = defineEmits<{
close: []
confirm: [entityId: string]
}>()
const selectedTypeId = ref('')
const selectedEntityId = ref('')
const loadingEntities = ref(false)
const entities = ref<any[]>([])
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
const { loadComposants } = useComposants()
const { loadPieces } = usePieces()
const { loadProducts } = useProducts()
const title = computed(() => {
const labels: Record<EntityKind, string> = {
component: 'Ajouter un composant',
piece: 'Ajouter une pièce',
product: 'Ajouter un produit',
}
return labels[props.entityKind]
})
const entityLabel = computed(() => {
const labels: Record<EntityKind, string> = {
component: 'Composant',
piece: 'Pièce',
product: 'Produit',
}
return labels[props.entityKind]
})
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
const types = computed(() => {
if (props.entityKind === 'component') return componentTypes.value
if (props.entityKind === 'piece') return pieceTypes.value
return productTypes.value
})
const loadingTypes = computed(() => {
if (props.entityKind === 'component') return loadingComponentTypes.value
if (props.entityKind === 'piece') return loadingPieceTypes.value
return loadingProductTypes.value
})
const selectedTypeName = computed(() => {
if (!selectedTypeId.value) return ''
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
return found?.name || ''
})
const entityOptionLabel = (e: any) => e.name || '(sans nom)'
const entityOptionDescription = (e: any) => e.reference || ''
const selectedEntitySummary = computed(() => {
if (!selectedEntityId.value || !entities.value.length) return null
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
if (!found) return null
return { name: found.name || '(sans nom)', reference: found.reference || null }
})
// Load types when modal opens
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
if (props.entityKind === 'component') await loadComponentTypes()
else if (props.entityKind === 'piece') await loadPieceTypes()
else await loadProductTypes()
})
// Load entities when type changes
watch(selectedTypeId, async () => {
selectedEntityId.value = ''
entities.value = []
if (!selectedTypeName.value) return
loadingEntities.value = true
try {
if (props.entityKind === 'component') {
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else if (props.entityKind === 'piece') {
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else {
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
}
} finally {
loadingEntities.value = false
}
})
const handleClose = () => {
resetState()
emit('close')
}
const handleConfirm = () => {
if (!selectedEntityId.value) return
emit('confirm', selectedEntityId.value)
resetState()
emit('close')
}
const resetState = () => {
selectedTypeId.value = ''
selectedEntityId.value = ''
entities.value = []
}
</script>

View File

@@ -3,32 +3,57 @@
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
</div>
<ComponentHierarchy
:components="components"
:is-edit-mode="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-component', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
Aucun composant associé à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="component in components" :key="component.id" class="relative">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer ce composant"
@click="$emit('remove-component', component.linkId || component.id)"
>
Supprimer
</button>
<ComponentHierarchy
:components="[component]"
:is-edit-mode="false"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@edit-piece="$emit('edit-piece', $event)"
/>
</div>
</div>
</div>
</div>
</template>
@@ -49,5 +74,7 @@ defineEmits<{
'update-component': [component: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-component': []
'remove-component': [linkId: string]
}>()
</script>

View File

@@ -4,25 +4,6 @@
<h1 class="text-3xl font-bold">
{{ title }}
</h1>
<div class="btn-group w-full max-w-xs print:hidden" data-print-hide>
<button
type="button"
class="btn btn-sm"
:class="isDetailsView ? 'btn-primary' : 'btn-outline'"
@click="$emit('change-view', 'details')"
>
Vue machine
</button>
<button
type="button"
class="btn btn-sm"
:class="isSkeletonView ? 'btn-primary' : 'btn-outline'"
:disabled="!hasSkeletonRequirements"
@click="$emit('change-view', 'skeleton')"
>
Squelette
</button>
</div>
</div>
<div class="flex items-center gap-2 print:hidden" data-print-hide>
<button
@@ -43,7 +24,7 @@
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<button
v-if="isDetailsView && !isEditMode"
v-if="!isEditMode"
@click="$emit('open-print')"
type="button"
class="btn btn-outline btn-secondary"
@@ -60,18 +41,12 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer'
const { canEdit } = usePermissions()
defineProps<{
title: string
isDetailsView: boolean
isSkeletonView: boolean
isEditMode: boolean
hasSkeletonRequirements: boolean
}>()
defineEmits<{
'change-view': [view: 'details' | 'skeleton']
'toggle-edit': []
'open-print': []
}>()

View File

@@ -3,32 +3,75 @@
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-piece')"
>
Ajouter une pièce
</button>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<PieceItem
v-for="piece in pieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
Aucune pièce associée à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="piece in pieces" :key="piece.id" class="relative">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer cette pièce"
@click="$emit('remove-piece', piece.linkId || piece.id)"
>
Supprimer
</button>
<PieceItem
:piece="piece"
:is-edit-mode="false"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@edit="$emit('edit-piece', $event)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{
pieces: any[]
isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>()
defineEmits<{
'update-piece': [piece: any]
'toggle-collapse': []
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-piece': []
'remove-piece': [linkId: string]
}>()
</script>

View File

@@ -1,29 +1,55 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="previewDocumentList"
@close="closePreview"
/>
<div class="flex items-center justify-between">
<div>
<h2 class="card-title">Produits associés</h2>
<p class="text-xs text-gray-500">
Produits sélectionnés directement pour cette machine selon le squelette.
Produits sélectionnés directement pour cette machine.
</p>
</div>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
</div>
<div v-if="products.length" class="space-y-3">
<div
v-for="product in products"
:key="product.id || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-1"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2 relative"
>
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2"
title="Supprimer ce produit"
@click="$emit('remove-product', (product.linkId || product.id) as string)"
>
Supprimer
</button>
<div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold text-base-content">
{{ product.name }}
</p>
<span class="badge badge-ghost badge-sm">
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
{{ product.groupLabel }}
</span>
</div>
@@ -39,6 +65,53 @@
<span class="font-medium">Prix indicatif :</span>
<span class="ml-1">{{ product.priceLabel }}</span>
</p>
<!-- Documents liés au produit -->
<div v-if="product.documents?.length" class="mt-2 space-y-1">
<p class="text-xs font-medium text-base-content/70">Documents :</p>
<div
v-for="doc in product.documents"
:key="doc.id || doc.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 text-xs">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
>
<component
:is="documentIcon(doc).component"
class="h-4 w-4"
:class="documentIcon(doc).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">{{ doc.name }}</div>
<div class="text-xs text-base-content/60">
{{ doc.mimeType || 'Inconnu' }} {{ formatSize(doc.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(doc, product.documents || [])"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(doc)"
>
Télécharger
</button>
</div>
</div>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">
@@ -49,14 +122,53 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
formatSize,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
defineProps<{
products: Array<{
id?: string | null
linkId?: string | null
name?: string
reference?: string | null
supplierLabel?: string | null
priceLabel?: string | null
groupLabel?: string
documents?: Array<{
id?: string
name?: string
mimeType?: string
size?: number
fileUrl?: string
downloadUrl?: string
}>
}>
isEditMode: boolean
}>()
defineEmits<{
'add-product': []
'remove-product': [linkId: string]
}>()
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const previewDocumentList = ref<any[]>([])
const openPreview = (doc: any, docs: any[]) => {
previewDocument.value = doc
previewDocumentList.value = docs
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
</script>

View File

@@ -1,193 +0,0 @@
<template>
<div
v-if="componentRequirementGroups.length || pieceRequirementGroups.length || productRequirementGroups.length"
class="card bg-base-100 shadow-lg"
>
<div class="card-body space-y-6">
<div>
<h2 class="card-title">Structure sélectionnée</h2>
<p class="text-sm text-gray-500">
Synthèse des familles définies dans le type et des modèles utilisés pour cette machine.
</p>
</div>
<!-- Component requirement groups -->
<div v-if="componentRequirementGroups.length" class="space-y-4">
<h3 class="text-sm font-semibold text-gray-700">Composants</h3>
<div
v-for="group in componentRequirementGroups"
:key="group.requirement.id"
class="rounded-lg border border-base-200 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h4 class="font-medium text-sm">
{{ group.requirement.label || group.requirement.typeComposant?.name || 'Famille de composants' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ group.requirement.typeComposant?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
</p>
</div>
<span class="badge badge-outline badge-sm">{{ group.components.length }} composant(s)</span>
</div>
<div v-if="group.components.length" class="space-y-2">
<div
v-for="component in group.components"
:key="component.id"
class="flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-medium">{{ component.name }}</span>
<span v-if="component.parentComposantId" class="text-xs text-gray-500">
(Sous-composant)
</span>
<div
v-if="summarizeCustomFields(component.customFields || []).length"
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
>
<span
v-for="field in summarizeCustomFields(component.customFields || [])"
:key="field.key"
class="badge badge-ghost badge-sm whitespace-pre-wrap"
>
<span class="font-medium">{{ field.label }} :</span>
<span class="ml-1">{{ field.value }}</span>
</span>
</div>
<SkeletonProductDisplay :product-display="component.__productDisplay" />
</div>
</div>
<p v-else class="text-xs text-gray-500">Aucun composant rattaché à ce groupe.</p>
</div>
</div>
<!-- Piece requirement groups -->
<div v-if="pieceRequirementGroups.length" class="space-y-4">
<h3 class="text-sm font-semibold text-gray-700">Pièces principales</h3>
<div
v-for="group in pieceRequirementGroups"
:key="group.requirement.id"
class="rounded-lg border border-base-200 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h4 class="font-medium text-sm">
{{ group.requirement.label || group.requirement.typePiece?.name || 'Groupe de pièces' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ group.requirement.typePiece?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
</p>
</div>
<span class="badge badge-outline badge-sm">{{ group.pieces.length }} pièce(s)</span>
</div>
<div v-if="group.pieces.length" class="space-y-2">
<div
v-for="piece in group.pieces"
:key="piece.id"
class="flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-medium">{{ piece.name }}</span>
<span v-if="piece.parentComponentName" class="text-xs text-gray-500">
(Rattachée à {{ piece.parentComponentName }})
</span>
<div
v-if="summarizeCustomFields(piece.customFields || []).length"
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
>
<span
v-for="field in summarizeCustomFields(piece.customFields || [])"
:key="field.key"
class="badge badge-ghost badge-sm whitespace-pre-wrap"
>
<span class="font-medium">{{ field.label }} :</span>
<span class="ml-1">{{ field.value }}</span>
</span>
</div>
<SkeletonProductDisplay :product-display="piece.__productDisplay" />
</div>
</div>
<p v-else class="text-xs text-gray-500">Aucune pièce rattachée à ce groupe.</p>
</div>
</div>
<!-- Product requirement groups -->
<div v-if="productRequirementGroups.length" class="space-y-4">
<h3 class="text-sm font-semibold text-gray-700">Produits requis</h3>
<div
v-for="group in productRequirementGroups"
:key="group.requirement.id"
class="rounded-lg border border-base-200 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h4 class="font-medium text-sm">
{{ group.requirement.label || group.requirement.typeProduct?.name || 'Groupe de produits' }}
</h4>
<p class="text-xs text-gray-500">
Catégorie : {{ group.requirement.typeProduct?.name || 'Non définie' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-outline badge-sm">Total {{ group.totalCount }}</span>
<span class="badge badge-ghost badge-sm">Direct {{ group.directProducts.length }}</span>
</div>
</div>
<div class="text-xs text-gray-500 mb-3">
Via composants : {{ group.componentCount }} &bull; Via pièces : {{ group.pieceCount }}
</div>
<div v-if="group.directProducts.length" class="space-y-2">
<div
v-for="product in group.directProducts"
:key="product.id || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm"
>
<div class="font-medium">{{ product.name }}</div>
<div v-if="product.reference" class="text-xs text-gray-500">
Référence : {{ product.reference }}
</div>
<div v-if="product.supplierLabel" class="text-xs text-gray-500">
Fournisseurs : {{ product.supplierLabel }}
</div>
<div v-if="product.priceLabel" class="text-xs text-gray-500">
Prix indicatif : {{ product.priceLabel }}
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">
Aucune sélection directe. Couverture assurée via composants ou pièces associés.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from 'vue'
import { summarizeCustomFields } from '~/shared/utils/customFieldUtils'
defineProps<{
componentRequirementGroups: any[]
pieceRequirementGroups: any[]
productRequirementGroups: any[]
}>()
const SkeletonProductDisplay = defineComponent({
name: 'SkeletonProductDisplay',
props: {
productDisplay: { type: Object, default: null },
},
template: `
<div v-if="productDisplay" class="w-full text-xs text-gray-600 space-y-1">
<div><span class="font-medium">Produit :</span> <span>{{ productDisplay.name }}</span></div>
<div v-if="productDisplay.category"><span class="font-medium">Catégorie :</span> <span>{{ productDisplay.category }}</span></div>
<div v-if="productDisplay.reference"><span class="font-medium">Référence :</span> <span>{{ productDisplay.reference }}</span></div>
<div v-if="productDisplay.suppliers"><span class="font-medium">Fournisseurs :</span> <span>{{ productDisplay.suppliers }}</span></div>
<div v-if="productDisplay.price"><span class="font-medium">Prix indicatif :</span> <span>{{ productDisplay.price }}</span></div>
</div>
`,
})
</script>

View File

@@ -1,205 +0,0 @@
<template>
<div v-if="preview" 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">
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700">
<IconLucideEye class="w-4 h-4" aria-hidden="true" />
<span>Prévisualisation avant création</span>
</div>
<span class="badge" :class="getStatusBadgeClass(preview.status)">
{{ preview.status === 'ready' ? 'Prête à créer' : preview.status === 'warning' ? 'À compléter' : 'Bloquante' }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div
v-for="field in preview.base.fields"
:key="field.key"
class="flex flex-col gap-1"
>
<span class="text-[11px] uppercase tracking-wide text-gray-500">{{ field.label }}</span>
<span
class="text-sm font-medium"
:class="field.status === 'missing'
? 'text-error'
: field.status === 'optional'
? 'text-gray-500 italic'
: 'text-gray-900'"
>
{{ field.display }}
</span>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
<span class="badge badge-ghost badge-sm">Type : {{ preview.type.name }}</span>
<span v-if="preview.type.category" class="badge badge-ghost badge-sm">Catégorie : {{ preview.type.category }}</span>
<span class="badge badge-ghost badge-sm">Structure JSON : {{ preview.type.hasStructuredDefinition ? 'Oui' : 'Legacy' }}</span>
</div>
<!-- Base issues -->
<div v-if="preview.base.issues.length" class="rounded-md bg-warning/10 border border-warning/30 p-3 text-xs text-warning">
<p class="font-medium mb-1">
Informations générales incomplètes :
</p>
<ul class="space-y-1">
<li v-for="issue in preview.base.issues" :key="issue.message">
<button
type="button"
class="flex w-full items-start gap-2 text-left hover:underline"
@click="handleIssueClick(issue)"
>
<span class="mt-0.5 text-[8px] leading-none">&bull;</span>
<span>{{ issue.message }}</span>
</button>
</li>
</ul>
</div>
<!-- Component groups -->
<div v-if="preview.componentGroups.length" class="space-y-3">
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Composants hérités
</h5>
<PreviewRequirementGroup
v-for="group in preview.componentGroups"
:key="group.id"
:group="group"
/>
</div>
<div v-else class="text-xs text-gray-500">
Aucun composant n'est requis pour ce type de machine.
</div>
<!-- Piece groups -->
<div v-if="preview.pieceGroups.length" class="space-y-3">
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Pièces associées
</h5>
<PreviewRequirementGroup
v-for="group in preview.pieceGroups"
:key="group.id"
:group="group"
/>
</div>
<div v-else class="text-xs text-gray-500">
Aucun groupe de pièces à configurer pour ce type.
</div>
<!-- Product groups -->
<div v-if="preview.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 preview.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>
<!-- Global issues -->
<div
v-if="preview.issues.length && preview.status !== 'ready'"
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
>
<div class="flex items-start gap-2">
<IconLucideAlertTriangle class="w-4 h-4 mt-0.5" aria-hidden="true" />
<div class="space-y-1">
<p class="font-medium">
Points à vérifier avant la création :
</p>
<ul class="space-y-1">
<li v-for="issue in preview.issues" :key="`${issue.scope}-${issue.message}`">
<button
type="button"
class="flex w-full items-start gap-2 text-left hover:underline"
@click="handleIssueClick(issue)"
>
<span class="mt-0.5 text-[8px] leading-none">&bull;</span>
<span>
<span class="font-medium">{{ issue.scope }} :</span> {{ issue.message }}
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
getStatusBadgeClass,
handleIssueClick,
} from '~/composables/useMachineCreatePreview'
import PreviewRequirementGroup from './PreviewRequirementGroup.vue'
import IconLucideEye from '~icons/lucide/eye'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
import IconLucideCircle from '~icons/lucide/circle'
defineProps<{
preview: any
}>()
</script>

View File

@@ -1,59 +0,0 @@
<template>
<div 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">
Type : {{ group.typeName }} · Min {{ group.min }} ·
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
</p>
</div>
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
{{ group.completed }} / {{ group.total || 0 }} complétée(s)
</span>
</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 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>
</div>
</template>
<script setup lang="ts">
import { getStatusBadgeClass } from '~/composables/useMachineCreatePreview'
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
import IconLucideCircle from '~icons/lucide/circle'
defineProps<{
group: any
}>()
</script>

View File

@@ -1,126 +0,0 @@
<template>
<div v-if="requirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Sélection des composants
</h4>
<div
v-for="requirement in requirements"
:id="`component-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>
<h5 class="font-medium text-sm">
{{ requirement.label || requirement.typeComposant?.name || 'Famille de composants' }}
</h5>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeComposant?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
· Max : {{ requirement.maxCount ?? '∞' }}
</p>
</div>
<button
type="button"
class="btn btn-sm btn-outline"
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
@click="$emit('add-entry', requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucun composant sélectionné pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getEntries(requirement.id)"
:key="`${requirement.id}-${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>
Type appliqué :
{{ resolveTypeLabel(requirement, entry) }}
</span>
<button
type="button"
class="btn btn-square btn-xs btn-error"
@click="$emit('remove-entry', 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">Composant existant</span>
</label>
<SearchSelect
:model-value="entry.composantId || ''"
:options="getOptions(requirement, entry)"
:loading="loading"
size="sm"
placeholder="Rechercher un composant…"
empty-text="Aucun composant disponible"
:option-label="optionLabel"
:option-description="optionDescription"
@update:modelValue="$emit('set-component', requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="getOptions(requirement, entry).length === 0"
class="text-xs text-error"
>
Aucun composant disponible pour cette famille.
</p>
</div>
<div
v-if="entry.composantId"
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
>
<div class="font-medium">
{{ findById(entry.composantId)?.name || "Composant" }}
</div>
<div>
Référence : {{ findById(entry.composantId)?.reference || "—" }}
</div>
<div>
Fournisseur :
{{ findById(entry.composantId)?.constructeur?.name || findById(entry.composantId)?.constructeurName || "—" }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
defineProps<{
requirements: any[]
loading: boolean
getEntries: (requirementId: string) => any[]
getOptions: (requirement: any, entry: any) => any[]
resolveTypeLabel: (requirement: any, entry: any) => string
findById: (id: string) => any
optionLabel: (item: any) => string
optionDescription: (item: any) => string
}>()
defineEmits<{
'add-entry': [requirement: any]
'remove-entry': [requirementId: string, entryIndex: number]
'set-component': [requirement: any, entryIndex: number, componentId: string]
}>()
</script>

View File

@@ -1,130 +0,0 @@
<template>
<div v-if="requirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Sélection des pièces principales
</h4>
<div
v-for="requirement in requirements"
:id="`piece-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>
<h5 class="font-medium text-sm">
{{ requirement.label || requirement.typePiece?.name || 'Groupe de pièces' }}
</h5>
<p class="text-xs text-gray-500">
Type : {{ requirement.typePiece?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
· Max : {{ requirement.maxCount ?? '∞' }}
</p>
</div>
<button
type="button"
class="btn btn-sm btn-outline"
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
@click="$emit('add-entry', requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucune pièce sélectionnée pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getEntries(requirement.id)"
:key="`${requirement.id}-piece-${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>
Type appliqué :
{{ resolveTypeLabel(requirement, entry) }}
</span>
<button
type="button"
class="btn btn-square btn-xs btn-error"
@click="$emit('remove-entry', 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">Pièce existante</span>
</label>
<SearchSelect
:model-value="entry.pieceId || ''"
:options="getOptions(requirement, entry, entryIndex)"
:loading="loading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
size="sm"
placeholder="Rechercher une pièce…"
empty-text="Aucune pièce disponible"
:option-label="optionLabel"
:option-description="optionDescription"
@search="(term: string) => $emit('search', requirement, entryIndex, term)"
@update:modelValue="$emit('set-piece', requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="getOptions(requirement, entry, entryIndex).length === 0"
class="text-xs text-error"
>
Aucune pièce disponible pour cette famille.
</p>
</div>
<div
v-if="entry.pieceId"
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
>
<div class="font-medium">
{{ findById(entry.pieceId)?.name || "Pièce" }}
</div>
<div>
Référence : {{ findById(entry.pieceId)?.reference || "—" }}
</div>
<div>
Fournisseur :
{{ findById(entry.pieceId)?.constructeur?.name || findById(entry.pieceId)?.constructeurName || "—" }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
defineProps<{
requirements: any[]
loading: boolean
pieceLoadingByKey: Record<string, boolean>
getEntries: (requirementId: string) => any[]
getOptions: (requirement: any, entry: any, entryIndex: number) => any[]
getPieceKey: (requirement: any, entryIndex: number) => string
resolveTypeLabel: (requirement: any, entry: any) => string
findById: (id: string) => any
optionLabel: (item: any) => string
optionDescription: (item: any) => string
}>()
defineEmits<{
'add-entry': [requirement: any]
'remove-entry': [requirementId: string, entryIndex: number]
'set-piece': [requirement: any, entryIndex: number, pieceId: string]
'search': [requirement: any, entryIndex: number, term: string]
}>()
</script>

View File

@@ -1,142 +0,0 @@
<template>
<div v-if="requirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Produits catalogue requis
</h4>
<div
v-for="requirement in requirements"
: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 && getEntries(requirement.id).length >= requirement.maxCount"
@click="$emit('add-entry', requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucun produit sélectionné pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getEntries(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="$emit('remove-entry', 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="$emit('set-product', 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">
{{ findById(entry.productId)?.name || 'Produit' }}
</div>
<div>
Référence : {{ findById(entry.productId)?.reference || "—" }}
</div>
<div>
Prix indicatif :
<span
v-if="findById(entry.productId)?.supplierPrice !== undefined && findById(entry.productId)?.supplierPrice !== null"
>
{{ Number(findById(entry.productId)?.supplierPrice).toFixed(2) }}
</span>
<span v-else>
</span>
</div>
<div>
Fournisseurs :
<span v-if="findById(entry.productId)?.constructeurs?.length">
{{ findById(entry.productId)?.constructeurs.map((constructeur: any) => constructeur?.name).filter(Boolean).join(', ') }}
</span>
<span v-else>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ProductSelect from '~/components/ProductSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
defineProps<{
requirements: any[]
productsLoading: boolean
getEntries: (requirementId: string) => any[]
getProductOptions: (requirement: any) => any[]
findById: (id: string) => any
}>()
defineEmits<{
'add-entry': [requirement: any]
'remove-entry': [requirementId: string, entryIndex: number]
'set-product': [requirement: any, entryIndex: number, productId: string]
}>()
</script>