fix champs personnalisé update
This commit is contained in:
@@ -150,48 +150,52 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Fields Display - Editable or Read-only -->
|
<!-- Custom Fields Display - Editable or Read-only -->
|
||||||
<div v-if="component.customFields && component.customFields.length > 0" class="mt-4 pt-4 border-t border-gray-200">
|
<div v-if="displayedCustomFields.length" class="mt-4 pt-4 border-t border-gray-200">
|
||||||
<h4 class="font-semibold text-sm text-gray-700 mb-3">
|
<h4 class="font-semibold text-sm text-gray-700 mb-3">
|
||||||
Champs personnalisés
|
Champs personnalisés
|
||||||
</h4>
|
</h4>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div v-for="field in component.customFields" :key="field.id" class="form-control">
|
<div
|
||||||
|
v-for="(field, index) in displayedCustomFields"
|
||||||
|
:key="resolveFieldKey(field, index)"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm">{{ field.name }}</span>
|
<span class="label-text text-sm">{{ resolveFieldName(field) }}</span>
|
||||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
<span v-if="resolveFieldRequired(field)" class="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||||
<input
|
<input
|
||||||
v-if="field.type === 'text'"
|
v-if="resolveFieldType(field) === 'text'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="field.required"
|
:required="resolveFieldRequired(field)"
|
||||||
@blur="updateComponentCustomField(field)"
|
@blur="updateComponentCustomField(field)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="resolveFieldType(field) === 'number'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="number"
|
type="number"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="field.required"
|
:required="resolveFieldRequired(field)"
|
||||||
@blur="updateComponentCustomField(field)"
|
@blur="updateComponentCustomField(field)"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="resolveFieldType(field) === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
:required="field.required"
|
:required="resolveFieldRequired(field)"
|
||||||
@change="updateComponentCustomField(field)"
|
@change="updateComponentCustomField(field)"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Sélectionner...
|
Sélectionner...
|
||||||
</option>
|
</option>
|
||||||
<option v-for="option in field.options" :key="option" :value="option">
|
<option v-for="option in resolveFieldOptions(field)" :key="option" :value="option">
|
||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<div v-else-if="resolveFieldType(field) === 'boolean'" class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -200,20 +204,20 @@
|
|||||||
false-value="false"
|
false-value="false"
|
||||||
@change="updateComponentCustomField(field)"
|
@change="updateComponentCustomField(field)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="resolveFieldType(field) === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="field.required"
|
:required="resolveFieldRequired(field)"
|
||||||
@blur="updateComponentCustomField(field)"
|
@blur="updateComponentCustomField(field)"
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="input input-bordered input-sm bg-base-200">
|
<div class="input input-bordered input-sm bg-base-200">
|
||||||
{{ field.value || 'Non défini' }}
|
{{ formatFieldDisplayValue(field) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,6 +356,8 @@ import { getFileIcon } from '~/utils/fileIcons'
|
|||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
component: {
|
component: {
|
||||||
@@ -409,6 +415,160 @@ const componentModelOptionsList = computed(() => {
|
|||||||
return Array.isArray(provided) && provided.length ? provided : props.componentModelOptions
|
return Array.isArray(provided) && provided.length ? provided : props.componentModelOptions
|
||||||
})
|
})
|
||||||
const pieceModelOptionsList = computed(() => props.pieceModelOptionsProvider(props.component) || [])
|
const pieceModelOptionsList = computed(() => props.pieceModelOptionsProvider(props.component) || [])
|
||||||
|
function fieldKeyFromNameAndType(name, type) {
|
||||||
|
const normalizedName = typeof name === 'string' ? name : ''
|
||||||
|
const normalizedType = typeof type === 'string' ? type : ''
|
||||||
|
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFieldDefinitionsWithValues(definitions, values) {
|
||||||
|
const definitionList = Array.isArray(definitions) ? definitions : []
|
||||||
|
const valueList = Array.isArray(values) ? values : []
|
||||||
|
|
||||||
|
const valueMap = new Map()
|
||||||
|
valueList.forEach((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||||
|
if (fieldId) {
|
||||||
|
valueMap.set(fieldId, entry)
|
||||||
|
}
|
||||||
|
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||||
|
if (nameKey) {
|
||||||
|
valueMap.set(nameKey, entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = definitionList.map((field) => {
|
||||||
|
if (!field || typeof field !== 'object') {
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldId = ensureCustomFieldId(field)
|
||||||
|
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
|
||||||
|
|
||||||
|
const matchedValue =
|
||||||
|
(fieldId ? valueMap.get(fieldId) : undefined) ??
|
||||||
|
(nameKey ? valueMap.get(nameKey) : undefined)
|
||||||
|
|
||||||
|
if (!matchedValue) {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
value: field?.value ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
||||||
|
customFieldId:
|
||||||
|
matchedValue.customField?.id ??
|
||||||
|
matchedValue.customFieldId ??
|
||||||
|
fieldId ??
|
||||||
|
null,
|
||||||
|
customField: matchedValue.customField ?? field.customField ?? null,
|
||||||
|
value: matchedValue.value ?? field.value ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
valueList.forEach((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||||
|
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||||
|
|
||||||
|
const exists = merged.some((field) => {
|
||||||
|
if (!field || typeof field !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (field.customFieldValueId && field.customFieldValueId === entry.id) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const existingId = ensureCustomFieldId(field)
|
||||||
|
if (fieldId && existingId && existingId === fieldId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!fieldId && nameKey) {
|
||||||
|
return (
|
||||||
|
fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
merged.push({
|
||||||
|
customFieldValueId: entry.id ?? null,
|
||||||
|
customFieldId: fieldId,
|
||||||
|
name: entry.customField?.name ?? '',
|
||||||
|
type: entry.customField?.type ?? 'text',
|
||||||
|
required: entry.customField?.required ?? false,
|
||||||
|
options: entry.customField?.options ?? [],
|
||||||
|
value: entry.value ?? '',
|
||||||
|
customField: entry.customField ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedCustomFields = computed(() =>
|
||||||
|
mergeFieldDefinitionsWithValues(
|
||||||
|
props.component.customFields,
|
||||||
|
props.component.customFieldValues,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const candidateCustomFields = computed(() => {
|
||||||
|
const sources = [
|
||||||
|
props.component.customFieldValues?.map((value) => value?.customField),
|
||||||
|
props.component.typeComposant?.customFields,
|
||||||
|
props.component.typeMachineComponentRequirement?.typeComposant?.customFields,
|
||||||
|
props.component.composantModel?.customFields,
|
||||||
|
props.component.typeMachineComponentRequirement?.customFields,
|
||||||
|
props.component.customFields,
|
||||||
|
]
|
||||||
|
|
||||||
|
const map = new Map()
|
||||||
|
sources.forEach((collection) => {
|
||||||
|
if (!Array.isArray(collection)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collection.forEach((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = item.id || item.customFieldId
|
||||||
|
const name = typeof item.name === 'string' ? item.name : null
|
||||||
|
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
|
||||||
|
if (!key || map.has(key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
map.set(key, item)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(map.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
candidateCustomFields,
|
||||||
|
() => {
|
||||||
|
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
displayedCustomFields,
|
||||||
|
(fields) => {
|
||||||
|
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
const handleConstructeurChange = async (value) => {
|
const handleConstructeurChange = async (value) => {
|
||||||
props.component.constructeurId = value
|
props.component.constructeurId = value
|
||||||
@@ -416,6 +576,11 @@ const handleConstructeurChange = async (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
|
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
|
||||||
|
const {
|
||||||
|
updateCustomFieldValue: updateComponentCustomFieldValueApi,
|
||||||
|
upsertCustomFieldValue: upsertComponentCustomFieldValue,
|
||||||
|
} = useCustomFields()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.toggleToken,
|
() => props.toggleToken,
|
||||||
@@ -446,8 +611,214 @@ const updateComponent = () => {
|
|||||||
emit('update', props.component)
|
emit('update', props.component)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateComponentCustomField = () => {
|
function resolveFieldKey(field, index) {
|
||||||
emit('update', props.component)
|
return field?.id
|
||||||
|
?? field?.customFieldValueId
|
||||||
|
?? field?.customFieldId
|
||||||
|
?? field?.name
|
||||||
|
?? `field-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldId(field) {
|
||||||
|
return field?.customFieldValueId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldName(field) {
|
||||||
|
return field?.name ?? 'Champ'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldType(field) {
|
||||||
|
return field?.type ?? 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldOptions(field) {
|
||||||
|
return field?.options ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldRequired(field) {
|
||||||
|
return !!field?.required
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldReadOnly(field) {
|
||||||
|
return !!field?.readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCustomFieldMetadata(field) {
|
||||||
|
return {
|
||||||
|
customFieldName: resolveFieldName(field),
|
||||||
|
customFieldType: resolveFieldType(field),
|
||||||
|
customFieldRequired: resolveFieldRequired(field),
|
||||||
|
customFieldOptions: resolveFieldOptions(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCustomFieldId(field) {
|
||||||
|
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCustomFieldId(field) {
|
||||||
|
const existingId = resolveCustomFieldId(field)
|
||||||
|
if (existingId) {
|
||||||
|
return existingId
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = resolveFieldName(field)
|
||||||
|
if (!name || name === 'Champ') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = candidateCustomFields.value.filter((candidate) => {
|
||||||
|
if (!candidate || typeof candidate !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const candidateId = candidate.id || candidate.customFieldId
|
||||||
|
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return typeof candidate.name === 'string' && candidate.name === name
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matches.length) {
|
||||||
|
const withId = matches.find((candidate) => candidate?.id || candidate?.customFieldId) || matches[0]
|
||||||
|
const id = withId?.id || withId?.customFieldId || null
|
||||||
|
if (id) {
|
||||||
|
field.customFieldId = id
|
||||||
|
}
|
||||||
|
if (!field.customField && typeof withId === 'object') {
|
||||||
|
field.customField = withId
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
candidateCustomFields,
|
||||||
|
() => {
|
||||||
|
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
displayedCustomFields,
|
||||||
|
(fields) => {
|
||||||
|
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
const formatFieldDisplayValue = (field) => {
|
||||||
|
const type = resolveFieldType(field)
|
||||||
|
const rawValue = field?.value ?? ''
|
||||||
|
if (type === 'boolean') {
|
||||||
|
const normalized = String(rawValue).toLowerCase()
|
||||||
|
if (normalized === 'true') return 'Oui'
|
||||||
|
if (normalized === 'false') return 'Non'
|
||||||
|
}
|
||||||
|
return rawValue || 'Non défini'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateComponentCustomField = async (field) => {
|
||||||
|
if (!field || resolveFieldReadOnly(field)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldValueId = resolveFieldId(field)
|
||||||
|
if (fieldValueId) {
|
||||||
|
const result = await updateComponentCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||||
|
if (result.success) {
|
||||||
|
const existingValue = props.component.customFieldValues?.find((value) => value.id === fieldValueId)
|
||||||
|
if (existingValue?.customField?.id) {
|
||||||
|
field.customFieldId = existingValue.customField.id
|
||||||
|
field.customField = existingValue.customField
|
||||||
|
}
|
||||||
|
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
||||||
|
} else {
|
||||||
|
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFieldId = ensureCustomFieldId(field)
|
||||||
|
const fieldName = resolveFieldName(field)
|
||||||
|
if (!props.component?.id) {
|
||||||
|
showError('Impossible de créer la valeur pour ce champ de composant')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
||||||
|
showError('Impossible de créer la valeur pour ce champ de composant')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
||||||
|
|
||||||
|
const result = await upsertComponentCustomFieldValue(
|
||||||
|
customFieldId,
|
||||||
|
'composant',
|
||||||
|
props.component.id,
|
||||||
|
field.value ?? '',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const newValue = result.data
|
||||||
|
if (newValue?.id) {
|
||||||
|
field.customFieldValueId = newValue.id
|
||||||
|
field.value = newValue.value ?? field.value ?? ''
|
||||||
|
if (newValue.customField?.id) {
|
||||||
|
field.customFieldId = newValue.customField.id
|
||||||
|
field.customField = newValue.customField
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(props.component.customFieldValues)) {
|
||||||
|
const index = props.component.customFieldValues.findIndex((value) => value.id === newValue.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
props.component.customFieldValues.splice(index, 1, newValue)
|
||||||
|
} else {
|
||||||
|
props.component.customFieldValues.push(newValue)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props.component.customFieldValues = [newValue]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
||||||
|
|
||||||
|
const definitions = Array.isArray(props.component.customFields)
|
||||||
|
? [...props.component.customFields]
|
||||||
|
: []
|
||||||
|
const fieldIdentifier = ensureCustomFieldId(field)
|
||||||
|
const existingIndex = definitions.findIndex((definition) => {
|
||||||
|
const definitionId = ensureCustomFieldId(definition)
|
||||||
|
if (fieldIdentifier && definitionId) {
|
||||||
|
return definitionId === fieldIdentifier
|
||||||
|
}
|
||||||
|
return definition?.name === resolveFieldName(field)
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedDefinition = {
|
||||||
|
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
||||||
|
customFieldValueId: field.customFieldValueId,
|
||||||
|
customFieldId: fieldIdentifier,
|
||||||
|
name: resolveFieldName(field),
|
||||||
|
type: resolveFieldType(field),
|
||||||
|
required: resolveFieldRequired(field),
|
||||||
|
options: resolveFieldOptions(field),
|
||||||
|
value: field.value ?? '',
|
||||||
|
customField: field.customField ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
definitions.splice(existingIndex, 1, updatedDefinition)
|
||||||
|
} else {
|
||||||
|
definitions.push(updatedDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
props.component.customFields = definitions
|
||||||
|
} else {
|
||||||
|
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePiece = (updatedPiece) => {
|
const updatePiece = (updatedPiece) => {
|
||||||
|
|||||||
@@ -1,138 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="space-y-3">
|
<StructureNodeEditor
|
||||||
<div class="flex items-center justify-between">
|
:node="localStructure"
|
||||||
<h3 class="text-sm font-semibold">Champs personnalisés du composant</h3>
|
:depth="0"
|
||||||
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
|
:component-types="availableComponentTypes"
|
||||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
:piece-types="availablePieceTypes"
|
||||||
Ajouter
|
is-root
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
<p v-if="!(localStructure.customFields?.length)" class="text-xs text-gray-500">
|
|
||||||
Aucun champ n'a encore été défini.
|
|
||||||
</p>
|
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="(field, index) in localStructure.customFields"
|
|
||||||
:key="`root-field-${index}`"
|
|
||||||
class="border border-base-200 rounded-md p-3 space-y-2"
|
|
||||||
>
|
|
||||||
<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-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 Option 2"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeCustomField(index)">
|
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold">Pièces incluses par défaut</h3>
|
|
||||||
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
|
||||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="!(localStructure.pieces?.length)" class="text-xs text-gray-500">Aucune pièce définie.</p>
|
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="(piece, index) in localStructure.pieces"
|
|
||||||
:key="`root-piece-${index}`"
|
|
||||||
class="border border-base-200 rounded-md p-3 space-y-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="flex-1 space-y-3">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Famille de pièce</span></label>
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
v-model="piece.typePieceId"
|
|
||||||
class="select select-bordered select-xs"
|
|
||||||
@change="handlePieceTypeSelect(piece)"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
Sélectionner une famille
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="type in availablePieceTypes"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ formatPieceTypeOption(type) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-[11px] text-gray-500">
|
|
||||||
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold">Sous-composants</h3>
|
|
||||||
<button type="button" class="btn btn-outline btn-xs" @click="addSubComponent">
|
|
||||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
|
||||||
Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="!(localStructure.subComponents?.length)" class="text-xs text-gray-500">
|
|
||||||
Aucun sous-composant défini.
|
|
||||||
</p>
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<StructureSubComponentEditor
|
|
||||||
v-for="(subComponent, index) in localStructure.subComponents"
|
|
||||||
:key="`root-sub-${index}`"
|
|
||||||
:node="subComponent"
|
|
||||||
:depth="0"
|
|
||||||
:component-types="availableComponentTypes"
|
|
||||||
@remove="removeSubComponent(index)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch, computed, onMounted } from 'vue'
|
import { reactive, watch, computed, onMounted } from 'vue'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import StructureNodeEditor from '~/components/StructureNodeEditor.vue'
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
|
||||||
import StructureSubComponentEditor from '~/components/StructureSubComponentEditor.vue'
|
|
||||||
import {
|
import {
|
||||||
defaultStructure,
|
defaultStructure,
|
||||||
hydrateStructureForEditor,
|
hydrateStructureForEditor,
|
||||||
@@ -185,201 +65,11 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelTypeOption = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
code?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) => {
|
|
||||||
if (!type) return ''
|
|
||||||
return type.code ? `${type.name} (${type.code})` : type.name
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
|
|
||||||
const availablePieceTypes = computed<ModelTypeOption[]>(() => pieceTypes.value ?? [])
|
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
|
||||||
const availableComponentTypes = computed<ModelTypeOption[]>(() => componentTypes.value ?? [])
|
const availableComponentTypes = computed(() => componentTypes.value ?? [])
|
||||||
|
|
||||||
const pieceTypeMap = computed(() => {
|
|
||||||
const map = new Map<string, ModelTypeOption>()
|
|
||||||
availablePieceTypes.value.forEach((type) => {
|
|
||||||
if (type && typeof type.id === 'string') {
|
|
||||||
map.set(type.id, type)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const componentTypeMap = computed(() => {
|
|
||||||
const map = new Map<string, ModelTypeOption>()
|
|
||||||
availableComponentTypes.value.forEach((type) => {
|
|
||||||
if (type && typeof type.id === 'string') {
|
|
||||||
map.set(type.id, type)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) => formatModelTypeOption(type)
|
|
||||||
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) => formatModelTypeOption(type)
|
|
||||||
|
|
||||||
const resolvePieceType = (input: string) => {
|
|
||||||
const normalized = input.trim().toLowerCase()
|
|
||||||
if (!normalized) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
availablePieceTypes.value.find((type) => {
|
|
||||||
const formatted = formatPieceTypeOption(type).toLowerCase()
|
|
||||||
const name = (type?.name ?? '').toLowerCase()
|
|
||||||
const code = (type?.code ?? '').toLowerCase()
|
|
||||||
return (
|
|
||||||
formatted === normalized
|
|
||||||
|| name === normalized
|
|
||||||
|| (!!code && code === normalized)
|
|
||||||
)
|
|
||||||
}) ?? null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveComponentType = (input: string) => {
|
|
||||||
const normalized = input.trim().toLowerCase()
|
|
||||||
if (!normalized) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
availableComponentTypes.value.find((type) => {
|
|
||||||
const formatted = formatComponentTypeOption(type).toLowerCase()
|
|
||||||
const name = (type?.name ?? '').toLowerCase()
|
|
||||||
const code = (type?.code ?? '').toLowerCase()
|
|
||||||
return (
|
|
||||||
formatted === normalized
|
|
||||||
|| name === normalized
|
|
||||||
|| (!!code && code === normalized)
|
|
||||||
)
|
|
||||||
}) ?? null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPieceTypeLabel = (id?: string) => {
|
|
||||||
if (!id) return ''
|
|
||||||
const option = pieceTypeMap.value.get(id)
|
|
||||||
return formatPieceTypeOption(option)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePieceTypeLabel = (piece: any) => {
|
|
||||||
if (!piece) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (piece.typePieceId) {
|
|
||||||
const option = pieceTypeMap.value.get(piece.typePieceId)
|
|
||||||
if (option) {
|
|
||||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
|
||||||
piece.name = option.name || formatPieceTypeOption(option)
|
|
||||||
} else if (!piece.typePieceLabel) {
|
|
||||||
piece.name = ''
|
|
||||||
}
|
|
||||||
} else if (piece.typePieceLabel) {
|
|
||||||
const match = resolvePieceType(piece.typePieceLabel)
|
|
||||||
if (match) {
|
|
||||||
piece.typePieceId = match.id
|
|
||||||
piece.typePieceLabel = formatPieceTypeOption(match)
|
|
||||||
piece.name = match.name || formatPieceTypeOption(match)
|
|
||||||
} else if (!piece.name) {
|
|
||||||
piece.name = piece.typePieceLabel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePieceTypeSelect = (piece: any) => {
|
|
||||||
if (!piece) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
piece.typePieceId = ''
|
|
||||||
piece.typePieceLabel = ''
|
|
||||||
piece.name = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = pieceTypeMap.value.get(id)
|
|
||||||
if (!option) {
|
|
||||||
piece.typePieceId = ''
|
|
||||||
piece.typePieceLabel = ''
|
|
||||||
piece.name = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
|
||||||
piece.name = option.name || piece.typePieceLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyPieceLabels = (pieces?: any[]) => {
|
|
||||||
if (!Array.isArray(pieces)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pieces.forEach((piece) => {
|
|
||||||
if (piece?.typePieceId) {
|
|
||||||
updatePieceTypeLabel(piece)
|
|
||||||
} else if (piece?.typePieceLabel) {
|
|
||||||
const match = resolvePieceType(piece.typePieceLabel)
|
|
||||||
if (match) {
|
|
||||||
piece.typePieceId = match.id
|
|
||||||
piece.typePieceLabel = formatPieceTypeOption(match)
|
|
||||||
piece.name = match.name || formatPieceTypeOption(match)
|
|
||||||
} else if (!piece.name) {
|
|
||||||
piece.name = piece.typePieceLabel
|
|
||||||
}
|
|
||||||
} else if (!piece?.name) {
|
|
||||||
piece.name = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyComponentTypeLabel = (component: any) => {
|
|
||||||
if (!component) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (component.typeComposantId) {
|
|
||||||
const option = componentTypeMap.value.get(component.typeComposantId)
|
|
||||||
if (option) {
|
|
||||||
component.typeComposantLabel = formatComponentTypeOption(option)
|
|
||||||
component.name = option.name || formatComponentTypeOption(option)
|
|
||||||
} else if (!component.typeComposantLabel) {
|
|
||||||
component.name = ''
|
|
||||||
}
|
|
||||||
} else if (component.typeComposantLabel) {
|
|
||||||
const match = resolveComponentType(component.typeComposantLabel)
|
|
||||||
if (match) {
|
|
||||||
component.typeComposantId = match.id
|
|
||||||
component.typeComposantLabel = formatComponentTypeOption(match)
|
|
||||||
component.name = match.name || formatComponentTypeOption(match)
|
|
||||||
} else {
|
|
||||||
component.typeComposantLabel = ''
|
|
||||||
component.name = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const traverseSubComponents = (components?: any[]) => {
|
|
||||||
if (!Array.isArray(components)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
components.forEach((component) => {
|
|
||||||
applyComponentTypeLabel(component)
|
|
||||||
applyPieceLabels(component?.pieces)
|
|
||||||
traverseSubComponents(component?.subComponents)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncAllTypeLabels = () => {
|
|
||||||
applyPieceLabels(localStructure.pieces)
|
|
||||||
traverseSubComponents(localStructure.subComponents)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const loaders: Promise<any>[] = []
|
const loaders: Promise<any>[] = []
|
||||||
@@ -390,74 +80,7 @@ onMounted(async () => {
|
|||||||
loaders.push(loadComponentTypes())
|
loaders.push(loadComponentTypes())
|
||||||
}
|
}
|
||||||
if (loaders.length) {
|
if (loaders.length) {
|
||||||
await Promise.all(loaders)
|
await Promise.allSettled(loaders)
|
||||||
}
|
}
|
||||||
syncAllTypeLabels()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => availablePieceTypes.value,
|
|
||||||
() => {
|
|
||||||
syncAllTypeLabels()
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => availableComponentTypes.value,
|
|
||||||
() => {
|
|
||||||
syncAllTypeLabels()
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const ensureArray = (key: 'customFields' | 'pieces' | 'subComponents') => {
|
|
||||||
if (!Array.isArray(localStructure[key])) {
|
|
||||||
localStructure[key] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addCustomField = () => {
|
|
||||||
ensureArray('customFields')
|
|
||||||
localStructure.customFields.push({
|
|
||||||
name: '',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
optionsText: '',
|
|
||||||
options: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeCustomField = (index: number) => {
|
|
||||||
if (!Array.isArray(localStructure.customFields)) return
|
|
||||||
localStructure.customFields.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addPiece = () => {
|
|
||||||
ensureArray('pieces')
|
|
||||||
localStructure.pieces.push({
|
|
||||||
name: '',
|
|
||||||
typePieceId: '',
|
|
||||||
typePieceLabel: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removePiece = (index: number) => {
|
|
||||||
if (!Array.isArray(localStructure.pieces)) return
|
|
||||||
localStructure.pieces.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSubComponent = () => {
|
|
||||||
ensureArray('subComponents')
|
|
||||||
localStructure.subComponents.push({
|
|
||||||
name: '',
|
|
||||||
typeComposantId: '',
|
|
||||||
typeComposantLabel: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSubComponent = (index: number) => {
|
|
||||||
if (!Array.isArray(localStructure.subComponents)) return
|
|
||||||
localStructure.subComponents.splice(index, 1)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
433
app/components/StructureNodeEditor.vue
Normal file
433
app/components/StructureNodeEditor.vue
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="containerClass">
|
||||||
|
<div class="border border-base-200 rounded-lg bg-base-100 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-base-200 px-4 py-3">
|
||||||
|
<div class="flex-1 min-w-[220px] space-y-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-semibold">
|
||||||
|
{{ isRoot ? 'Famille de composant racine' : 'Famille de composant' }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="node.typeComposantId"
|
||||||
|
class="select select-bordered select-sm w-full"
|
||||||
|
@change="handleComponentTypeSelect(node)"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Sélectionner une famille de composant
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="type in componentTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ formatComponentTypeOption(type) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-[11px] text-gray-500">
|
||||||
|
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!isRoot"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs btn-square"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-4 space-y-5">
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h4 :class="headingClass">
|
||||||
|
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
||||||
|
</h4>
|
||||||
|
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
|
||||||
|
Aucun champ n'a encore été défini.
|
||||||
|
</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(field, index) in node.customFields"
|
||||||
|
:key="`field-${index}`"
|
||||||
|
class="border border-base-200 rounded-md p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<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-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 Option 2"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs btn-square"
|
||||||
|
@click="removeCustomField(index)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h4 :class="headingClass">
|
||||||
|
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||||
|
</h4>
|
||||||
|
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">
|
||||||
|
Aucune pièce définie.
|
||||||
|
</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(piece, index) in node.pieces"
|
||||||
|
:key="`piece-${index}`"
|
||||||
|
class="border border-base-200 rounded-md p-3 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Famille de pièce</span></label>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
v-model="piece.typePieceId"
|
||||||
|
class="select select-bordered select-xs"
|
||||||
|
@change="handlePieceTypeSelect(piece)"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Sélectionner une famille
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="type in pieceTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ formatPieceTypeOption(type) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-[11px] text-gray-500">
|
||||||
|
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h4 :class="headingClass">Sous-composants</h4>
|
||||||
|
<button type="button" class="btn btn-outline btn-xs" @click="addSubComponent">
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!(node.subComponents?.length)" class="text-xs text-gray-500">
|
||||||
|
Aucun sous-composant défini.
|
||||||
|
</p>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<StructureNodeEditor
|
||||||
|
v-for="(subComponent, index) in node.subComponents"
|
||||||
|
:key="`sub-${index}`"
|
||||||
|
:node="subComponent"
|
||||||
|
:depth="depth + 1"
|
||||||
|
:component-types="componentTypes"
|
||||||
|
:piece-types="pieceTypes"
|
||||||
|
@remove="removeSubComponent(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
|
|
||||||
|
defineOptions({ name: 'StructureNodeEditor' })
|
||||||
|
|
||||||
|
type ModelTypeOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
code?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
node: Record<string, any>
|
||||||
|
depth?: number
|
||||||
|
componentTypes?: ModelTypeOption[]
|
||||||
|
pieceTypes?: ModelTypeOption[]
|
||||||
|
isRoot?: boolean
|
||||||
|
}>(), {
|
||||||
|
depth: 0,
|
||||||
|
componentTypes: () => [],
|
||||||
|
pieceTypes: () => [],
|
||||||
|
isRoot: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['remove'])
|
||||||
|
|
||||||
|
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||||
|
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||||
|
|
||||||
|
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
|
||||||
|
const containerClass = computed(() => {
|
||||||
|
const level = Math.max(0, props.depth ?? 0)
|
||||||
|
const index = Math.min(level, depthClasses.length - 1)
|
||||||
|
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
|
||||||
|
})
|
||||||
|
|
||||||
|
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
|
||||||
|
|
||||||
|
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) => {
|
||||||
|
if (!type) return ''
|
||||||
|
return type.code ? `${type.name} (${type.code})` : type.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTypeMap = computed(() => {
|
||||||
|
const map = new Map<string, ModelTypeOption>()
|
||||||
|
componentTypes.value.forEach((type) => {
|
||||||
|
if (type && typeof type.id === 'string') {
|
||||||
|
map.set(type.id, type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieceTypeMap = computed(() => {
|
||||||
|
const map = new Map<string, ModelTypeOption>()
|
||||||
|
pieceTypes.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))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPieceTypeLabel = (id?: string) => {
|
||||||
|
if (!id) return ''
|
||||||
|
return formatModelTypeOption(pieceTypeMap.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') => {
|
||||||
|
if (!Array.isArray(props.node[key])) {
|
||||||
|
props.node[key] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncComponentType = (component: any) => {
|
||||||
|
if (!component) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = typeof component.typeComposantId === 'string'
|
||||||
|
? component.typeComposantId
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
component.typeComposantLabel = ''
|
||||||
|
component.name = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = componentTypeMap.value.get(id)
|
||||||
|
if (!option) {
|
||||||
|
component.typeComposantLabel = ''
|
||||||
|
component.name = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
component.typeComposantLabel = formatModelTypeOption(option)
|
||||||
|
component.name = option.name || component.typeComposantLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePieceTypeLabel = (piece: any) => {
|
||||||
|
if (!piece) return
|
||||||
|
|
||||||
|
if (piece.typePieceId) {
|
||||||
|
const option = pieceTypeMap.value.get(piece.typePieceId)
|
||||||
|
if (option) {
|
||||||
|
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||||
|
piece.name = option.name || formatPieceTypeOption(option)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (piece.typePieceLabel) {
|
||||||
|
const normalized = piece.typePieceLabel.trim().toLowerCase()
|
||||||
|
if (normalized) {
|
||||||
|
const match = pieceTypes.value.find((type) => {
|
||||||
|
const formatted = formatPieceTypeOption(type).toLowerCase()
|
||||||
|
const name = (type?.name ?? '').toLowerCase()
|
||||||
|
const code = (type?.code ?? '').toLowerCase()
|
||||||
|
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||||
|
})
|
||||||
|
if (match) {
|
||||||
|
piece.typePieceId = match.id
|
||||||
|
piece.typePieceLabel = formatPieceTypeOption(match)
|
||||||
|
piece.name = match.name || formatPieceTypeOption(match)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!piece.name) {
|
||||||
|
piece.name = piece.typePieceLabel || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncPieceLabels = (pieces?: any[]) => {
|
||||||
|
if (!Array.isArray(pieces)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pieces.forEach((piece) => {
|
||||||
|
updatePieceTypeLabel(piece)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComponentTypeSelect = (component: any) => {
|
||||||
|
syncComponentType(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePieceTypeSelect = (piece: any) => {
|
||||||
|
if (!piece) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
|
||||||
|
if (!id) {
|
||||||
|
piece.typePieceLabel = ''
|
||||||
|
piece.name = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const option = pieceTypeMap.value.get(id)
|
||||||
|
if (!option) {
|
||||||
|
piece.typePieceId = ''
|
||||||
|
piece.typePieceLabel = ''
|
||||||
|
piece.name = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||||
|
piece.name = option.name || piece.typePieceLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomField = () => {
|
||||||
|
ensureArray('customFields')
|
||||||
|
props.node.customFields.push({
|
||||||
|
name: '',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
optionsText: '',
|
||||||
|
options: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomField = (index: number) => {
|
||||||
|
if (!Array.isArray(props.node.customFields)) return
|
||||||
|
props.node.customFields.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPiece = () => {
|
||||||
|
ensureArray('pieces')
|
||||||
|
props.node.pieces.push({
|
||||||
|
name: '',
|
||||||
|
typePieceId: '',
|
||||||
|
typePieceLabel: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePiece = (index: number) => {
|
||||||
|
if (!Array.isArray(props.node.pieces)) return
|
||||||
|
props.node.pieces.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSubComponent = () => {
|
||||||
|
ensureArray('subComponents')
|
||||||
|
props.node.subComponents.push({
|
||||||
|
name: '',
|
||||||
|
typeComposantId: '',
|
||||||
|
typeComposantLabel: '',
|
||||||
|
customFields: [],
|
||||||
|
pieces: [],
|
||||||
|
subComponents: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSubComponent = (index: number) => {
|
||||||
|
if (!Array.isArray(props.node.subComponents)) return
|
||||||
|
props.node.subComponents.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(componentTypes, () => {
|
||||||
|
syncComponentType(props.node)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node.typeComposantId,
|
||||||
|
() => {
|
||||||
|
syncComponentType(props.node)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(pieceTypes, () => {
|
||||||
|
syncPieceLabels(props.node?.pieces)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node.pieces,
|
||||||
|
(value) => {
|
||||||
|
syncPieceLabels(value)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="border border-base-200 rounded-lg bg-base-100" :class="depthPadding">
|
|
||||||
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<select
|
|
||||||
v-model="node.typeComposantId"
|
|
||||||
class="select select-bordered select-sm w-full"
|
|
||||||
@change="handleComponentTypeSelect(node)"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
Sélectionner une famille de composant
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="type in componentTypes"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ formatComponentTypeOption(type) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p class="mt-1 text-[11px] text-gray-500">
|
|
||||||
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="emit('remove')">
|
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, watch } from 'vue'
|
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
|
||||||
|
|
||||||
defineOptions({ name: 'StructureSubComponentEditor' })
|
|
||||||
|
|
||||||
type ModelTypeOption = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
code?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
node: Record<string, any>
|
|
||||||
depth?: number
|
|
||||||
componentTypes?: ModelTypeOption[]
|
|
||||||
}>(), {
|
|
||||||
depth: 0,
|
|
||||||
componentTypes: () => [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['remove'])
|
|
||||||
|
|
||||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
|
||||||
|
|
||||||
const depthPadding = computed(() => {
|
|
||||||
const level = props.depth ?? 0
|
|
||||||
return level > 0 ? `ml-${Math.min(level * 4, 12)}` : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) => {
|
|
||||||
if (!type) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return type.code ? `${type.name} (${type.code})` : type.name
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
|
|
||||||
formatModelTypeOption(type)
|
|
||||||
|
|
||||||
const componentTypeMap = computed(() => {
|
|
||||||
const map = new Map<string, ModelTypeOption>()
|
|
||||||
componentTypes.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))
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncComponentType = (component: any) => {
|
|
||||||
if (!component) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const id = typeof component.typeComposantId === 'string'
|
|
||||||
? component.typeComposantId
|
|
||||||
: ''
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
component.typeComposantLabel = ''
|
|
||||||
component.name = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = componentTypeMap.value.get(id)
|
|
||||||
if (!option) {
|
|
||||||
component.typeComposantLabel = ''
|
|
||||||
component.name = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
component.typeComposantLabel = formatModelTypeOption(option)
|
|
||||||
component.name = option.name || component.typeComposantLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleComponentTypeSelect = (component: any) => {
|
|
||||||
syncComponentType(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(componentTypes, () => {
|
|
||||||
syncComponentType(props.node)
|
|
||||||
}, { deep: true, immediate: true })
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.node.typeComposantId,
|
|
||||||
() => {
|
|
||||||
syncComponentType(props.node)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
@@ -54,7 +54,13 @@ export function useCustomFields () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Créer ou mettre à jour une valeur de champ personnalisé
|
// Créer ou mettre à jour une valeur de champ personnalisé
|
||||||
const upsertCustomFieldValue = async (customFieldId, entityType, entityId, value) => {
|
const upsertCustomFieldValue = async (
|
||||||
|
customFieldId,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
value,
|
||||||
|
metadata = {},
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const result = await apiCall('/custom-fields/values/upsert', {
|
const result = await apiCall('/custom-fields/values/upsert', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -62,7 +68,8 @@ export function useCustomFields () {
|
|||||||
customFieldId,
|
customFieldId,
|
||||||
entityType,
|
entityType,
|
||||||
entityId,
|
entityId,
|
||||||
value
|
value,
|
||||||
|
...metadata
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user