397 lines
12 KiB
Vue
397 lines
12 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<section class="space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-semibold">Champs personnalisés du composant</h3>
|
||
<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="!(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="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||
<input
|
||
v-model="piece.name"
|
||
type="text"
|
||
class="input input-bordered input-xs"
|
||
placeholder="Nom de la pièce"
|
||
/>
|
||
<input
|
||
v-model="piece.reference"
|
||
type="text"
|
||
class="input input-bordered input-xs"
|
||
placeholder="Référence"
|
||
/>
|
||
<input
|
||
v-model.number="piece.quantity"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="input input-bordered input-xs"
|
||
placeholder="Quantité"
|
||
/>
|
||
</div>
|
||
<div class="form-control">
|
||
<label class="label"><span class="label-text">Famille de pièce</span></label>
|
||
<div>
|
||
<input
|
||
:list="`component-piece-type-options-${index}`"
|
||
v-model="piece.typePieceLabel"
|
||
type="search"
|
||
autocomplete="off"
|
||
class="input input-bordered input-xs"
|
||
placeholder="Rechercher une famille"
|
||
@change="handlePieceTypeChange(piece)"
|
||
@blur="handlePieceTypeChange(piece)"
|
||
/>
|
||
<datalist :id="`component-piece-type-options-${index}`">
|
||
<option
|
||
v-for="type in availablePieceTypes"
|
||
:key="type.id"
|
||
:value="formatPieceTypeOption(type)"
|
||
/>
|
||
</datalist>
|
||
</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"
|
||
:piece-types="availablePieceTypes"
|
||
@remove="removeSubComponent(index)"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive, watch, computed, onMounted } from 'vue'
|
||
import IconLucidePlus from '~icons/lucide/plus'
|
||
import IconLucideTrash from '~icons/lucide/trash'
|
||
import StructureSubComponentEditor from '~/components/StructureSubComponentEditor.vue'
|
||
import {
|
||
defaultStructure,
|
||
hydrateStructureForEditor,
|
||
normalizeStructureForSave,
|
||
} from '~/shared/modelUtils'
|
||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||
|
||
defineOptions({ name: 'ComponentModelStructureEditor' })
|
||
|
||
const props = defineProps({
|
||
modelValue: {
|
||
type: Object,
|
||
default: () => defaultStructure(),
|
||
},
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue'])
|
||
|
||
const localStructure = reactive(hydrateStructureForEditor(props.modelValue))
|
||
|
||
const syncFromProps = (value: any) => {
|
||
const hydrated = hydrateStructureForEditor(value)
|
||
localStructure.customFields = hydrated.customFields
|
||
localStructure.pieces = hydrated.pieces
|
||
localStructure.subComponents = hydrated.subComponents
|
||
lastEmitted = JSON.stringify(normalizeStructureForSave(value))
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(value) => {
|
||
syncFromProps(value)
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
let lastEmitted = JSON.stringify(normalizeStructureForSave(props.modelValue))
|
||
|
||
watch(
|
||
localStructure,
|
||
(value) => {
|
||
const normalized = normalizeStructureForSave(value)
|
||
const serialized = JSON.stringify(normalized)
|
||
if (serialized !== lastEmitted) {
|
||
lastEmitted = serialized
|
||
emit('update:modelValue', normalized)
|
||
}
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
type PieceTypeOption = {
|
||
id: string
|
||
name: string
|
||
code?: string | null
|
||
}
|
||
|
||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||
|
||
const availablePieceTypes = computed<PieceTypeOption[]>(() => pieceTypes.value ?? [])
|
||
|
||
const pieceTypeMap = computed(() => {
|
||
const map = new Map<string, PieceTypeOption>()
|
||
availablePieceTypes.value.forEach((type) => {
|
||
if (type && typeof type.id === 'string') {
|
||
map.set(type.id, type)
|
||
}
|
||
})
|
||
return map
|
||
})
|
||
|
||
const formatPieceTypeOption = (type: PieceTypeOption | undefined | null) => {
|
||
if (!type) return ''
|
||
return type.code ? `${type.name} (${type.code})` : type.name
|
||
}
|
||
|
||
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 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)
|
||
piece.typePieceLabel = option ? formatPieceTypeOption(option) : piece.typePieceLabel || ''
|
||
} else if (piece.typePieceLabel) {
|
||
const match = resolvePieceType(piece.typePieceLabel)
|
||
if (match) {
|
||
piece.typePieceId = match.id
|
||
piece.typePieceLabel = formatPieceTypeOption(match)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handlePieceTypeChange = (piece: any) => {
|
||
if (!piece) {
|
||
return
|
||
}
|
||
const value = typeof piece.typePieceLabel === 'string' ? piece.typePieceLabel.trim() : ''
|
||
if (!value) {
|
||
piece.typePieceId = ''
|
||
piece.typePieceLabel = ''
|
||
return
|
||
}
|
||
const match = resolvePieceType(value)
|
||
if (match) {
|
||
piece.typePieceId = match.id
|
||
piece.typePieceLabel = formatPieceTypeOption(match)
|
||
} else {
|
||
piece.typePieceId = ''
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const traverseSubComponents = (components?: any[]) => {
|
||
if (!Array.isArray(components)) {
|
||
return
|
||
}
|
||
components.forEach((component) => {
|
||
applyPieceLabels(component?.pieces)
|
||
traverseSubComponents(component?.subComponents)
|
||
})
|
||
}
|
||
|
||
const syncAllPieceTypeLabels = () => {
|
||
applyPieceLabels(localStructure.pieces)
|
||
traverseSubComponents(localStructure.subComponents)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (!availablePieceTypes.value.length) {
|
||
await loadPieceTypes()
|
||
}
|
||
syncAllPieceTypeLabels()
|
||
})
|
||
|
||
watch(
|
||
() => availablePieceTypes.value,
|
||
() => {
|
||
syncAllPieceTypeLabels()
|
||
},
|
||
{ 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: '',
|
||
reference: '',
|
||
quantity: undefined,
|
||
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: '',
|
||
description: '',
|
||
quantity: undefined,
|
||
customFields: [],
|
||
pieces: [],
|
||
subComponents: [],
|
||
})
|
||
}
|
||
|
||
const removeSubComponent = (index: number) => {
|
||
if (!Array.isArray(localStructure.subComponents)) return
|
||
localStructure.subComponents.splice(index, 1)
|
||
}
|
||
</script>
|