feat(frontend): add reusable search select and wire it into machine creation
fix(frontend): guard custom field persistence against non-string values
This commit is contained in:
335
app/components/common/SearchSelect.vue
Normal file
335
app/components/common/SearchSelect.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="space-y-1 search-select">
|
||||
<label v-if="$slots.label" class="label">
|
||||
<span class="label-text">
|
||||
<slot name="label" />
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:class="inputClasses"
|
||||
@focus="handleFocus"
|
||||
@keydown.down.prevent="highlightNext"
|
||||
@keydown.up.prevent="highlightPrevious"
|
||||
@keydown.enter.prevent="selectHighlighted"
|
||||
@input="handleInput"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="toggleButtonClasses"
|
||||
@click="toggleDropdown"
|
||||
aria-label="Afficher les options"
|
||||
>
|
||||
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="openDropdown"
|
||||
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-gray-500">
|
||||
<span class="loading loading-spinner loading-xs" />
|
||||
Recherche en cours…
|
||||
</div>
|
||||
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-gray-500">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
<ul v-else class="menu p-0">
|
||||
<li
|
||||
v-for="(option, index) in displayedOptions"
|
||||
:key="resolveValue(option) ?? index"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
:class="{
|
||||
'bg-base-200': isOptionSelected(option),
|
||||
'bg-base-300/60': highlightedIndex === index
|
||||
}"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
@mouseleave="highlightedIndex = -1"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">
|
||||
<slot name="option-label" :option="option">
|
||||
{{ resolveLabel(option) }}
|
||||
</slot>
|
||||
</span>
|
||||
<span v-if="resolveDescription(option)" class="text-xs text-gray-500">
|
||||
<slot name="option-description" :option="option">
|
||||
{{ resolveDescription(option) }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechercher…'
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: 'Aucun résultat'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
optionValue: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
optionLabel: {
|
||||
type: [String, Function],
|
||||
default: 'name'
|
||||
},
|
||||
optionDescription: {
|
||||
type: [String, Function],
|
||||
default: null
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['xs', 'sm', 'md', 'lg'].includes(value)
|
||||
},
|
||||
maxVisible: {
|
||||
type: Number,
|
||||
default: 50
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const inputRef = ref(null)
|
||||
|
||||
const baseOptions = computed(() => Array.isArray(props.options) ? props.options : [])
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return baseOptions.value.find(option => isEqualValue(resolveValue(option), props.modelValue)) || null
|
||||
})
|
||||
|
||||
const displayedOptions = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const items = baseOptions.value.slice()
|
||||
|
||||
const filtered = term
|
||||
? items.filter((option) => {
|
||||
const label = resolveLabel(option).toLowerCase()
|
||||
const description = resolveDescription(option)?.toLowerCase() || ''
|
||||
return label.includes(term) || description.includes(term)
|
||||
})
|
||||
: items
|
||||
|
||||
if (props.maxVisible && filtered.length > props.maxVisible) {
|
||||
return filtered.slice(0, props.maxVisible)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const base = ['input', 'input-bordered', 'w-full', 'pr-10']
|
||||
if (props.size === 'xs') base.push('input-xs')
|
||||
if (props.size === 'sm') base.push('input-sm')
|
||||
if (props.size === 'lg') base.push('input-lg')
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
const toggleButtonClasses = computed(() => {
|
||||
const base = ['absolute', 'top-1/2', '-translate-y-1/2', 'right-2', 'btn', 'btn-ghost']
|
||||
if (props.size === 'xs' || props.size === 'sm') {
|
||||
base.push('btn-xs')
|
||||
} else {
|
||||
base.push('btn-sm')
|
||||
}
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
baseOptions,
|
||||
() => {
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : searchTerm.value
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(openDropdown, (isOpen) => {
|
||||
if (isOpen) {
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
})
|
||||
|
||||
function resolveValue (option) {
|
||||
if (!option) {
|
||||
return null
|
||||
}
|
||||
if (typeof props.optionValue === 'function') {
|
||||
return props.optionValue(option)
|
||||
}
|
||||
return option[props.optionValue]
|
||||
}
|
||||
|
||||
function resolveLabel (option) {
|
||||
if (!option) {
|
||||
return ''
|
||||
}
|
||||
if (typeof props.optionLabel === 'function') {
|
||||
return props.optionLabel(option) || ''
|
||||
}
|
||||
return option[props.optionLabel] || ''
|
||||
}
|
||||
|
||||
function resolveDescription (option) {
|
||||
if (!option || !props.optionDescription) {
|
||||
return ''
|
||||
}
|
||||
if (typeof props.optionDescription === 'function') {
|
||||
return props.optionDescription(option) || ''
|
||||
}
|
||||
return option[props.optionDescription] || ''
|
||||
}
|
||||
|
||||
function isEqualValue (a, b) {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
return String(a ?? '') === String(b ?? '')
|
||||
}
|
||||
|
||||
function isOptionSelected (option) {
|
||||
return isEqualValue(resolveValue(option), props.modelValue)
|
||||
}
|
||||
|
||||
function selectOption (option) {
|
||||
emit('update:modelValue', resolveValue(option) ?? '')
|
||||
searchTerm.value = resolveLabel(option)
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function handleFocus () {
|
||||
openDropdown.value = true
|
||||
if (searchTerm.value === '' && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDropdown () {
|
||||
openDropdown.value = !openDropdown.value
|
||||
if (openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
if (openDropdown.value && inputRef.value) {
|
||||
inputRef.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
function highlightNext () {
|
||||
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % displayedOptions.value.length
|
||||
}
|
||||
|
||||
function highlightPrevious () {
|
||||
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
highlightedIndex.value =
|
||||
highlightedIndex.value <= 0
|
||||
? displayedOptions.value.length - 1
|
||||
: highlightedIndex.value - 1
|
||||
}
|
||||
|
||||
function selectHighlighted () {
|
||||
if (!openDropdown.value) {
|
||||
return
|
||||
}
|
||||
if (highlightedIndex.value >= 0 && highlightedIndex.value < displayedOptions.value.length) {
|
||||
selectOption(displayedOptions.value[highlightedIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalClick = (event) => {
|
||||
if (!openDropdown.value) {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (target?.closest?.('.search-select')) {
|
||||
return
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleGlobalClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -50,12 +50,22 @@
|
||||
<td>{{ component.typeComposant?.name || '—' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -70,9 +80,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
const { composants, loadComposants, loading: loadingComposantsRef } = useComposants()
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const { showError } = useToast()
|
||||
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||
const composantsList = computed(() => composants.value || [])
|
||||
|
||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
const hasLinkedElements =
|
||||
(component?.machineLinks?.length ?? 0) > 0 ||
|
||||
(component?.documents?.length ?? 0) > 0 ||
|
||||
(component?.customFieldValues?.length ?? 0) > 0
|
||||
|
||||
if (hasLinkedElements) {
|
||||
showError('Impossible de supprimer ce composant car il possède des éléments liés.')
|
||||
return
|
||||
}
|
||||
|
||||
const componentName = component?.name || 'ce composant'
|
||||
const confirmed = window.confirm(`Voulez-vous vraiment supprimer ${componentName} ?`)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
await deleteComposant(component.id)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadComposants()
|
||||
})
|
||||
|
||||
@@ -347,7 +347,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -358,6 +358,19 @@ const canSubmit = computed(() => Boolean(
|
||||
!saving.value,
|
||||
))
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const fetchComponent = async () => {
|
||||
const id = route.params.id
|
||||
if (!id || typeof id !== 'string') {
|
||||
@@ -636,14 +649,14 @@ const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value.trim() !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return field.value.trim()
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
|
||||
const saveCustomFieldValues = async (updatedComponent: any) => {
|
||||
|
||||
@@ -597,7 +597,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -609,6 +609,19 @@ const canSubmit = computed(() => Boolean(
|
||||
!submitting.value,
|
||||
))
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
}
|
||||
@@ -917,13 +930,13 @@ const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value.trim() !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return field.value.trim()
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -53,14 +53,15 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Type de machine</span>
|
||||
</label>
|
||||
<select v-model="newMachine.typeMachineId" class="select select-bordered" required>
|
||||
<option value="">
|
||||
Sélectionner un type
|
||||
</option>
|
||||
<option v-for="type in machineTypes" :key="type.id" :value="type.id">
|
||||
{{ type.name }} ({{ type.category }})
|
||||
</option>
|
||||
</select>
|
||||
<SearchSelect
|
||||
v-model="newMachine.typeMachineId"
|
||||
:options="machineTypes"
|
||||
:loading="machineTypesLoading"
|
||||
placeholder="Rechercher un type…"
|
||||
empty-text="Aucun type trouvé"
|
||||
:option-label="machineTypeLabel"
|
||||
:option-description="machineTypeDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
@@ -164,22 +165,17 @@
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Composant existant</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered select-sm"
|
||||
:value="entry.composantId || ''"
|
||||
@change="setComponentRequirementComponent(requirement, entryIndex, ($event.target && $event.target.value) || '')"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner un composant disponible
|
||||
</option>
|
||||
<option
|
||||
v-for="component in getComponentOptions(requirement, entry)"
|
||||
:key="component.id"
|
||||
:value="component.id"
|
||||
>
|
||||
{{ formatComponentOption(component) }}
|
||||
</option>
|
||||
</select>
|
||||
<SearchSelect
|
||||
:model-value="entry.composantId || ''"
|
||||
:options="getComponentOptions(requirement, entry)"
|
||||
:loading="composantsLoading"
|
||||
size="sm"
|
||||
placeholder="Rechercher un composant…"
|
||||
empty-text="Aucun composant disponible"
|
||||
:option-label="componentOptionLabel"
|
||||
:option-description="componentOptionDescription"
|
||||
@update:modelValue="setComponentRequirementComponent(requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="getComponentOptions(requirement, entry).length === 0"
|
||||
@@ -271,22 +267,17 @@
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Pièce existante</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered select-sm"
|
||||
:value="entry.pieceId || ''"
|
||||
@change="setPieceRequirementPiece(requirement, entryIndex, ($event.target && $event.target.value) || '')"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner une pièce disponible
|
||||
</option>
|
||||
<option
|
||||
v-for="pieceOption in getPieceOptions(requirement, entry)"
|
||||
:key="pieceOption.id"
|
||||
:value="pieceOption.id"
|
||||
>
|
||||
{{ formatPieceOption(pieceOption) }}
|
||||
</option>
|
||||
</select>
|
||||
<SearchSelect
|
||||
:model-value="entry.pieceId || ''"
|
||||
:options="getPieceOptions(requirement, entry)"
|
||||
:loading="piecesLoading"
|
||||
size="sm"
|
||||
placeholder="Rechercher une pièce…"
|
||||
empty-text="Aucune pièce disponible"
|
||||
:option-label="pieceOptionLabel"
|
||||
:option-description="pieceOptionDescription"
|
||||
@update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="getPieceOptions(requirement, entry).length === 0"
|
||||
@@ -562,6 +553,7 @@ import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
@@ -571,9 +563,9 @@ import IconLucideCircle from '~icons/lucide/circle'
|
||||
|
||||
const { createMachine, createMachineFromType } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||
const { composants, loadComposants } = useComposants()
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
||||
const toast = useToast()
|
||||
|
||||
const submitting = ref(false)
|
||||
@@ -595,6 +587,27 @@ const selectedMachineType = computed(() => {
|
||||
return machineTypes.value.find(type => type.id === newMachine.typeMachineId) || null
|
||||
})
|
||||
|
||||
const machineTypeLabel = (type) => {
|
||||
if (!type) {
|
||||
return ''
|
||||
}
|
||||
return type.name || 'Type de machine'
|
||||
}
|
||||
|
||||
const machineTypeDescription = (type) => {
|
||||
if (!type) {
|
||||
return ''
|
||||
}
|
||||
const parts = []
|
||||
if (type.category) {
|
||||
parts.push(`Catégorie : ${type.category}`)
|
||||
}
|
||||
const componentCount = type.componentRequirements?.length ?? 0
|
||||
const pieceCount = type.pieceRequirements?.length ?? 0
|
||||
parts.push(`${componentCount} composant(s)`, `${pieceCount} pièce(s)`)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const componentById = computed(() => {
|
||||
const map = new Map()
|
||||
;(composants.value || []).forEach((component) => {
|
||||
@@ -891,11 +904,13 @@ const getPieceOptions = (requirement, currentEntry) => {
|
||||
})
|
||||
}
|
||||
|
||||
const formatComponentOption = (component) => {
|
||||
const componentOptionLabel = (component) => component?.name || 'Composant'
|
||||
|
||||
const componentOptionDescription = (component) => {
|
||||
if (!component) {
|
||||
return ''
|
||||
}
|
||||
const parts = [component.name || 'Composant']
|
||||
const parts = []
|
||||
if (component.reference) {
|
||||
parts.push(`Réf. ${component.reference}`)
|
||||
}
|
||||
@@ -910,11 +925,13 @@ const formatComponentOption = (component) => {
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatPieceOption = (piece) => {
|
||||
const pieceOptionLabel = (piece) => piece?.name || 'Pièce'
|
||||
|
||||
const pieceOptionDescription = (piece) => {
|
||||
if (!piece) {
|
||||
return ''
|
||||
}
|
||||
const parts = [piece.name || 'Pièce']
|
||||
const parts = []
|
||||
if (piece.reference) {
|
||||
parts.push(`Réf. ${piece.reference}`)
|
||||
}
|
||||
|
||||
@@ -49,12 +49,22 @@
|
||||
<td>{{ piece.typePiece?.name || '—' }}</td>
|
||||
<td>{{ piece.reference || '—' }}</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
:to="`/pieces/${piece.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/pieces/${piece.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingPieces"
|
||||
@click="handleDeletePiece(piece)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -69,10 +79,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
const { pieces, loadPieces, loading: loadingPiecesRef } = usePieces()
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const { showError } = useToast()
|
||||
const { pieces, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||
const piecesList = computed(() => pieces.value || [])
|
||||
|
||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||
const hasLinkedElements =
|
||||
(piece?.machineLinks?.length ?? 0) > 0 ||
|
||||
(piece?.documents?.length ?? 0) > 0 ||
|
||||
(piece?.customFieldValues?.length ?? 0) > 0
|
||||
|
||||
if (hasLinkedElements) {
|
||||
showError('Impossible de supprimer cette pièce car elle possède des éléments liés.')
|
||||
return
|
||||
}
|
||||
|
||||
const pieceName = piece?.name || 'cette pièce'
|
||||
const confirmed = window.confirm(`Voulez-vous vraiment supprimer ${pieceName} ?`)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
await deletePiece(piece.id)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPieces()
|
||||
})
|
||||
|
||||
@@ -314,7 +314,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -325,6 +325,19 @@ const canSubmit = computed(() => Boolean(
|
||||
!saving.value,
|
||||
))
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const fetchPiece = async () => {
|
||||
const id = route.params.id
|
||||
if (!id || typeof id !== 'string') {
|
||||
@@ -547,14 +560,14 @@ const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value.trim() !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return field.value.trim()
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
|
||||
const saveCustomFieldValues = async (updatedPiece: any) => {
|
||||
|
||||
@@ -320,7 +320,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -331,6 +331,19 @@ const canSubmit = computed(() => Boolean(
|
||||
!submitting.value,
|
||||
))
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const clearCreationForm = () => {
|
||||
@@ -565,13 +578,13 @@ const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value.trim() !== ''
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return field.value.trim()
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user