Files
Inventory/app/components/ComponentModelStructureEditor.vue
2025-09-29 15:05:54 +02:00

464 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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&#10;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>
</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,
cloneStructure,
} from '~/shared/modelUtils'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useComponentTypes } from '~/composables/useComponentTypes'
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(cloneStructure(value))
}
watch(
() => props.modelValue,
(value) => {
syncFromProps(value)
},
{ deep: true }
)
let lastEmitted = JSON.stringify(cloneStructure(props.modelValue))
watch(
localStructure,
(value) => {
const payload = cloneStructure(value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
},
{ 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 { componentTypes, loadComponentTypes } = useComponentTypes()
const availablePieceTypes = computed<ModelTypeOption[]>(() => pieceTypes.value ?? [])
const availableComponentTypes = computed<ModelTypeOption[]>(() => 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 () => {
const loaders: Promise<any>[] = []
if (!availablePieceTypes.value.length) {
loaders.push(loadPieceTypes())
}
if (!availableComponentTypes.value.length) {
loaders.push(loadComponentTypes())
}
if (loaders.length) {
await Promise.all(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>