set up new view for skeleton hiearchi

This commit is contained in:
Matthieu
2025-09-22 08:34:05 +02:00
parent e33e91ee26
commit 936a9d74ca
30 changed files with 4530 additions and 2288 deletions

View File

@@ -7,8 +7,15 @@
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
:component-model-options="componentModelOptionsProvider(component)"
:component-model-options-provider="componentModelOptionsProvider"
:piece-model-options-provider="pieceModelOptionsProvider"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@assign-model="$emit('assign-model', $event)"
@assign-piece-model="$emit('assign-piece-model', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@create-model-from-component="$emit('create-model-from-component', $event)"
/>
</div>
</div>
@@ -33,8 +40,16 @@ defineProps({
toggleToken: {
type: Number,
default: 0
}
},
componentModelOptionsProvider: {
type: Function,
default: () => [],
},
pieceModelOptionsProvider: {
type: Function,
default: () => [],
},
})
defineEmits(['update', 'edit-piece'])
defineEmits(['update', 'edit-piece', 'assign-model', 'assign-piece-model', 'custom-field-update', 'create-model-from-component'])
</script>

View File

@@ -27,6 +27,18 @@
<span v-if="component.constructeur" class="badge badge-outline badge-sm">{{ component.constructeur?.name }}</span>
<span v-if="component.emplacement" class="badge badge-outline badge-sm">{{ component.emplacement }}</span>
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span>
<span
v-if="component.typeMachineComponentRequirement"
class="badge badge-outline badge-sm"
>
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
</span>
<span
v-if="component.composantModel"
class="badge badge-outline badge-sm badge-primary"
>
Modèle : {{ component.composantModel.name }}
</span>
</div>
</div>
</div>
@@ -99,6 +111,44 @@
</div>
</div>
</div>
<div
v-if="isEditMode && component.typeMachineComponentRequirement"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Modèle de composant</span>
<span class="label-text-alt text-xs">
{{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Famille' }}
</span>
</label>
<div class="flex flex-col md:flex-row gap-2 items-start md:items-center">
<select
:value="selectedComponentModelId"
class="select select-bordered select-sm"
@change="assignComponentModel($event.target.value)"
>
<option value="">Définir manuellement</option>
<option
v-for="model in componentModelOptionsList"
:key="model.id"
:value="model.id"
>
{{ model.name }}
</option>
</select>
<button
v-if="isEditMode && component.typeMachineComponentRequirement?.typeComposantId"
type="button"
class="btn btn-ghost btn-xs"
@click="emit('create-model-from-component', component)"
>
Sauvegarder comme modèle
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields Display - Editable or Read-only -->
@@ -243,9 +293,11 @@
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
:piece-model-options="pieceModelOptionsProvider(piece)"
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@assign-model="emitAssignPieceModel"
/>
</div>
</div>
@@ -261,8 +313,13 @@
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
:component-model-options="componentModelOptionsProvider(subComponent)"
:component-model-options-provider="componentModelOptionsProvider"
:piece-model-options-provider="pieceModelOptionsProvider"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@assign-model="$emit('assign-model', $event)"
@assign-piece-model="$emit('assign-piece-model', $event)"
/>
</div>
</div>
@@ -284,23 +341,42 @@ import IconLucideChevronRight from '~icons/lucide/chevron-right'
const props = defineProps({
component: {
type: Object,
required: true
required: true,
},
isEditMode: {
type: Boolean,
default: false
default: false,
},
collapseAll: {
type: Boolean,
default: true
default: true,
},
toggleToken: {
type: Number,
default: 0
}
default: 0,
},
componentModelOptions: {
type: Array,
default: () => [],
},
componentModelOptionsProvider: {
type: Function,
default: () => [],
},
pieceModelOptionsProvider: {
type: Function,
default: () => [],
},
})
const emit = defineEmits(['update', 'edit-piece'])
const emit = defineEmits([
'update',
'edit-piece',
'custom-field-update',
'assign-model',
'assign-piece-model',
'create-model-from-component',
])
const isCollapsed = ref(true)
const selectedFiles = ref([])
@@ -312,6 +388,13 @@ const documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime
const previewDocument = ref(null)
const previewVisible = ref(false)
const selectedComponentModelId = computed(() => props.component.composantModelId || props.component.composantModel?.id || '')
const componentModelOptionsList = computed(() => {
const provided = props.componentModelOptionsProvider(props.component)
return Array.isArray(provided) && provided.length ? provided : props.componentModelOptions
})
const pieceModelOptionsList = computed(() => props.pieceModelOptionsProvider(props.component) || [])
const handleConstructeurChange = async (value) => {
props.component.constructeurId = value
await updateComponent()
@@ -327,15 +410,14 @@ watch(
ensureDocumentsLoaded()
}
},
{ immediate: true }
{ immediate: true },
)
watch(
() => props.component.documents,
(docs) => {
documentsLoaded.value = !!(docs && docs.length)
}
},
)
const toggleCollapse = () => {
@@ -345,13 +427,11 @@ const toggleCollapse = () => {
}
}
// Methods
const updateComponent = () => {
emit('update', props.component)
}
const updateComponentCustomField = (field) => {
// Mettre à jour le champ personnalisé du composant
const updateComponentCustomField = () => {
emit('update', props.component)
}
@@ -364,10 +444,29 @@ const editPiece = (piece) => {
}
const updatePieceCustomField = (fieldUpdate) => {
// Forward to parent
emit('custom-field-update', fieldUpdate)
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
}
const assignComponentModel = (value) => {
const previousModelId = props.component.composantModelId || props.component.composantModel?.id || null
const previousModel = props.component.composantModel || null
props.component.composantModelId = value || null
if (!value) {
props.component.composantModel = null
}
emit('assign-model', {
componentId: props.component.id,
composantModelId: value || null,
previousModelId,
previousModel,
})
}
const emitAssignPieceModel = (payload) => {
emit('assign-piece-model', payload)
}
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !props.component?.id) return
await refreshDocuments()
@@ -393,9 +492,9 @@ const handleFilesAdded = async (files) => {
const result = await uploadDocuments(
{
files,
context: { composantId: props.component.id }
context: { composantId: props.component.id },
},
{ updateStore: false }
{ updateStore: false },
)
if (result.success) {
@@ -413,7 +512,7 @@ const removeDocument = async (documentId) => {
if (!documentId) return
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
props.component.documents = (props.component.documents || []).filter(doc => doc.id !== documentId)
props.component.documents = (props.component.documents || []).filter((doc) => doc.id !== documentId)
}
}
@@ -450,4 +549,4 @@ const formatSize = (size) => {
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
</script>
</script>

View File

@@ -0,0 +1,242 @@
<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="grid grid-cols-1 md:grid-cols-2 gap-2">
<label class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</label>
<input
v-model="field.defaultValue"
type="text"
class="input input-bordered input-xs"
placeholder="Valeur par défaut"
/>
</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"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 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>
<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"
@remove="removeSubComponent(index)"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } 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'
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 }
)
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,
defaultValue: '',
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,
})
}
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>

View File

@@ -0,0 +1,36 @@
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-600">
<span class="badge badge-outline badge-sm" v-if="stats.customFields">{{ stats.customFields }} champ(s)</span>
<span class="badge badge-outline badge-sm" v-if="stats.pieces">{{ stats.pieces }} pièce(s)</span>
<span class="badge badge-outline badge-sm" v-if="stats.subComponents">{{ stats.subComponents }} sous-composant(s)</span>
<span v-if="!stats.customFields && !stats.pieces && !stats.subComponents" class="text-xs text-gray-500">
Structure vide
</span>
</div>
<details class="collapse collapse-arrow bg-base-200">
<summary class="collapse-title text-sm font-medium">Voir la structure JSON</summary>
<div class="collapse-content">
<pre class="mockup-code whitespace-pre-wrap text-xs bg-base-300 p-4 rounded">
<code>{{ formatted }}</code>
</pre>
</div>
</details>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computeStructureStats } from '~/shared/modelUtils'
const props = defineProps({
structure: {
type: Object,
default: () => ({}),
},
})
const stats = computed(() => computeStructureStats(props.structure))
const formatted = computed(() => JSON.stringify(props.structure ?? {}, null, 2))
</script>

View File

@@ -24,6 +24,23 @@
{{ pieceData.name }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span
v-if="piece.typeMachinePieceRequirement"
class="badge badge-outline badge-sm"
>
Groupe : {{ piece.typeMachinePieceRequirement.label || piece.typeMachinePieceRequirement.typePiece?.name || 'Non défini' }}
</span>
<span
v-if="piece.pieceModel"
class="badge badge-outline badge-primary badge-sm"
>
Modèle : {{ piece.pieceModel.name }}
</span>
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
Rattachée à {{ piece.parentComponentName }}
</span>
</div>
</div>
<div class="space-y-2 text-sm">
@@ -81,6 +98,32 @@
</div>
</div>
<div
v-if="isEditMode && piece.typeMachinePieceRequirement"
class="mt-3"
>
<label class="label">
<span class="label-text text-sm font-medium">Modèle de pièce</span>
<span class="label-text-alt text-xs">
{{ piece.typeMachinePieceRequirement.label || piece.typeMachinePieceRequirement.typePiece?.name || 'Groupe' }}
</span>
</label>
<select
:value="selectedPieceModelId"
class="select select-bordered select-sm w-full"
@change="assignPieceModel($event.target.value)"
>
<option value="">Définir manuellement</option>
<option
v-for="model in pieceModelOptions"
:key="model.id"
:value="model.id"
>
{{ model.name }}
</option>
</select>
</div>
<!-- Champs personnalisés de la pièce -->
<div v-if="piece.customFieldValues && piece.customFieldValues.length > 0" class="mt-4 pt-4 border-t border-gray-200">
<h5 class="text-sm font-medium text-gray-700 mb-3">Champs personnalisés</h5>
@@ -260,15 +303,19 @@ import IconLucidePackage from '~icons/lucide/package'
const props = defineProps({
piece: {
type: Object,
required: true
required: true,
},
isEditMode: {
type: Boolean,
default: false
}
default: false,
},
pieceModelOptions: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'assign-model'])
// Données locales isolées pour cette pièce
const pieceData = reactive({
@@ -286,6 +333,8 @@ const pieceDocuments = computed(() => props.piece.documents || [])
const documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
const previewDocument = ref(null)
const previewVisible = ref(false)
const selectedPieceModelId = computed(() => props.piece.pieceModelId || props.piece.pieceModel?.id || '')
const pieceModelOptions = computed(() => props.pieceModelOptions || [])
const handleConstructeurChange = (value) => {
props.piece.constructeurId = value
@@ -399,6 +448,21 @@ const updatePiece = () => {
})
}
const assignPieceModel = (value) => {
const previousModelId = props.piece.pieceModelId || props.piece.pieceModel?.id || null
const previousModel = props.piece.pieceModel || null
props.piece.pieceModelId = value || null
if (!value) {
props.piece.pieceModel = null
}
emit('assign-model', {
pieceId: props.piece.id,
pieceModelId: value || null,
previousModelId,
previousModel,
})
}
const updateCustomFieldValue = async (fieldValueId) => {
const fieldValue = props.piece.customFieldValues?.find(fv => fv.id === fieldValueId)
if (fieldValue) {
@@ -419,6 +483,16 @@ watch(() => props.piece.customFieldValues, () => {
console.log('PieceItem - customFieldValues updated:', props.piece.customFieldValues)
}, { deep: true })
watch(
() => [props.piece.name, props.piece.reference, props.piece.emplacement, props.piece.prix],
() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.emplacement = props.piece.emplacement || ''
pieceData.prix = props.piece.prix || ''
},
)
onMounted(() => {
// Initialiser les données avec les props
pieceData.name = props.piece.name || ''

View File

@@ -0,0 +1,260 @@
<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="grid grid-cols-1 md:grid-cols-2 gap-2">
<label class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</label>
<input
v-model="field.defaultValue"
type="text"
class="input input-bordered input-xs"
placeholder="Valeur par défaut"
/>
</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"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 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>
<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"
@remove="removeSubComponent(index)"
/>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
defineOptions({ name: 'StructureSubComponentEditor' })
const props = defineProps({
node: {
type: Object,
required: true,
},
depth: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['remove'])
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,
defaultValue: '',
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,
})
}
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>

View File

@@ -1,474 +0,0 @@
<template>
<div class="border border-gray-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleComponent"
title="Plier / déplier le composant"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': expanded }"
aria-hidden="true"
/>
</button>
<IconLucideCpu class="w-5 h-5 text-blue-500" aria-hidden="true" />
<h4 class="text-lg font-medium">{{ component.name }}</h4>
<span v-if="!expanded && component.reference" class="text-xs text-gray-500">(Ref: {{ component.reference }})</span>
</div>
<div v-if="!expanded && compactInfo" class="text-xs text-gray-500">
{{ compactInfo }}
</div>
</div>
<template v-if="expanded">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div v-if="component.constructeur" class="text-sm">
<span class="font-medium">Constructeur:</span> {{ component.constructeur }}
</div>
<div v-if="component.emplacement" class="text-sm">
<span class="font-medium">Emplacement:</span> {{ component.emplacement }}
</div>
<div v-if="component.prix" class="text-sm">
<span class="font-medium">Prix:</span> {{ component.prix }}
</div>
</div>
<div v-if="component.customFields && component.customFields.length > 0" class="mb-4">
<div class="flex items-center justify-between mb-2">
<h5 class="text-sm font-medium text-gray-700">Champs personnalisés</h5>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSection('customFields')"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': expandedSections.customFields }"
aria-hidden="true"
/>
</button>
</div>
<div v-if="expandedSections.customFields" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
<div v-for="field in component.customFields" :key="field.id || field.name" class="bg-gray-50 rounded p-2">
<div class="text-xs font-medium">{{ field.name }}</div>
<div class="text-xs text-gray-600">{{ field.type }}</div>
<div v-if="field.required" class="text-xs text-red-500">Obligatoire</div>
<div v-if="field.defaultValue" class="text-xs text-gray-500">Défaut: {{ field.defaultValue }}</div>
</div>
</div>
</div>
<div v-if="component.pieces && component.pieces.length > 0" class="mb-4">
<div class="flex items-center justify-between mb-2">
<h5 class="text-sm font-medium text-gray-700">Pièces du composant</h5>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSection('pieces')"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': expandedSections.pieces }"
aria-hidden="true"
/>
</button>
</div>
<div v-if="expandedSections.pieces" class="space-y-2">
<div
v-for="(piece, pieceIndex) in component.pieces"
:key="pieceIndex"
class="border border-gray-200 rounded p-3 bg-gray-50"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="togglePieceDetails(pieceIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isPieceExpanded(pieceIndex) }"
aria-hidden="true"
/>
</button>
<IconLucidePackage class="w-4 h-4 text-red-500" aria-hidden="true" />
<span class="font-medium">{{ piece.name }}</span>
<span v-if="!isPieceExpanded(pieceIndex) && piece.reference" class="text-xs text-gray-500">(Ref: {{ piece.reference }})</span>
</div>
<div v-if="!isPieceExpanded(pieceIndex) && quickPieceSummary(piece)" class="text-xs text-gray-500">
{{ quickPieceSummary(piece) }}
</div>
</div>
<div v-if="isPieceExpanded(pieceIndex)">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 mb-2">
<div v-if="piece.constructeur" class="text-xs">
<span class="font-medium">Constructeur:</span> {{ piece.constructeur }}
</div>
<div v-if="piece.emplacement" class="text-xs">
<span class="font-medium">Emplacement:</span> {{ piece.emplacement }}
</div>
<div v-if="piece.prix" class="text-xs">
<span class="font-medium">Prix:</span> {{ piece.prix }}
</div>
</div>
<div v-if="piece.customFields && piece.customFields.length > 0">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-600">Champs personnalisés</span>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="togglePieceCustomFields(pieceIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isPieceCustomFieldSectionExpanded(pieceIndex) }"
aria-hidden="true"
/>
</button>
</div>
<div v-if="isPieceCustomFieldSectionExpanded(pieceIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-1">
<div v-for="field in piece.customFields" :key="field.id || field.name" class="bg-white rounded p-1">
<div class="text-xs font-medium">{{ field.name }}</div>
<div class="text-xs text-gray-600">{{ field.type }}</div>
<div v-if="field.required" class="text-xs text-red-500">Obligatoire</div>
<div v-if="field.defaultValue" class="text-xs text-gray-500">Défaut: {{ field.defaultValue }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="component.subComponents && component.subComponents.length > 0" class="ml-4 border-l-2 border-gray-300 pl-4">
<div class="flex items-center justify-between mb-2">
<h5 class="text-sm font-medium text-gray-700">Sous-composants</h5>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSection('subComponents')"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': expandedSections.subComponents }"
aria-hidden="true"
/>
</button>
</div>
<div v-if="expandedSections.subComponents" class="space-y-3">
<div
v-for="(subComponent, subIndex) in component.subComponents"
:key="subIndex"
class="border border-gray-200 rounded p-3 bg-gray-50"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSubComponentDetails(subIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isSubComponentExpanded(subIndex) }"
aria-hidden="true"
/>
</button>
<IconLucideCheckCircle2 class="w-4 h-4 text-green-500" aria-hidden="true" />
<span class="font-medium">{{ subComponent.name }}</span>
<span v-if="!isSubComponentExpanded(subIndex) && subComponent.reference" class="text-xs text-gray-500">(Ref: {{ subComponent.reference }})</span>
</div>
<div v-if="!isSubComponentExpanded(subIndex) && quickSubSummary(subComponent)" class="text-xs text-gray-500">
{{ quickSubSummary(subComponent) }}
</div>
</div>
<div v-if="isSubComponentExpanded(subIndex)" class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div v-if="subComponent.constructeur" class="text-xs">
<span class="font-medium">Constructeur:</span> {{ subComponent.constructeur }}
</div>
<div v-if="subComponent.emplacement" class="text-xs">
<span class="font-medium">Emplacement:</span> {{ subComponent.emplacement }}
</div>
<div v-if="subComponent.prix" class="text-xs">
<span class="font-medium">Prix:</span> {{ subComponent.prix }}
</div>
</div>
<div v-if="subComponent.customFields && subComponent.customFields.length > 0" class="mb-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-600">Champs personnalisés</span>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSubComponentCustomFieldSection(subIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isSubComponentCustomFieldSectionExpanded(subIndex) }"
aria-hidden="true"
/>
</button>
</div>
<div v-if="isSubComponentCustomFieldSectionExpanded(subIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-1">
<div v-for="field in subComponent.customFields" :key="field.id || field.name" class="bg-white rounded p-1">
<div class="text-xs font-medium">{{ field.name }}</div>
<div class="text-xs text-gray-600">{{ field.type }}</div>
<div v-if="field.required" class="text-xs text-red-500">Obligatoire</div>
<div v-if="field.defaultValue" class="text-xs text-gray-500">Défaut: {{ field.defaultValue }}</div>
</div>
</div>
</div>
<div v-if="subComponent.pieces && subComponent.pieces.length > 0">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-600 font-medium">Pièces</span>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSubComponentPieceSection(subIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isSubComponentPieceSectionExpanded(subIndex) }"
aria-hidden="true"
/>
</button>
</div>
<div v-if="isSubComponentPieceSectionExpanded(subIndex)" class="space-y-1">
<div
v-for="(piece, pieceIndex) in subComponent.pieces"
:key="pieceIndex"
class="border border-gray-100 rounded p-2 bg-white"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSubPieceDetails(subIndex, pieceIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isSubPieceExpanded(subIndex, pieceIndex) }"
aria-hidden="true"
/>
</button>
<IconLucidePackage class="w-3 h-3 text-red-500" aria-hidden="true" />
<span class="text-xs font-medium">{{ piece.name }}</span>
<span v-if="!isSubPieceExpanded(subIndex, pieceIndex) && piece.reference" class="text-[10px] text-gray-500">(Ref: {{ piece.reference }})</span>
</div>
<div v-if="!isSubPieceExpanded(subIndex, pieceIndex) && quickPieceSummary(piece)" class="text-[10px] text-gray-500">
{{ quickPieceSummary(piece) }}
</div>
</div>
<div v-if="isSubPieceExpanded(subIndex, pieceIndex)">
<div class="grid grid-cols-2 gap-1 text-[11px] mb-1">
<div v-if="piece.constructeur">
<span class="font-medium">Constructeur:</span> {{ piece.constructeur }}
</div>
<div v-if="piece.emplacement">
<span class="font-medium">Emplacement:</span> {{ piece.emplacement }}
</div>
<div v-if="piece.prix">
<span class="font-medium">Prix:</span> {{ piece.prix }}
</div>
</div>
<div v-if="piece.customFields && piece.customFields.length > 0" class="mt-1">
<div class="flex items-center justify-between mb-1">
<span class="text-[11px] font-medium text-gray-600">Champs personnalisés</span>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleSubPieceCustomFields(subIndex, pieceIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isSubPieceCustomFieldSectionExpanded(subIndex, pieceIndex) }"
aria-hidden="true"
/>
</button>
</div>
<div v-if="isSubPieceCustomFieldSectionExpanded(subIndex, pieceIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-1">
<div v-for="field in piece.customFields" :key="field.id || field.name" class="bg-gray-50 rounded p-1">
<div class="text-[11px] font-medium">{{ field.name }}</div>
<div class="text-[10px] text-gray-600">{{ field.type }}</div>
<div v-if="field.required" class="text-[10px] text-red-500">Obligatoire</div>
<div v-if="field.defaultValue" class="text-[10px] text-gray-500">Défaut: {{ field.defaultValue }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
const props = defineProps({
component: {
type: Object,
required: true
},
globalExpandState: {
type: Object,
default: null
}
})
const expanded = ref(true)
const expandedSections = reactive({
customFields: true,
pieces: true,
subComponents: true
})
const expandedPieces = ref([])
const expandedPieceCustomFields = ref([])
const expandedSubComponents = ref([])
const expandedSubComponentCustomFields = ref([])
const expandedSubComponentPieces = ref([])
const expandedSubComponentPieceCustomFields = reactive({})
const compactInfo = computed(() => {
const infos = []
if (props.component?.constructeur) {
infos.push(props.component.constructeur)
}
if (props.component?.emplacement) {
infos.push(props.component.emplacement)
}
return infos.join(' • ')
})
const quickPieceSummary = (piece) => {
const infos = []
if (piece?.constructeur) infos.push(piece.constructeur)
if (piece?.emplacement) infos.push(piece.emplacement)
return infos.join(' • ')
}
const quickSubSummary = (subComponent) => {
const infos = []
if (subComponent?.constructeur) infos.push(subComponent.constructeur)
if (subComponent?.emplacement) infos.push(subComponent.emplacement)
return infos.join(' • ')
}
const applyGlobalExpansion = (expand) => {
expanded.value = expand
expandedSections.customFields = expand && (props.component?.customFields || []).length > 0
expandedSections.pieces = expand && (props.component?.pieces || []).length > 0
expandedSections.subComponents = expand && (props.component?.subComponents || []).length > 0
expandedPieces.value = (props.component?.pieces || []).map(() => expand)
expandedPieceCustomFields.value = (props.component?.pieces || []).map(() => expand)
expandedSubComponents.value = (props.component?.subComponents || []).map(() => expand)
expandedSubComponentCustomFields.value = (props.component?.subComponents || []).map(() => expand)
expandedSubComponentPieces.value = (props.component?.subComponents || []).map(() => expand)
Object.keys(expandedSubComponentPieceCustomFields).forEach((key) => delete expandedSubComponentPieceCustomFields[key])
;(props.component?.subComponents || []).forEach((subComponent, subIndex) => {
expandedSubComponentPieceCustomFields[subIndex] = (subComponent.pieces || []).map(() => ({
expanded: expand,
customFields: expand
}))
})
}
const initializeExpansionState = () => {
const initialExpand = props.globalExpandState ? props.globalExpandState.expanded : true
applyGlobalExpansion(initialExpand)
}
watch(() => props.component, () => {
initializeExpansionState()
}, { deep: true })
onMounted(() => {
initializeExpansionState()
})
const toggleComponent = () => {
expanded.value = !expanded.value
}
const toggleSection = (section) => {
expandedSections[section] = !expandedSections[section]
}
const isPieceExpanded = (index) => expandedPieces.value[index]
const togglePieceDetails = (index) => {
expandedPieces.value[index] = !expandedPieces.value[index]
}
const isPieceCustomFieldSectionExpanded = (index) => expandedPieceCustomFields.value[index]
const togglePieceCustomFields = (index) => {
expandedPieceCustomFields.value[index] = !expandedPieceCustomFields.value[index]
}
const isSubComponentExpanded = (index) => expandedSubComponents.value[index]
const toggleSubComponentDetails = (index) => {
expandedSubComponents.value[index] = !expandedSubComponents.value[index]
}
const isSubComponentCustomFieldSectionExpanded = (index) => expandedSubComponentCustomFields.value[index]
const toggleSubComponentCustomFieldSection = (index) => {
expandedSubComponentCustomFields.value[index] = !expandedSubComponentCustomFields.value[index]
}
const isSubComponentPieceSectionExpanded = (index) => expandedSubComponentPieces.value[index]
const toggleSubComponentPieceSection = (index) => {
expandedSubComponentPieces.value[index] = !expandedSubComponentPieces.value[index]
}
const isSubPieceExpanded = (subIndex, pieceIndex) => expandedSubComponentPieceCustomFields[subIndex]?.[pieceIndex]?.expanded ?? false
const ensureSubPieceEntry = (subIndex, pieceIndex) => {
if (!expandedSubComponentPieceCustomFields[subIndex]) {
expandedSubComponentPieceCustomFields[subIndex] = []
}
if (!expandedSubComponentPieceCustomFields[subIndex][pieceIndex]) {
expandedSubComponentPieceCustomFields[subIndex][pieceIndex] = { expanded: false, customFields: false }
}
}
const toggleSubPieceDetails = (subIndex, pieceIndex) => {
ensureSubPieceEntry(subIndex, pieceIndex)
expandedSubComponentPieceCustomFields[subIndex][pieceIndex].expanded = !expandedSubComponentPieceCustomFields[subIndex][pieceIndex].expanded
}
const isSubPieceCustomFieldSectionExpanded = (subIndex, pieceIndex) => expandedSubComponentPieceCustomFields[subIndex]?.[pieceIndex]?.customFields ?? false
const toggleSubPieceCustomFields = (subIndex, pieceIndex) => {
ensureSubPieceEntry(subIndex, pieceIndex)
expandedSubComponentPieceCustomFields[subIndex][pieceIndex].customFields = !expandedSubComponentPieceCustomFields[subIndex][pieceIndex].customFields
}
watch(() => props.globalExpandState?.id, () => {
if (props.globalExpandState) {
applyGlobalExpansion(props.globalExpandState.expanded)
}
})
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-lg">Familles de composants</h3>
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter une famille
</button>
</div>
<p class="text-sm text-gray-500">
Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.
</p>
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
Aucun groupe configuré. Ajoutez votre première famille de composants.
</div>
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
<label class="label">
<span class="label-text">Type de composant</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
:value="requirement.typeComposantId || ''"
class="select select-bordered select-sm"
required
@change="updateRequirement(index, { typeComposantId: $event.target.value || null })"
>
<option value="">Sélectionner un type</option>
<option
v-for="type in componentTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Libellé</span>
<span class="label-text-alt text-xs">Optionnel</span>
</label>
<input
:value="requirement.label || ''"
type="text"
class="input input-bordered input-sm"
placeholder="Ex: Sangles principales"
@input="updateRequirement(index, { label: $event.target.value })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Minimum requis</span>
</label>
<input
:value="requirement.minCount ?? 1"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Maximum autorisé</span>
<span class="label-text-alt text-xs">Laisser vide pour illimité</span>
</label>
<input
:value="requirement.maxCount ?? ''"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
/>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
@click="removeRequirement(index)"
>
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.required ?? true"
@change="updateRequirement(index, { required: $event.target.checked })"
/>
Requis
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.allowNewModels ?? true"
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
/>
Autoriser la création de nouveaux modèles lors de l'instanciation
</label>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import { useComponentTypes } from '~/composables/useComponentTypes'
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { componentTypes, loadComponentTypes } = useComponentTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
onMounted(async () => {
if (!componentTypes.value.length) {
await loadComponentTypes()
}
})
const addRequirement = () => {
requirements.value = [
...requirements.value,
{
id: undefined,
typeComposantId: null,
label: '',
minCount: 1,
maxCount: null,
required: true,
allowNewModels: true,
},
]
}
const removeRequirement = (index) => {
requirements.value = requirements.value.filter((_, i) => i !== index)
}
const updateRequirement = (index, patch) => {
requirements.value = requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item
)
}
const parseNumber = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const parseOptionalNumber = (value) => {
if (value === '' || value === null || value === undefined) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
</script>

View File

@@ -1,282 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-sm p-1"
@click="toggleSection"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': expanded }"
aria-hidden="true"
/>
</button>
<h3 class="card-title text-lg">Composants</h3>
<span class="badge badge-accent">{{ components.length }}</span>
</div>
</div>
<div v-if="expanded" class="space-y-4">
<div
v-for="(component, index) in components"
:key="index"
class="border border-gray-200 rounded-lg p-4 bg-gray-50 space-y-4"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleComponent(index)"
title="Plier / déplier le composant"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': isComponentExpanded(index) }"
aria-hidden="true"
/>
</button>
<IconLucideSettings2 class="w-4 h-4 text-green-500" aria-hidden="true" />
<h5 class="text-sm font-medium">Composant {{ index + 1 }}</h5>
<span v-if="!isComponentExpanded(index)" class="text-xs text-gray-500 truncate max-w-[160px]">
{{ component.name || 'Sans nom' }}
</span>
</div>
<button
type="button"
@click="removeComponent(index)"
class="btn btn-square btn-error btn-sm"
title="Supprimer ce composant"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="isComponentExpanded(index)" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
:value="component.name"
type="text"
placeholder="Nom du composant"
class="input input-bordered input-sm"
required
@input="updateComponent(index, { name: $event.target.value })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
:value="component.reference"
type="text"
placeholder="Référence"
class="input input-bordered input-sm"
@input="updateComponent(index, { reference: $event.target.value })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Constructeur</span>
</label>
<ConstructeurSelect
:model-value="component.constructeurId || component.constructeur?.id || null"
placeholder="Rechercher un constructeur..."
@update:modelValue="(value) => setComponentConstructeur(index, value)"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Emplacement</span>
</label>
<input
:value="component.emplacement"
type="text"
placeholder="Emplacement"
class="input input-bordered input-sm"
@input="updateComponent(index, { emplacement: $event.target.value })"
/>
</div>
</div>
<TypeEditCustomFieldsSection
:model-value="component.customFields || []"
:all-expanded="allExpanded"
:expand-all-trigger="componentExpandTrigger"
@update:model-value="(value) => updateComponent(index, { customFields: value })"
/>
<TypeMachinePieceForm
:model-value="component.pieces || []"
:all-expanded="allExpanded"
:expand-all-trigger="componentExpandTrigger"
@update:model-value="(value) => updateComponent(index, { pieces: value })"
/>
</div>
</div>
<div class="flex justify-end">
<button type="button" @click="addComponent" class="btn btn-primary btn-sm">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un composant
</button>
</div>
</div>
<div v-else class="flex justify-end">
<button type="button" @click="addComponent" class="btn btn-primary btn-sm">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un composant
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
import TypeMachinePieceForm from '~/components/TypeMachinePieceForm.vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
allExpanded: {
type: Boolean,
default: false,
},
expandAllTrigger: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:modelValue'])
const { constructeurs } = useConstructeurs()
const components = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const expanded = ref(false)
const expandedComponents = ref([])
const componentExpandTrigger = computed(() => props.expandAllTrigger)
watch(
() => props.expandAllTrigger,
() => {
expanded.value = props.allExpanded
expandedComponents.value = components.value.map(() => props.allExpanded)
},
{ immediate: true }
)
watch(
() => components.value.length,
(length) => {
expandedComponents.value = Array.from({ length }, (_, index) => expandedComponents.value[index] ?? props.allExpanded)
}
)
const toggleSection = () => {
expanded.value = !expanded.value
}
const ensureComponentState = (index) => {
if (expandedComponents.value[index] === undefined) {
expandedComponents.value[index] = props.allExpanded
}
}
const isComponentExpanded = (index) => {
ensureComponentState(index)
return expandedComponents.value[index]
}
const toggleComponent = (index) => {
ensureComponentState(index)
expandedComponents.value[index] = !expandedComponents.value[index]
}
const createComponent = () => ({
name: '',
reference: '',
constructeur: null,
constructeurId: null,
emplacement: '',
prix: null,
customFields: [],
pieces: [],
})
const addComponent = () => {
components.value = [...components.value, createComponent()]
expanded.value = true
expandedComponents.value.push(true)
}
const removeComponent = (index) => {
components.value = components.value.filter((_, i) => i !== index)
expandedComponents.value.splice(index, 1)
}
const updateComponent = (index, patch) => {
components.value = components.value.map((component, i) =>
i === index ? { ...component, ...patch } : component
)
}
const findConstructeurById = (id) => constructeurs.value.find(item => item.id === id) || null
const hydrateComponents = () => {
components.value.forEach((component) => {
if (component.constructeurId && (!component.constructeur || component.constructeur.id !== component.constructeurId)) {
component.constructeur = findConstructeurById(component.constructeurId)
}
(component.pieces || []).forEach((piece) => {
if (piece.constructeurId && (!piece.constructeur || piece.constructeur.id !== piece.constructeurId)) {
piece.constructeur = findConstructeurById(piece.constructeurId)
}
})
})
}
const setComponentConstructeur = (index, constructeurId) => {
updateComponent(index, {
constructeurId,
constructeur: constructeurId ? findConstructeurById(constructeurId) : null,
})
}
watch(
() => components.value,
() => hydrateComponents(),
{ deep: true }
)
watch(
() => constructeurs.value.length,
() => hydrateComponents()
)
hydrateComponents()
</script>

View File

@@ -16,18 +16,14 @@
@update:model-value="(value) => (formData.customFields = value)"
/>
<TypeEditMachinePiecesSection
:model-value="formData.machinePieces"
:all-expanded="allExpanded"
:expand-all-trigger="expandAllTrigger"
@update:model-value="(value) => (formData.machinePieces = value)"
<TypeEditComponentRequirementsSection
:model-value="formData.componentRequirements"
@update:model-value="(value) => (formData.componentRequirements = value)"
/>
<TypeEditComponentsSection
:model-value="formData.components"
:all-expanded="allExpanded"
:expand-all-trigger="expandAllTrigger"
@update:model-value="(value) => (formData.components = value)"
<TypeEditPieceRequirementsSection
:model-value="formData.pieceRequirements"
@update:model-value="(value) => (formData.pieceRequirements = value)"
/>
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
@@ -38,10 +34,10 @@
import { reactive, ref, watch } from 'vue'
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
import TypeEditComponentsSection from '~/components/TypeEditComponentsSection.vue'
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
import TypeEditMachinePiecesSection from '~/components/TypeEditMachinePiecesSection.vue'
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
const props = defineProps({
modelValue: {
@@ -58,45 +54,14 @@ const emit = defineEmits(['update:modelValue', 'submit'])
const deepClone = (value) => JSON.parse(JSON.stringify(value))
const normalizePieces = (pieces = []) => {
const cloned = deepClone(pieces)
return cloned.map((piece) => ({
...piece,
constructeur: typeof piece.constructeur === 'string'
? { id: null, name: piece.constructeur }
: piece.constructeur || null,
constructeurId:
piece.constructeurId ||
(typeof piece.constructeur === 'object' && piece.constructeur?.id) ||
null,
customFields: piece.customFields || [],
}))
}
const normalizeComponents = (components = []) => {
const cloned = deepClone(components)
return cloned.map((component) => ({
...component,
constructeur: typeof component.constructeur === 'string'
? { id: null, name: component.constructeur }
: component.constructeur || null,
constructeurId:
component.constructeurId ||
(typeof component.constructeur === 'object' && component.constructeur?.id) ||
null,
customFields: component.customFields || [],
pieces: normalizePieces(component.pieces || []),
}))
}
const createDefaultForm = (source = {}) => ({
name: source.name || '',
description: source.description || '',
category: source.category || '',
maintenanceFrequency: source.maintenanceFrequency || '',
customFields: deepClone(source.customFields || []),
machinePieces: normalizePieces(source.machinePieces || []),
components: normalizeComponents(source.components || []),
componentRequirements: deepClone(source.componentRequirements || []),
pieceRequirements: deepClone(source.pieceRequirements || []),
})
const formData = reactive(createDefaultForm(props.modelValue))

View File

@@ -1,75 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-sm p-1"
@click="toggleSection"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': expanded }"
aria-hidden="true"
/>
</button>
<h3 class="card-title text-lg">Pièces principales</h3>
<span class="badge badge-secondary">{{ pieces.length }}</span>
</div>
</div>
<div v-if="expanded">
<TypeMachinePieceForm
v-model="internalPieces"
:all-expanded="allExpanded"
:expand-all-trigger="expandAllTrigger"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import TypeMachinePieceForm from '~/components/TypeMachinePieceForm.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
allExpanded: {
type: Boolean,
default: false,
},
expandAllTrigger: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:modelValue'])
const expanded = ref(false)
watch(
() => props.expandAllTrigger,
() => {
expanded.value = props.allExpanded
},
{ immediate: true }
)
const internalPieces = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const pieces = computed(() => internalPieces.value)
const toggleSection = () => {
expanded.value = !expanded.value
}
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-lg">Pièces principales</h3>
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un groupe
</button>
</div>
<p class="text-sm text-gray-500">
Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.
</p>
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
Aucun groupe configuré. Ajoutez votre première famille de pièces.
</div>
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
<label class="label">
<span class="label-text">Type de pièce</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
:value="requirement.typePieceId || ''"
class="select select-bordered select-sm"
required
@change="updateRequirement(index, { typePieceId: $event.target.value || null })"
>
<option value="">Sélectionner un type</option>
<option
v-for="type in pieceTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Libellé</span>
<span class="label-text-alt text-xs">Optionnel</span>
</label>
<input
:value="requirement.label || ''"
type="text"
class="input input-bordered input-sm"
placeholder="Ex: Vis principale"
@input="updateRequirement(index, { label: $event.target.value })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Minimum requis</span>
</label>
<input
:value="requirement.minCount ?? 0"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Maximum autorisé</span>
<span class="label-text-alt text-xs">Laisser vide pour illimité</span>
</label>
<input
:value="requirement.maxCount ?? ''"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
/>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
@click="removeRequirement(index)"
>
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.required ?? false"
@change="updateRequirement(index, { required: $event.target.checked })"
/>
Requis
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.allowNewModels ?? true"
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
/>
Autoriser la création de nouveaux modèles lors de l'instanciation
</label>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import { usePieceTypes } from '~/composables/usePieceTypes'
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
onMounted(async () => {
if (!pieceTypes.value.length) {
await loadPieceTypes()
}
})
const addRequirement = () => {
requirements.value = [
...requirements.value,
{
id: undefined,
typePieceId: null,
label: '',
minCount: 0,
maxCount: null,
required: false,
allowNewModels: true,
},
]
}
const removeRequirement = (index) => {
requirements.value = requirements.value.filter((_, i) => i !== index)
}
const updateRequirement = (index, patch) => {
requirements.value = requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item
)
}
const parseNumber = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const parseOptionalNumber = (value) => {
if (value === '' || value === null || value === undefined) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
</script>

View File

@@ -5,8 +5,8 @@
<div class="text-sm">
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
<p><strong>Composants:</strong> {{ type.components?.length || 0 }}</p>
<p><strong>Pièces principales:</strong> {{ type.machinePieces?.length || 0 }}</p>
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
<p v-if="type.description"><strong>Description:</strong> {{ type.description }}</p>
</div>
</div>
@@ -20,4 +20,4 @@ defineProps({
required: true
}
})
</script>
</script>

View File

@@ -1,99 +0,0 @@
<template>
<div class="border border-gray-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="toggleDetails"
title="Plier / déplier la pièce"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': expanded }"
aria-hidden="true"
/>
</button>
<IconLucidePackage class="w-5 h-5 text-red-500" aria-hidden="true" />
<h4 class="font-medium">{{ piece.name }}</h4>
<span v-if="!expanded && piece.reference" class="text-xs text-gray-500">(Ref: {{ piece.reference }})</span>
</div>
<div v-if="!expanded && quickSummary" class="text-xs text-gray-500">
{{ quickSummary }}
</div>
</div>
<div v-if="expanded">
<div class="space-y-1 mb-3">
<div v-if="piece.reference" class="text-sm">
<span class="font-medium">Référence:</span> {{ piece.reference }}
</div>
<div v-if="piece.constructeur" class="text-sm">
<span class="font-medium">Constructeur:</span> {{ piece.constructeur }}
</div>
<div v-if="piece.emplacement" class="text-sm">
<span class="font-medium">Emplacement:</span> {{ piece.emplacement }}
</div>
<div v-if="piece.prix" class="text-sm">
<span class="font-medium">Prix:</span> {{ piece.prix }}
</div>
</div>
<div v-if="piece.customFields && piece.customFields.length > 0">
<h5 class="text-sm font-medium text-gray-700 mb-2">Champs personnalisés:</h5>
<div class="grid grid-cols-1 gap-2">
<div v-for="field in piece.customFields" :key="field.id || field.name" class="bg-gray-50 rounded p-2">
<div class="text-sm font-medium">{{ field.name }}</div>
<div class="text-xs text-gray-600">{{ field.type }}</div>
<div v-if="field.required" class="text-xs text-red-500">Obligatoire</div>
<div v-if="field.defaultValue" class="text-xs text-gray-500">Défaut: {{ field.defaultValue }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucidePackage from '~icons/lucide/package'
const props = defineProps({
piece: {
type: Object,
required: true
},
globalExpandState: {
type: Object,
default: null
}
})
const expanded = ref(true)
const quickSummary = computed(() => {
const infos = []
if (props.piece?.constructeur) {
infos.push(props.piece.constructeur)
}
if (props.piece?.emplacement) {
infos.push(props.piece.emplacement)
}
return infos.join(' • ')
})
watch(() => props.piece, () => {
expanded.value = props.globalExpandState ? props.globalExpandState.expanded : true
}, { deep: true })
watch(() => props.globalExpandState?.id, () => {
if (props.globalExpandState) {
expanded.value = props.globalExpandState.expanded
}
})
const toggleDetails = () => {
expanded.value = !expanded.value
}
</script>

View File

@@ -1,404 +0,0 @@
<template>
<div class="space-y-4">
<div class="flex justify-end">
<button
type="button"
class="btn btn-outline btn-sm"
@click="toggleAllPieces"
>
<IconLucideChevronRight
class="w-4 h-4 mr-2 transform transition-transform"
:class="allExpanded ? 'rotate-90' : ''"
aria-hidden="true"
/>
<span>{{ allExpanded ? 'Tout plier' : 'Tout déplier' }}</span>
</button>
</div>
<div
v-for="(piece, index) in pieces"
:key="piece.id ?? index"
class="border border-base-200 rounded-lg bg-base-100 p-4 space-y-3"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-2">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="togglePieceDetails(index)"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': isPieceExpanded(index) }"
aria-hidden="true"
/>
</button>
<div>
<h5 class="text-sm font-medium">Pièce {{ index + 1 }}</h5>
<span
v-if="!isPieceExpanded(index)"
class="text-xs text-gray-500 block max-w-[200px] truncate"
>
{{ piece.name || 'Sans nom' }}
</span>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-xs"
@click="removePiece(index)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</div>
<div v-if="isPieceExpanded(index)" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input
v-model="piece.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom de la pièce"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Référence</span></label>
<input
v-model="piece.reference"
type="text"
class="input input-bordered input-sm"
placeholder="Référence"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Constructeur</span></label>
<ConstructeurSelect
:model-value="piece.constructeurId || piece.constructeur?.id || null"
placeholder="Sélectionner un constructeur"
@update:modelValue="value => setPieceConstructeur(index, value)"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Emplacement</span></label>
<input
v-model="piece.emplacement"
type="text"
class="input input-bordered input-sm"
placeholder="Emplacement"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Prix</span></label>
<input
v-model="piece.prix"
type="number"
step="0.01"
class="input input-bordered input-sm"
placeholder="Prix (optionnel)"
/>
</div>
</div>
<div class="space-y-3">
<div
v-for="(field, fieldIndex) in piece.customFields || []"
:key="field.id || `${index}-${fieldIndex}`"
class="border border-base-200 rounded-lg bg-base-200/60 p-3 space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@click="togglePieceCustomFieldDetails(index, fieldIndex)"
>
<IconLucideChevronRight
class="w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isPieceCustomFieldExpanded(index, fieldIndex) }"
aria-hidden="true"
/>
</button>
<span class="text-xs font-medium">Champ {{ fieldIndex + 1 }}</span>
<span
v-if="!isPieceCustomFieldExpanded(index, fieldIndex)"
class="text-[10px] text-gray-500 max-w-[160px] truncate"
>
{{ field.name || 'Sans nom' }}
</span>
</div>
<button
type="button"
class="btn btn-square btn-error btn-xs"
@click="removeCustomField(index, fieldIndex)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</div>
<div v-if="isPieceCustomFieldExpanded(index, fieldIndex)" class="space-y-3">
<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 w-full"
placeholder="Nom du champ"
/>
<select v-model="field.type" class="select select-bordered select-xs w-full">
<option value="">Type</option>
<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="grid grid-cols-1 md:grid-cols-2 gap-2">
<label class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</label>
<input
v-model="field.defaultValue"
type="text"
class="input input-bordered input-xs w-full"
placeholder="Valeur par défaut"
/>
</div>
<div v-if="field.type === 'select'">
<textarea
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs w-full h-16"
placeholder="Option 1&#10;Option 2&#10;Option 3"
@input="updateFieldOptions(index, fieldIndex)"
></textarea>
</div>
</div>
</div>
<button
type="button"
class="btn btn-outline btn-sm"
@click="addCustomField(index)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un champ personnalisé
</button>
</div>
</div>
</div>
<button
type="button"
class="btn btn-outline btn-sm"
@click="addPiece"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter une pièce de machine
</button>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
allExpanded: {
type: Boolean,
default: false,
},
expandAllTrigger: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:modelValue'])
const { constructeurs } = useConstructeurs()
const pieces = ref(props.modelValue)
watch(
() => props.modelValue,
(newValue) => {
pieces.value = newValue
initializeExpansionState()
hydrateConstructeurs()
}
)
watch(
() => props.expandAllTrigger,
() => {
setAllExpanded(props.allExpanded)
}
)
const allExpanded = ref(false)
const expandedPieces = ref([])
const expandedPieceCustomFields = reactive({})
const ensurePieceFieldState = (pieceIndex) => {
if (!expandedPieceCustomFields[pieceIndex]) {
expandedPieceCustomFields[pieceIndex] = []
}
}
const isPieceExpanded = (index) => {
if (expandedPieces.value[index] === undefined) {
expandedPieces.value[index] = allExpanded.value
}
return expandedPieces.value[index]
}
const togglePieceDetails = (index) => {
expandedPieces.value[index] = !isPieceExpanded(index)
if (!expandedPieces.value[index]) {
allExpanded.value = false
}
}
const isPieceCustomFieldExpanded = (pieceIndex, fieldIndex) => {
ensurePieceFieldState(pieceIndex)
if (expandedPieceCustomFields[pieceIndex][fieldIndex] === undefined) {
expandedPieceCustomFields[pieceIndex][fieldIndex] = allExpanded.value
}
return expandedPieceCustomFields[pieceIndex][fieldIndex]
}
const togglePieceCustomFieldDetails = (pieceIndex, fieldIndex) => {
ensurePieceFieldState(pieceIndex)
expandedPieceCustomFields[pieceIndex][fieldIndex] = !isPieceCustomFieldExpanded(pieceIndex, fieldIndex)
if (!expandedPieceCustomFields[pieceIndex][fieldIndex]) {
allExpanded.value = false
}
}
const clearExpansionState = () => {
expandedPieces.value = []
Object.keys(expandedPieceCustomFields).forEach((key) => delete expandedPieceCustomFields[key])
}
const setAllExpanded = (value) => {
allExpanded.value = value
expandedPieces.value = pieces.value.map(() => value)
Object.keys(expandedPieceCustomFields).forEach((key) => delete expandedPieceCustomFields[key])
pieces.value.forEach((piece, index) => {
expandedPieceCustomFields[index] = (piece.customFields || []).map(() => value)
})
}
const toggleAllPieces = () => {
setAllExpanded(!allExpanded.value)
}
const initializeExpansionState = () => {
clearExpansionState()
setAllExpanded(props.allExpanded)
}
onMounted(() => {
initializeExpansionState()
hydrateConstructeurs()
})
// Méthodes pour les pièces
const addPiece = () => {
pieces.value.push({
name: '',
reference: '',
constructeur: null,
constructeurId: null,
emplacement: '',
prix: null,
customFields: []
})
emit('update:modelValue', pieces.value)
expandedPieces.value.push(allExpanded.value)
ensurePieceFieldState(pieces.value.length - 1)
}
const removePiece = (index) => {
pieces.value.splice(index, 1)
emit('update:modelValue', pieces.value)
expandedPieces.value.splice(index, 1)
delete expandedPieceCustomFields[index]
const reordered = {}
Object.keys(expandedPieceCustomFields)
.map(key => Number(key))
.sort((a, b) => a - b)
.forEach((key, position) => {
reordered[position] = expandedPieceCustomFields[key]
})
Object.keys(expandedPieceCustomFields).forEach(key => delete expandedPieceCustomFields[key])
Object.assign(expandedPieceCustomFields, reordered)
}
const addCustomField = (pieceIndex) => {
if (!pieces.value[pieceIndex].customFields) {
pieces.value[pieceIndex].customFields = []
}
pieces.value[pieceIndex].customFields.push({
name: '',
type: '',
required: false,
defaultValue: '',
optionsText: ''
})
ensurePieceFieldState(pieceIndex)
expandedPieceCustomFields[pieceIndex].push(allExpanded.value)
}
const removeCustomField = (pieceIndex, fieldIndex) => {
if (pieces.value[pieceIndex].customFields) {
pieces.value[pieceIndex].customFields.splice(fieldIndex, 1)
}
ensurePieceFieldState(pieceIndex)
expandedPieceCustomFields[pieceIndex].splice(fieldIndex, 1)
}
const updateFieldOptions = (pieceIndex, fieldIndex) => {
if (pieces.value[pieceIndex].customFields[fieldIndex]) {
pieces.value[pieceIndex].customFields[fieldIndex].optionsText = pieces.value[pieceIndex].customFields[fieldIndex].optionsText.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
}
const findConstructeurById = (id) => constructeurs.value.find(item => item.id === id) || null
const hydrateConstructeurs = () => {
pieces.value.forEach((piece) => {
if (piece.constructeurId && (!piece.constructeur || piece.constructeur.id !== piece.constructeurId)) {
piece.constructeur = findConstructeurById(piece.constructeurId)
}
})
}
const setPieceConstructeur = (index, constructeurId) => {
const piece = pieces.value[index]
if (!piece) return
piece.constructeurId = constructeurId
piece.constructeur = constructeurId ? findConstructeurById(constructeurId) : null
emit('update:modelValue', pieces.value)
}
watch(
() => constructeurs.value.length,
() => hydrateConstructeurs()
)
</script>