set up new view for skeleton hiearchi
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
242
app/components/ComponentModelStructureEditor.vue
Normal file
242
app/components/ComponentModelStructureEditor.vue
Normal 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 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>
|
||||
36
app/components/ModelStructureViewer.vue
Normal file
36
app/components/ModelStructureViewer.vue
Normal 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>
|
||||
@@ -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 || ''
|
||||
|
||||
260
app/components/StructureSubComponentEditor.vue
Normal file
260
app/components/StructureSubComponentEditor.vue
Normal 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 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>
|
||||
@@ -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>
|
||||
191
app/components/TypeEditComponentRequirementsSection.vue
Normal file
191
app/components/TypeEditComponentRequirementsSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
191
app/components/TypeEditPieceRequirementsSection.vue
Normal file
191
app/components/TypeEditPieceRequirementsSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 Option 2 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>
|
||||
Reference in New Issue
Block a user