Enable adding a component, piece, or product to a machine by selecting only the category (ModelType) without a specific entity. The link displays a red "À remplir" badge; clicking it reopens the modal pre-filled with the category so the user can associate an item later. Backend: entity FKs made nullable on the 3 link tables, modelType FK added, controller/audit/version/MCP normalization adapted for null entities. Frontend: modal accepts category-only confirm, page handles fill mode, hierarchy builder creates pending nodes, display components show clickable badge with event propagation through the full hierarchy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
252 lines
8.3 KiB
Vue
252 lines
8.3 KiB
Vue
<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"
|
|
>
|
|
×
|
|
</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"
|
|
server-search
|
|
@search="handleEntitySearch"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="selectedTypeName && !selectedEntityId && !loadingEntities" class="bg-warning/10 border border-warning rounded-lg p-3 mb-4">
|
|
<p class="text-sm text-warning font-medium">
|
|
Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir".
|
|
</p>
|
|
</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="!selectedTypeId"
|
|
@click="handleConfirm"
|
|
>
|
|
{{ selectedEntityId ? 'Ajouter' : 'Ajouter (catégorie seule)' }}
|
|
</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
|
|
prefillTypeId?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: 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) => {
|
|
const name = e.name || '(sans nom)'
|
|
return e.reference ? `${name} — ${e.reference}` : name
|
|
}
|
|
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()
|
|
|
|
if (props.prefillTypeId) {
|
|
selectedTypeId.value = props.prefillTypeId
|
|
}
|
|
})
|
|
|
|
// 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
|
|
}
|
|
})
|
|
|
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const handleEntitySearch = (term: string) => {
|
|
if (searchDebounce) clearTimeout(searchDebounce)
|
|
searchDebounce = setTimeout(async () => {
|
|
if (!selectedTypeName.value) return
|
|
loadingEntities.value = true
|
|
try {
|
|
if (props.entityKind === 'component') {
|
|
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
|
entities.value = result?.data?.items || []
|
|
} else if (props.entityKind === 'piece') {
|
|
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
|
entities.value = result?.data?.items || []
|
|
} else {
|
|
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
|
entities.value = result?.data?.items || []
|
|
}
|
|
} finally {
|
|
loadingEntities.value = false
|
|
}
|
|
}, 300)
|
|
}
|
|
|
|
const handleClose = () => {
|
|
resetState()
|
|
emit('close')
|
|
}
|
|
|
|
const handleConfirm = () => {
|
|
if (!selectedTypeId.value) return
|
|
emit('confirm', {
|
|
entityId: selectedEntityId.value || undefined,
|
|
modelTypeId: selectedTypeId.value,
|
|
modelTypeName: selectedTypeName.value,
|
|
})
|
|
resetState()
|
|
emit('close')
|
|
}
|
|
|
|
const resetState = () => {
|
|
selectedTypeId.value = ''
|
|
selectedEntityId.value = ''
|
|
entities.value = []
|
|
}
|
|
</script>
|