feat: reorganize machine skeleton pages
This commit is contained in:
@@ -1,213 +1,36 @@
|
||||
<template>
|
||||
<div class="border border-base-200 rounded-lg bg-base-100" :class="depthPadding">
|
||||
<div class="flex items-start justify-between gap-3 border-b border-base-200 px-4 py-3">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="toggle">
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'rotate-90': expanded }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<input
|
||||
:list="componentTypeListId"
|
||||
v-model="node.typeComposantLabel"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="input input-sm input-bordered w-full"
|
||||
placeholder="Sélectionner une famille de composant"
|
||||
@change="handleComponentTypeChange(node)"
|
||||
@blur="handleComponentTypeChange(node)"
|
||||
/>
|
||||
<datalist :id="componentTypeListId">
|
||||
<option
|
||||
v-for="type in componentTypes"
|
||||
:key="type.id"
|
||||
:value="formatComponentTypeOption(type)"
|
||||
/>
|
||||
</datalist>
|
||||
<p class="mt-1 text-[11px] text-gray-500">
|
||||
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!expanded && node.description" class="text-xs text-gray-500 truncate">
|
||||
{{ node.description }}
|
||||
</span>
|
||||
<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 v-if="expanded" class="space-y-5 px-4 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Description</span></label>
|
||||
<textarea
|
||||
v-model="node.description"
|
||||
class="textarea textarea-bordered textarea-sm"
|
||||
rows="2"
|
||||
placeholder="Notes optionnelles"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Quantité</span></label>
|
||||
<input
|
||||
v-model.number="node.quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Quantité (optionnel)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-sm font-semibold">Champs personnalisés</h4>
|
||||
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
|
||||
Aucun champ défini.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(field, fieldIndex) in node.customFields"
|
||||
:key="`sub-${depth}-${fieldIndex}`"
|
||||
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(fieldIndex)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-sm font-semibold">Pièces associées</h4>
|
||||
<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, pieceIndex) in node.pieces"
|
||||
:key="`piece-${depth}-${pieceIndex}`"
|
||||
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-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Famille de pièce</span></label>
|
||||
<div>
|
||||
<input
|
||||
:list="getPieceTypeListId(pieceIndex)"
|
||||
v-model="piece.typePieceLabel"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Sélectionner une famille"
|
||||
@change="handlePieceTypeChange(piece)"
|
||||
@blur="handlePieceTypeChange(piece)"
|
||||
/>
|
||||
<datalist :id="getPieceTypeListId(pieceIndex)">
|
||||
<option
|
||||
v-for="type in pieceTypes"
|
||||
: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 class="form-control">
|
||||
<label class="label"><span class="label-text">Quantité (optionnel)</span></label>
|
||||
<input
|
||||
v-model.number="piece.quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Quantité"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(pieceIndex)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter une pièce
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">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">
|
||||
<StructureSubComponentEditor
|
||||
v-for="(sub, index) in node.subComponents"
|
||||
:key="`sub-${depth}-${index}`"
|
||||
:node="sub"
|
||||
:depth="depth + 1"
|
||||
:piece-types="pieceTypes"
|
||||
:component-types="componentTypes"
|
||||
@remove="removeSubComponent(index)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, getCurrentInstance } from 'vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import { computed, watch } from 'vue'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
|
||||
defineOptions({ name: 'StructureSubComponentEditor' })
|
||||
@@ -221,36 +44,30 @@ type ModelTypeOption = {
|
||||
const props = withDefaults(defineProps<{
|
||||
node: Record<string, any>
|
||||
depth?: number
|
||||
pieceTypes?: ModelTypeOption[]
|
||||
componentTypes?: ModelTypeOption[]
|
||||
}>(), {
|
||||
depth: 0,
|
||||
pieceTypes: () => [],
|
||||
componentTypes: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const componentTypeListId = `sub-component-type-options-${instance?.uid ?? 0}`
|
||||
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 ''
|
||||
if (!type) {
|
||||
return ''
|
||||
}
|
||||
return type.code ? `${type.name} (${type.code})` : type.name
|
||||
}
|
||||
|
||||
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 formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const componentTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
@@ -262,275 +79,51 @@ const componentTypeMap = computed(() => {
|
||||
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 (
|
||||
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)
|
||||
)
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
const resolveComponentType = (input: string) => {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
componentTypes.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 getComponentTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
const option = componentTypeMap.value.get(id)
|
||||
return formatComponentTypeOption(option)
|
||||
return formatModelTypeOption(componentTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
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 {
|
||||
piece.typePieceLabel = ''
|
||||
piece.name = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePieceTypeChange = (piece: any) => {
|
||||
if (!piece) {
|
||||
return
|
||||
}
|
||||
const value = typeof piece.typePieceLabel === 'string' ? piece.typePieceLabel.trim() : ''
|
||||
if (!value) {
|
||||
piece.typePieceId = ''
|
||||
piece.typePieceLabel = ''
|
||||
piece.name = ''
|
||||
return
|
||||
}
|
||||
const match = resolvePieceType(value)
|
||||
if (match) {
|
||||
piece.typePieceId = match.id
|
||||
piece.typePieceLabel = formatPieceTypeOption(match)
|
||||
piece.name = match.name || formatPieceTypeOption(match)
|
||||
} else {
|
||||
piece.typePieceId = ''
|
||||
piece.typePieceLabel = ''
|
||||
piece.name = ''
|
||||
}
|
||||
}
|
||||
|
||||
const getPieceTypeListId = (pieceIndex: number) => `sub-piece-type-options-${props.depth ?? 0}-${pieceIndex}`
|
||||
|
||||
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 {
|
||||
piece.typePieceLabel = ''
|
||||
piece.name = ''
|
||||
}
|
||||
} else if (!piece?.name) {
|
||||
piece.name = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const applyComponentTypeLabel = (component: any) => {
|
||||
const syncComponentType = (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 handleComponentTypeChange = (component: any) => {
|
||||
if (!component) {
|
||||
return
|
||||
}
|
||||
const value = typeof component.typeComposantLabel === 'string'
|
||||
? component.typeComposantLabel.trim()
|
||||
const id = typeof component.typeComposantId === 'string'
|
||||
? component.typeComposantId
|
||||
: ''
|
||||
if (!value) {
|
||||
component.typeComposantId = ''
|
||||
|
||||
if (!id) {
|
||||
component.typeComposantLabel = ''
|
||||
component.name = ''
|
||||
return
|
||||
}
|
||||
const match = resolveComponentType(value)
|
||||
if (match) {
|
||||
component.typeComposantId = match.id
|
||||
component.typeComposantLabel = formatComponentTypeOption(match)
|
||||
component.name = match.name || formatComponentTypeOption(match)
|
||||
} else {
|
||||
component.typeComposantId = ''
|
||||
|
||||
const option = componentTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
component.typeComposantLabel = ''
|
||||
component.name = ''
|
||||
}
|
||||
}
|
||||
|
||||
const traverseSubComponents = (components?: any[]) => {
|
||||
if (!Array.isArray(components)) {
|
||||
return
|
||||
}
|
||||
components.forEach((component) => {
|
||||
applyComponentTypeLabel(component)
|
||||
applyPieceLabels(component?.pieces)
|
||||
traverseSubComponents(component?.subComponents)
|
||||
})
|
||||
|
||||
component.typeComposantLabel = formatModelTypeOption(option)
|
||||
component.name = option.name || component.typeComposantLabel
|
||||
}
|
||||
|
||||
const syncTypeLabels = () => {
|
||||
applyComponentTypeLabel(props.node)
|
||||
applyPieceLabels(props.node?.pieces)
|
||||
traverseSubComponents(props.node?.subComponents)
|
||||
const handleComponentTypeSelect = (component: any) => {
|
||||
syncComponentType(component)
|
||||
}
|
||||
|
||||
watch(pieceTypes, () => {
|
||||
syncTypeLabels()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(componentTypes, () => {
|
||||
syncTypeLabels()
|
||||
syncComponentType(props.node)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
() => props.node.typeComposantId,
|
||||
() => {
|
||||
syncTypeLabels()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const expanded = ref(true)
|
||||
const depthPadding = computed(() => (props.depth > 0 ? 'ml-4' : ''))
|
||||
|
||||
const toggle = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const ensureArray = (key: 'customFields' | 'pieces' | 'subComponents') => {
|
||||
if (!Array.isArray(props.node[key])) {
|
||||
props.node[key] = []
|
||||
syncComponentType(props.node)
|
||||
}
|
||||
}
|
||||
|
||||
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: '',
|
||||
quantity: undefined,
|
||||
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: '',
|
||||
description: '',
|
||||
quantity: undefined,
|
||||
typeComposantId: '',
|
||||
typeComposantLabel: '',
|
||||
customFields: [],
|
||||
pieces: [],
|
||||
subComponents: [],
|
||||
})
|
||||
}
|
||||
|
||||
const removeSubComponent = (index: number) => {
|
||||
if (!Array.isArray(props.node.subComponents)) return
|
||||
props.node.subComponents.splice(index, 1)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user