Files
Inventory_frontend/app/components/StructureSubComponentEditor.vue
2025-09-25 11:25:43 +02:00

411 lines
13 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="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>
<input
v-model="node.name"
type="text"
class="input input-sm input-bordered w-full"
placeholder="Nom du sous-composant"
/>
</div>
<span v-if="!expanded && node.description" class="text-xs text-gray-500 truncate">
{{ node.description }}
</span>
</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&#10;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-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="getPieceTypeListId(pieceIndex)"
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="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>
<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"
@remove="removeSubComponent(index)"
/>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
defineOptions({ name: 'StructureSubComponentEditor' })
type PieceTypeOption = {
id: string
name: string
code?: string | null
}
const props = withDefaults(defineProps<{
node: Record<string, any>
depth?: number
pieceTypes?: PieceTypeOption[]
}>(), {
depth: 0,
pieceTypes: () => [],
})
const emit = defineEmits(['remove'])
const pieceTypeMap = computed(() => {
const map = new Map<string, PieceTypeOption>()
;(props.pieceTypes ?? []).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 (
(props.pieceTypes ?? []).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 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)
}
}
})
}
const traverseSubComponents = (components?: any[]) => {
if (!Array.isArray(components)) {
return
}
components.forEach((component) => {
applyPieceLabels(component?.pieces)
traverseSubComponents(component?.subComponents)
})
}
const syncPieceTypeLabels = () => {
applyPieceLabels(props.node?.pieces)
traverseSubComponents(props.node?.subComponents)
}
watch(
() => props.pieceTypes,
() => {
syncPieceTypeLabels()
},
{ deep: true, immediate: true }
)
watch(
() => props.node,
() => {
syncPieceTypeLabels()
},
{ 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] = []
}
}
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: '',
reference: '',
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,
customFields: [],
pieces: [],
subComponents: [],
})
}
const removeSubComponent = (index: number) => {
if (!Array.isArray(props.node.subComponents)) return
props.node.subComponents.splice(index, 1)
}
</script>