set up new view for skeleton hiearchi
This commit is contained in:
18
app/app.vue
18
app/app.vue
@@ -44,6 +44,15 @@
|
|||||||
Types de Machines
|
Types de Machines
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/models"
|
||||||
|
class="rounded-md px-2 py-1 transition-colors"
|
||||||
|
:class="isActive('/models') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||||
|
>
|
||||||
|
Modèles
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
@@ -120,6 +129,15 @@
|
|||||||
Types de Machines
|
Types de Machines
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/models"
|
||||||
|
class="transition-colors px-3 py-2 rounded-md"
|
||||||
|
:class="isActive('/models') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||||
|
>
|
||||||
|
Modèles
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
|
|||||||
@@ -7,8 +7,15 @@
|
|||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
:collapse-all="collapseAll"
|
:collapse-all="collapseAll"
|
||||||
:toggle-token="toggleToken"
|
:toggle-token="toggleToken"
|
||||||
|
:component-model-options="componentModelOptionsProvider(component)"
|
||||||
|
:component-model-options-provider="componentModelOptionsProvider"
|
||||||
|
:piece-model-options-provider="pieceModelOptionsProvider"
|
||||||
@update="$emit('update', $event)"
|
@update="$emit('update', $event)"
|
||||||
@edit-piece="$emit('edit-piece', $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>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,8 +40,16 @@ defineProps({
|
|||||||
toggleToken: {
|
toggleToken: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
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>
|
</script>
|
||||||
|
|||||||
@@ -27,6 +27,18 @@
|
|||||||
<span v-if="component.constructeur" class="badge badge-outline badge-sm">{{ component.constructeur?.name }}</span>
|
<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.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.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,6 +111,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Fields Display - Editable or Read-only -->
|
<!-- Custom Fields Display - Editable or Read-only -->
|
||||||
@@ -243,9 +293,11 @@
|
|||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
:piece="piece"
|
:piece="piece"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
|
:piece-model-options="pieceModelOptionsProvider(piece)"
|
||||||
@update="updatePiece"
|
@update="updatePiece"
|
||||||
@edit="editPiece"
|
@edit="editPiece"
|
||||||
@custom-field-update="updatePieceCustomField"
|
@custom-field-update="updatePieceCustomField"
|
||||||
|
@assign-model="emitAssignPieceModel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,8 +313,13 @@
|
|||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
:collapse-all="collapseAll"
|
:collapse-all="collapseAll"
|
||||||
:toggle-token="toggleToken"
|
:toggle-token="toggleToken"
|
||||||
|
:component-model-options="componentModelOptionsProvider(subComponent)"
|
||||||
|
:component-model-options-provider="componentModelOptionsProvider"
|
||||||
|
:piece-model-options-provider="pieceModelOptionsProvider"
|
||||||
@update="$emit('update', $event)"
|
@update="$emit('update', $event)"
|
||||||
@edit-piece="$emit('edit-piece', $event)"
|
@edit-piece="$emit('edit-piece', $event)"
|
||||||
|
@assign-model="$emit('assign-model', $event)"
|
||||||
|
@assign-piece-model="$emit('assign-piece-model', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,23 +341,42 @@ import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
component: {
|
component: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
isEditMode: {
|
isEditMode: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
collapseAll: {
|
collapseAll: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true,
|
||||||
},
|
},
|
||||||
toggleToken: {
|
toggleToken: {
|
||||||
type: Number,
|
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 isCollapsed = ref(true)
|
||||||
const selectedFiles = ref([])
|
const selectedFiles = ref([])
|
||||||
@@ -312,6 +388,13 @@ const documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime
|
|||||||
const previewDocument = ref(null)
|
const previewDocument = ref(null)
|
||||||
const previewVisible = ref(false)
|
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) => {
|
const handleConstructeurChange = async (value) => {
|
||||||
props.component.constructeurId = value
|
props.component.constructeurId = value
|
||||||
await updateComponent()
|
await updateComponent()
|
||||||
@@ -327,15 +410,14 @@ watch(
|
|||||||
ensureDocumentsLoaded()
|
ensureDocumentsLoaded()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.component.documents,
|
() => props.component.documents,
|
||||||
(docs) => {
|
(docs) => {
|
||||||
documentsLoaded.value = !!(docs && docs.length)
|
documentsLoaded.value = !!(docs && docs.length)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
@@ -345,13 +427,11 @@ const toggleCollapse = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods
|
|
||||||
const updateComponent = () => {
|
const updateComponent = () => {
|
||||||
emit('update', props.component)
|
emit('update', props.component)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateComponentCustomField = (field) => {
|
const updateComponentCustomField = () => {
|
||||||
// Mettre à jour le champ personnalisé du composant
|
|
||||||
emit('update', props.component)
|
emit('update', props.component)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,10 +444,29 @@ const editPiece = (piece) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatePieceCustomField = (fieldUpdate) => {
|
const updatePieceCustomField = (fieldUpdate) => {
|
||||||
// Forward to parent
|
emit('custom-field-update', fieldUpdate)
|
||||||
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
|
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 () => {
|
const ensureDocumentsLoaded = async () => {
|
||||||
if (documentsLoaded.value || !props.component?.id) return
|
if (documentsLoaded.value || !props.component?.id) return
|
||||||
await refreshDocuments()
|
await refreshDocuments()
|
||||||
@@ -393,9 +492,9 @@ const handleFilesAdded = async (files) => {
|
|||||||
const result = await uploadDocuments(
|
const result = await uploadDocuments(
|
||||||
{
|
{
|
||||||
files,
|
files,
|
||||||
context: { composantId: props.component.id }
|
context: { composantId: props.component.id },
|
||||||
},
|
},
|
||||||
{ updateStore: false }
|
{ updateStore: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -413,7 +512,7 @@ const removeDocument = async (documentId) => {
|
|||||||
if (!documentId) return
|
if (!documentId) return
|
||||||
const result = await deleteDocument(documentId, { updateStore: false })
|
const result = await deleteDocument(documentId, { updateStore: false })
|
||||||
if (result.success) {
|
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)
|
const formatted = size / Math.pow(1024, index)
|
||||||
return `${formatted.toFixed(1)} ${units[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 }}
|
{{ pieceData.name }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
@@ -81,6 +98,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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">
|
<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>
|
<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({
|
const props = defineProps({
|
||||||
piece: {
|
piece: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
isEditMode: {
|
isEditMode: {
|
||||||
type: Boolean,
|
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
|
// Données locales isolées pour cette pièce
|
||||||
const pieceData = reactive({
|
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 documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||||
const previewDocument = ref(null)
|
const previewDocument = ref(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
const selectedPieceModelId = computed(() => props.piece.pieceModelId || props.piece.pieceModel?.id || '')
|
||||||
|
const pieceModelOptions = computed(() => props.pieceModelOptions || [])
|
||||||
|
|
||||||
const handleConstructeurChange = (value) => {
|
const handleConstructeurChange = (value) => {
|
||||||
props.piece.constructeurId = 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 updateCustomFieldValue = async (fieldValueId) => {
|
||||||
const fieldValue = props.piece.customFieldValues?.find(fv => fv.id === fieldValueId)
|
const fieldValue = props.piece.customFieldValues?.find(fv => fv.id === fieldValueId)
|
||||||
if (fieldValue) {
|
if (fieldValue) {
|
||||||
@@ -419,6 +483,16 @@ watch(() => props.piece.customFieldValues, () => {
|
|||||||
console.log('PieceItem - customFieldValues updated:', props.piece.customFieldValues)
|
console.log('PieceItem - customFieldValues updated:', props.piece.customFieldValues)
|
||||||
}, { deep: true })
|
}, { 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(() => {
|
onMounted(() => {
|
||||||
// Initialiser les données avec les props
|
// Initialiser les données avec les props
|
||||||
pieceData.name = props.piece.name || ''
|
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)"
|
@update:model-value="(value) => (formData.customFields = value)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TypeEditMachinePiecesSection
|
<TypeEditComponentRequirementsSection
|
||||||
:model-value="formData.machinePieces"
|
:model-value="formData.componentRequirements"
|
||||||
:all-expanded="allExpanded"
|
@update:model-value="(value) => (formData.componentRequirements = value)"
|
||||||
:expand-all-trigger="expandAllTrigger"
|
|
||||||
@update:model-value="(value) => (formData.machinePieces = value)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TypeEditComponentsSection
|
<TypeEditPieceRequirementsSection
|
||||||
:model-value="formData.components"
|
:model-value="formData.pieceRequirements"
|
||||||
:all-expanded="allExpanded"
|
@update:model-value="(value) => (formData.pieceRequirements = value)"
|
||||||
:expand-all-trigger="expandAllTrigger"
|
|
||||||
@update:model-value="(value) => (formData.components = value)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
||||||
@@ -38,10 +34,10 @@
|
|||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
|
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
|
||||||
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
|
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
|
||||||
import TypeEditComponentsSection from '~/components/TypeEditComponentsSection.vue'
|
|
||||||
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
|
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
|
||||||
import TypeEditMachinePiecesSection from '~/components/TypeEditMachinePiecesSection.vue'
|
|
||||||
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
||||||
|
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
|
||||||
|
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -58,45 +54,14 @@ const emit = defineEmits(['update:modelValue', 'submit'])
|
|||||||
|
|
||||||
const deepClone = (value) => JSON.parse(JSON.stringify(value))
|
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 = {}) => ({
|
const createDefaultForm = (source = {}) => ({
|
||||||
name: source.name || '',
|
name: source.name || '',
|
||||||
description: source.description || '',
|
description: source.description || '',
|
||||||
category: source.category || '',
|
category: source.category || '',
|
||||||
maintenanceFrequency: source.maintenanceFrequency || '',
|
maintenanceFrequency: source.maintenanceFrequency || '',
|
||||||
customFields: deepClone(source.customFields || []),
|
customFields: deepClone(source.customFields || []),
|
||||||
machinePieces: normalizePieces(source.machinePieces || []),
|
componentRequirements: deepClone(source.componentRequirements || []),
|
||||||
components: normalizeComponents(source.components || []),
|
pieceRequirements: deepClone(source.pieceRequirements || []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const formData = reactive(createDefaultForm(props.modelValue))
|
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">
|
<div class="text-sm">
|
||||||
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
|
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
|
||||||
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || '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>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
|
||||||
<p><strong>Pièces principales:</strong> {{ type.machinePieces?.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>
|
<p v-if="type.description"><strong>Description:</strong> {{ type.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,4 +20,4 @@ defineProps({
|
|||||||
required: true
|
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>
|
|
||||||
131
app/composables/useComponentModels.js
Normal file
131
app/composables/useComponentModels.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
|
const componentModelsBuckets = ref({})
|
||||||
|
const loadingComponentModels = ref(false)
|
||||||
|
|
||||||
|
export function useComponentModels() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
const loadComponentModels = async (typeComposantId) => {
|
||||||
|
loadingComponentModels.value = true
|
||||||
|
try {
|
||||||
|
const query = typeComposantId ? `?typeComposantId=${encodeURIComponent(typeComposantId)}` : ''
|
||||||
|
const result = await get(`/types/composants/models${query}`)
|
||||||
|
if (result.success) {
|
||||||
|
const key = typeComposantId || '__all__'
|
||||||
|
componentModelsBuckets.value = {
|
||||||
|
...componentModelsBuckets.value,
|
||||||
|
[key]: result.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Impossible de charger les modèles de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createComponentModel = async (payload) => {
|
||||||
|
loadingComponentModels.value = true
|
||||||
|
try {
|
||||||
|
const result = await post('/types/composants/models', payload)
|
||||||
|
if (result.success) {
|
||||||
|
const key = result.data?.typeComposantId || '__all__'
|
||||||
|
const bucket = componentModelsBuckets.value[key] || []
|
||||||
|
componentModelsBuckets.value = {
|
||||||
|
...componentModelsBuckets.value,
|
||||||
|
[key]: [...bucket, result.data],
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle de composant "${result.data.name}" créé`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la création du modèle de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateComponentModel = async (id, payload) => {
|
||||||
|
loadingComponentModels.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/types/composants/models/${id}`, payload)
|
||||||
|
if (result.success) {
|
||||||
|
const key = result.data?.typeComposantId || '__all__'
|
||||||
|
const bucket = componentModelsBuckets.value[key] || []
|
||||||
|
const updatedBucket = bucket.map((model) =>
|
||||||
|
model.id === id ? result.data : model
|
||||||
|
)
|
||||||
|
componentModelsBuckets.value = {
|
||||||
|
...componentModelsBuckets.value,
|
||||||
|
[key]: updatedBucket,
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle de composant "${result.data.name}" mis à jour`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la mise à jour du modèle de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComponentModel = async (id) => {
|
||||||
|
loadingComponentModels.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/types/composants/models/${id}`)
|
||||||
|
if (result.success) {
|
||||||
|
const updatedBuckets = {}
|
||||||
|
for (const [key, bucket] of Object.entries(componentModelsBuckets.value)) {
|
||||||
|
updatedBuckets[key] = bucket.filter((model) => model.id !== id)
|
||||||
|
}
|
||||||
|
componentModelsBuckets.value = updatedBuckets
|
||||||
|
showSuccess('Modèle de composant supprimé')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la suppression du modèle de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allComponentModels = computed(() => {
|
||||||
|
return Object.values(componentModelsBuckets.value).reduce((acc, bucket) => {
|
||||||
|
bucket.forEach((model) => {
|
||||||
|
if (!acc.find((existing) => existing.id === model.id)) {
|
||||||
|
acc.push(model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const getComponentModelsForType = (typeComposantId) => {
|
||||||
|
return componentModelsBuckets.value[typeComposantId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getComponentModels = () => allComponentModels.value
|
||||||
|
const isComponentModelLoading = () => loadingComponentModels.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
componentModels: allComponentModels,
|
||||||
|
componentModelsBuckets,
|
||||||
|
loadingComponentModels,
|
||||||
|
loadComponentModels,
|
||||||
|
createComponentModel,
|
||||||
|
updateComponentModel,
|
||||||
|
deleteComponentModel,
|
||||||
|
getComponentModels,
|
||||||
|
getComponentModelsForType,
|
||||||
|
isComponentModelLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/composables/useComponentTypes.js
Normal file
95
app/composables/useComponentTypes.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
|
const componentTypes = ref([])
|
||||||
|
const loadingComponentTypes = ref(false)
|
||||||
|
|
||||||
|
export function useComponentTypes() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
const loadComponentTypes = async () => {
|
||||||
|
loadingComponentTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await get('/types/composants')
|
||||||
|
if (result.success) {
|
||||||
|
componentTypes.value = result.data
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Impossible de charger les types de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createComponentType = async (payload) => {
|
||||||
|
loadingComponentTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await post('/types/composants', payload)
|
||||||
|
if (result.success) {
|
||||||
|
componentTypes.value.push(result.data)
|
||||||
|
showSuccess(`Type de composant "${result.data.name}" créé`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la création du type de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateComponentType = async (id, payload) => {
|
||||||
|
loadingComponentTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/types/composants/${id}`, payload)
|
||||||
|
if (result.success) {
|
||||||
|
const index = componentTypes.value.findIndex((type) => type.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
componentTypes.value[index] = result.data
|
||||||
|
}
|
||||||
|
showSuccess(`Type de composant "${result.data.name}" mis à jour`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la mise à jour du type de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComponentType = async (id) => {
|
||||||
|
loadingComponentTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/types/composants/${id}`)
|
||||||
|
if (result.success) {
|
||||||
|
componentTypes.value = componentTypes.value.filter((type) => type.id !== id)
|
||||||
|
showSuccess('Type de composant supprimé')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la suppression du type de composant: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingComponentTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getComponentTypes = () => componentTypes.value
|
||||||
|
const isComponentTypeLoading = () => loadingComponentTypes.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
componentTypes,
|
||||||
|
loadingComponentTypes,
|
||||||
|
loadComponentTypes,
|
||||||
|
createComponentType,
|
||||||
|
updateComponentType,
|
||||||
|
deleteComponentType,
|
||||||
|
getComponentTypes,
|
||||||
|
isComponentTypeLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,654 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
// Types de machines prédéfinis avec structure hiérarchique
|
|
||||||
const machineTypes = ref([
|
|
||||||
// Machines de production
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Presse hydraulique',
|
|
||||||
category: 'Production',
|
|
||||||
description: 'Machine de formage par compression hydraulique',
|
|
||||||
maintenanceFrequency: 'Mensuelle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Système hydraulique',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Pompe hydraulique',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Rotor' },
|
|
||||||
{ name: 'Stator' },
|
|
||||||
{ name: 'Joint d\'étanchéité' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cylindre principal',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Piston' },
|
|
||||||
{ name: 'Tige' },
|
|
||||||
{ name: 'Joint de piston' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Soupapes de sécurité',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Soupape de surpression' },
|
|
||||||
{ name: 'Soupape de décharge' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système mécanique',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Banc de machine',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Poutre supérieure' },
|
|
||||||
{ name: 'Poutre inférieure' },
|
|
||||||
{ name: 'Colonnes' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de guidage',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Rails de guidage' },
|
|
||||||
{ name: 'Patins' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Pompe hydraulique', 'Cylindre principal', 'Soupapes de sécurité'],
|
|
||||||
specifications: {
|
|
||||||
force: '100-5000 tonnes',
|
|
||||||
course: '100-800 mm',
|
|
||||||
vitesse: '5-50 mm/s'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Convoyeur à bande',
|
|
||||||
category: 'Production',
|
|
||||||
description: 'Système de transport continu de matériaux',
|
|
||||||
maintenanceFrequency: 'Hebdomadaire',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Système de transport',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Bande transporteuse',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Carcasse' },
|
|
||||||
{ name: 'Revêtement' },
|
|
||||||
{ name: 'Armature' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Rouleaux',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Rouleaux porteurs' },
|
|
||||||
{ name: 'Rouleaux de retour' },
|
|
||||||
{ name: 'Rouleaux d\'impact' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système d\'entraînement',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Moteur d\'entraînement',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Rotor' },
|
|
||||||
{ name: 'Stator' },
|
|
||||||
{ name: 'Roulements' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Réducteur',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Engrenages' },
|
|
||||||
{ name: 'Arbre de sortie' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Bande transporteuse', 'Rouleaux', 'Moteur d\'entraînement'],
|
|
||||||
specifications: {
|
|
||||||
longueur: '5-100 m',
|
|
||||||
largeur: '400-2000 mm',
|
|
||||||
vitesse: '0.5-3 m/s'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Robot de soudage',
|
|
||||||
category: 'Production',
|
|
||||||
description: 'Robot industriel pour opérations de soudage automatisé',
|
|
||||||
maintenanceFrequency: 'Trimestrielle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Bras robotique',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Base rotative',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Moteur de rotation' },
|
|
||||||
{ name: 'Réducteur' },
|
|
||||||
{ name: 'Capteur de position' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Bras articulé',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Joint 1' },
|
|
||||||
{ name: 'Joint 2' },
|
|
||||||
{ name: 'Joint 3' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de soudage',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Torche de soudage',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Électrode' },
|
|
||||||
{ name: 'Gainage' },
|
|
||||||
{ name: 'Conduit de gaz' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Alimentation électrique',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Transformateur' },
|
|
||||||
{ name: 'Régulateur de courant' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Bras robotique', 'Torche de soudage', 'Contrôleur'],
|
|
||||||
specifications: {
|
|
||||||
portée: '1.5-3 m',
|
|
||||||
charge: '5-200 kg',
|
|
||||||
précision: '±0.1 mm'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Machines de transformation
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Tour CNC',
|
|
||||||
category: 'Transformation',
|
|
||||||
description: 'Machine-outil pour usinage de pièces cylindriques',
|
|
||||||
maintenanceFrequency: 'Mensuelle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Banc de machine',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Banc principal',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Poutre' },
|
|
||||||
{ name: 'Guidages' },
|
|
||||||
{ name: 'Vis à billes' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de broche',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Broche principale',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Arbre de broche' },
|
|
||||||
{ name: 'Roulements' },
|
|
||||||
{ name: 'Moteur de broche' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Contre-pointe',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Pointe' },
|
|
||||||
{ name: 'Cylindre' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Banc de machine', 'Broche', 'Contre-pointe'],
|
|
||||||
specifications: {
|
|
||||||
diamètre: '200-1000 mm',
|
|
||||||
longueur: '500-3000 mm',
|
|
||||||
puissance: '5-50 kW'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'Fraiseuse',
|
|
||||||
category: 'Transformation',
|
|
||||||
description: 'Machine-outil pour usinage par enlèvement de copeaux',
|
|
||||||
maintenanceFrequency: 'Mensuelle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Table de travail',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Table X',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Guidages X' },
|
|
||||||
{ name: 'Vis à billes X' },
|
|
||||||
{ name: 'Moteur X' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Table Y',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Guidages Y' },
|
|
||||||
{ name: 'Vis à billes Y' },
|
|
||||||
{ name: 'Moteur Y' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de broche',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Broche verticale',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Arbre de broche' },
|
|
||||||
{ name: 'Roulements' },
|
|
||||||
{ name: 'Moteur de broche' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Table de travail', 'Broche', 'Guidages'],
|
|
||||||
specifications: {
|
|
||||||
courseX: '400-2000 mm',
|
|
||||||
courseY: '300-1500 mm',
|
|
||||||
courseZ: '200-800 mm'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Machines de manutention
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: 'Pont roulant',
|
|
||||||
category: 'Manutention',
|
|
||||||
description: 'Système de levage et transport de charges lourdes',
|
|
||||||
maintenanceFrequency: 'Mensuelle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Poutre principale',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Poutre de roulement',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Profilé principal' },
|
|
||||||
{ name: 'Rails de roulement' },
|
|
||||||
{ name: 'Renforts' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de palans',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Palans',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Moteur de levage' },
|
|
||||||
{ name: 'Treuil' },
|
|
||||||
{ name: 'Crochet' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de translation',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Moteur de translation' },
|
|
||||||
{ name: 'Roues de roulement' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Poutre principale', 'Palans', 'Rails de guidage'],
|
|
||||||
specifications: {
|
|
||||||
capacité: '1-500 tonnes',
|
|
||||||
portée: '5-50 m',
|
|
||||||
hauteur: '3-20 m'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
name: 'Chariot élévateur',
|
|
||||||
category: 'Manutention',
|
|
||||||
description: 'Véhicule de manutention pour charges palettisées',
|
|
||||||
maintenanceFrequency: 'Hebdomadaire',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Système de levage',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Mast',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Mât extérieur' },
|
|
||||||
{ name: 'Mât intérieur' },
|
|
||||||
{ name: 'Cylindres de levage' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Fourches',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Fourche gauche' },
|
|
||||||
{ name: 'Fourche droite' },
|
|
||||||
{ name: 'Système de réglage' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Groupe motopropulseur',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Moteur',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Bloc moteur' },
|
|
||||||
{ name: 'Système d\'injection' },
|
|
||||||
{ name: 'Système de refroidissement' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transmission',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Boîte de vitesses' },
|
|
||||||
{ name: 'Arbre de transmission' },
|
|
||||||
{ name: 'Pont arrière' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Mast', 'Fourches', 'Moteur'],
|
|
||||||
specifications: {
|
|
||||||
capacité: '1-10 tonnes',
|
|
||||||
hauteur: '3-6 m',
|
|
||||||
type: 'Électrique/Diesel/Gaz'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Machines de traitement
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
name: 'Compresseur d\'air',
|
|
||||||
category: 'Traitement',
|
|
||||||
description: 'Générateur d\'air comprimé pour applications industrielles',
|
|
||||||
maintenanceFrequency: 'Hebdomadaire',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Système de compression',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Compresseur',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Pistons' },
|
|
||||||
{ name: 'Cylindres' },
|
|
||||||
{ name: 'Soupapes' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Réservoir',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Cuve' },
|
|
||||||
{ name: 'Soupape de sécurité' },
|
|
||||||
{ name: 'Manomètre' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de filtration',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Filtres',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Filtre à air' },
|
|
||||||
{ name: 'Filtre à huile' },
|
|
||||||
{ name: 'Séparateur d\'eau' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Compresseur', 'Réservoir', 'Filtres'],
|
|
||||||
specifications: {
|
|
||||||
débit: '100-10000 L/min',
|
|
||||||
pression: '7-10 bar',
|
|
||||||
puissance: '5-500 kW'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
name: 'Pompe hydraulique',
|
|
||||||
category: 'Traitement',
|
|
||||||
description: 'Pompe pour circuits hydrauliques industriels',
|
|
||||||
maintenanceFrequency: 'Mensuelle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Système de pompage',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Rotor',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Ailettes' },
|
|
||||||
{ name: 'Arbre' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Stator',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Corps' },
|
|
||||||
{ name: 'Chambres' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système d\'étanchéité',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Joint d\'étanchéité',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Joint radial' },
|
|
||||||
{ name: 'Joint axial' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Rotor', 'Stator', 'Joint d\'étanchéité'],
|
|
||||||
specifications: {
|
|
||||||
débit: '10-500 L/min',
|
|
||||||
pression: '50-350 bar',
|
|
||||||
type: 'Piston/Palette/Engrenage'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Machines de contrôle
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
name: 'Capteur de température',
|
|
||||||
category: 'Contrôle',
|
|
||||||
description: 'Instrument de mesure de température industrielle',
|
|
||||||
maintenanceFrequency: 'Annuelle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Système de mesure',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Élément sensible',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Fil de platine' },
|
|
||||||
{ name: 'Isolation' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Câblage',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Fils de connexion' },
|
|
||||||
{ name: 'Gaine de protection' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de transmission',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Transmetteur',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Circuit électronique' },
|
|
||||||
{ name: 'Affichage' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Élément sensible', 'Câblage', 'Transmetteur'],
|
|
||||||
specifications: {
|
|
||||||
plage: '-50 à +500°C',
|
|
||||||
précision: '±0.5°C',
|
|
||||||
type: 'PT100/PT1000/Thermocouple'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
name: 'Manomètre',
|
|
||||||
category: 'Contrôle',
|
|
||||||
description: 'Instrument de mesure de pression',
|
|
||||||
maintenanceFrequency: 'Annuelle',
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
name: 'Système de mesure',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Tube de Bourdon',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Tube' },
|
|
||||||
{ name: 'Extrémité fixe' },
|
|
||||||
{ name: 'Extrémité mobile' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cadran',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Échelle' },
|
|
||||||
{ name: 'Aiguille' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Système de connexion',
|
|
||||||
subComponents: [
|
|
||||||
{
|
|
||||||
name: 'Joint',
|
|
||||||
subComponents: [
|
|
||||||
{ name: 'Joint d\'étanchéité' },
|
|
||||||
{ name: 'Filetage' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
criticalParts: ['Tube de Bourdon', 'Cadran', 'Joint'],
|
|
||||||
specifications: {
|
|
||||||
plage: '0-600 bar',
|
|
||||||
précision: '±1%',
|
|
||||||
type: 'Analogique/Numérique'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Catégories disponibles
|
|
||||||
const categories = ref([
|
|
||||||
'Production',
|
|
||||||
'Transformation',
|
|
||||||
'Manutention',
|
|
||||||
'Traitement',
|
|
||||||
'Contrôle'
|
|
||||||
])
|
|
||||||
|
|
||||||
export function useMachineTypes() {
|
|
||||||
const getTypes = () => machineTypes.value
|
|
||||||
|
|
||||||
const getTypeById = (id) => {
|
|
||||||
return machineTypes.value.find(type => type.id === id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTypesByCategory = (category) => {
|
|
||||||
return machineTypes.value.filter(type => type.category === category)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategories = () => categories.value
|
|
||||||
|
|
||||||
const addType = (newType) => {
|
|
||||||
const id = Math.max(...machineTypes.value.map(t => t.id)) + 1
|
|
||||||
machineTypes.value.push({
|
|
||||||
id,
|
|
||||||
...newType
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateType = (id, updatedType) => {
|
|
||||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
|
||||||
if (index !== -1) {
|
|
||||||
machineTypes.value[index] = { ...machineTypes.value[index], ...updatedType }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteType = (id) => {
|
|
||||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
|
||||||
if (index !== -1) {
|
|
||||||
machineTypes.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Méthodes pour la hiérarchie
|
|
||||||
const flattenComponents = (components, level = 0) => {
|
|
||||||
let flat = []
|
|
||||||
components.forEach(comp => {
|
|
||||||
flat.push({ ...comp, level })
|
|
||||||
if (comp.subComponents && comp.subComponents.length > 0) {
|
|
||||||
flat = flat.concat(flattenComponents(comp.subComponents, level + 1))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return flat
|
|
||||||
}
|
|
||||||
|
|
||||||
const getComponentHierarchy = (typeId) => {
|
|
||||||
const type = getTypeById(typeId)
|
|
||||||
if (!type || !type.components) return []
|
|
||||||
return flattenComponents(type.components)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getTypes,
|
|
||||||
getTypeById,
|
|
||||||
getTypesByCategory,
|
|
||||||
getCategories,
|
|
||||||
addType,
|
|
||||||
updateType,
|
|
||||||
deleteType,
|
|
||||||
flattenComponents,
|
|
||||||
getComponentHierarchy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
131
app/composables/usePieceModels.js
Normal file
131
app/composables/usePieceModels.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
|
const pieceModelsBuckets = ref({})
|
||||||
|
const loadingPieceModels = ref(false)
|
||||||
|
|
||||||
|
export function usePieceModels() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
const loadPieceModels = async (typePieceId) => {
|
||||||
|
loadingPieceModels.value = true
|
||||||
|
try {
|
||||||
|
const query = typePieceId ? `?typePieceId=${encodeURIComponent(typePieceId)}` : ''
|
||||||
|
const result = await get(`/types/pieces/models${query}`)
|
||||||
|
if (result.success) {
|
||||||
|
const key = typePieceId || '__all__'
|
||||||
|
pieceModelsBuckets.value = {
|
||||||
|
...pieceModelsBuckets.value,
|
||||||
|
[key]: result.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Impossible de charger les modèles de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPieceModel = async (payload) => {
|
||||||
|
loadingPieceModels.value = true
|
||||||
|
try {
|
||||||
|
const result = await post('/types/pieces/models', payload)
|
||||||
|
if (result.success) {
|
||||||
|
const key = result.data?.typePieceId || '__all__'
|
||||||
|
const bucket = pieceModelsBuckets.value[key] || []
|
||||||
|
pieceModelsBuckets.value = {
|
||||||
|
...pieceModelsBuckets.value,
|
||||||
|
[key]: [...bucket, result.data],
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle de pièce "${result.data.name}" créé`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la création du modèle de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePieceModel = async (id, payload) => {
|
||||||
|
loadingPieceModels.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/types/pieces/models/${id}`, payload)
|
||||||
|
if (result.success) {
|
||||||
|
const key = result.data?.typePieceId || '__all__'
|
||||||
|
const bucket = pieceModelsBuckets.value[key] || []
|
||||||
|
const updatedBucket = bucket.map((model) =>
|
||||||
|
model.id === id ? result.data : model
|
||||||
|
)
|
||||||
|
pieceModelsBuckets.value = {
|
||||||
|
...pieceModelsBuckets.value,
|
||||||
|
[key]: updatedBucket,
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle de pièce "${result.data.name}" mis à jour`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la mise à jour du modèle de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePieceModel = async (id) => {
|
||||||
|
loadingPieceModels.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/types/pieces/models/${id}`)
|
||||||
|
if (result.success) {
|
||||||
|
const updatedBuckets = {}
|
||||||
|
for (const [key, bucket] of Object.entries(pieceModelsBuckets.value)) {
|
||||||
|
updatedBuckets[key] = bucket.filter((model) => model.id !== id)
|
||||||
|
}
|
||||||
|
pieceModelsBuckets.value = updatedBuckets
|
||||||
|
showSuccess('Modèle de pièce supprimé')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la suppression du modèle de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPieceModels = computed(() => {
|
||||||
|
return Object.values(pieceModelsBuckets.value).reduce((acc, bucket) => {
|
||||||
|
bucket.forEach((model) => {
|
||||||
|
if (!acc.find((existing) => existing.id === model.id)) {
|
||||||
|
acc.push(model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPieceModelsForType = (typePieceId) => {
|
||||||
|
return pieceModelsBuckets.value[typePieceId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPieceModels = () => allPieceModels.value
|
||||||
|
const isPieceModelLoading = () => loadingPieceModels.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
pieceModels: allPieceModels,
|
||||||
|
pieceModelsBuckets,
|
||||||
|
loadingPieceModels,
|
||||||
|
loadPieceModels,
|
||||||
|
createPieceModel,
|
||||||
|
updatePieceModel,
|
||||||
|
deletePieceModel,
|
||||||
|
getPieceModels,
|
||||||
|
getPieceModelsForType,
|
||||||
|
isPieceModelLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/composables/usePieceTypes.js
Normal file
95
app/composables/usePieceTypes.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
|
const pieceTypes = ref([])
|
||||||
|
const loadingPieceTypes = ref(false)
|
||||||
|
|
||||||
|
export function usePieceTypes() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
const loadPieceTypes = async () => {
|
||||||
|
loadingPieceTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await get('/types/pieces')
|
||||||
|
if (result.success) {
|
||||||
|
pieceTypes.value = result.data
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Impossible de charger les types de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPieceType = async (payload) => {
|
||||||
|
loadingPieceTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await post('/types/pieces', payload)
|
||||||
|
if (result.success) {
|
||||||
|
pieceTypes.value.push(result.data)
|
||||||
|
showSuccess(`Type de pièce "${result.data.name}" créé`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la création du type de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePieceType = async (id, payload) => {
|
||||||
|
loadingPieceTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/types/pieces/${id}`, payload)
|
||||||
|
if (result.success) {
|
||||||
|
const index = pieceTypes.value.findIndex((type) => type.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
pieceTypes.value[index] = result.data
|
||||||
|
}
|
||||||
|
showSuccess(`Type de pièce "${result.data.name}" mis à jour`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la mise à jour du type de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePieceType = async (id) => {
|
||||||
|
loadingPieceTypes.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/types/pieces/${id}`)
|
||||||
|
if (result.success) {
|
||||||
|
pieceTypes.value = pieceTypes.value.filter((type) => type.id !== id)
|
||||||
|
showSuccess('Type de pièce supprimé')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Erreur lors de la suppression du type de pièce: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loadingPieceTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPieceTypes = () => pieceTypes.value
|
||||||
|
const isPieceTypeLoading = () => loadingPieceTypes.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
pieceTypes,
|
||||||
|
loadingPieceTypes,
|
||||||
|
loadPieceTypes,
|
||||||
|
createPieceType,
|
||||||
|
updatePieceType,
|
||||||
|
deletePieceType,
|
||||||
|
getPieceTypes,
|
||||||
|
isPieceTypeLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,13 +51,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-600 line-clamp-3">{{ type.description || 'Aucune description' }}</p>
|
<p class="text-sm text-gray-600 line-clamp-3">{{ type.description || 'Aucune description' }}</p>
|
||||||
<div class="text-xs text-gray-500 flex items-center gap-2">
|
<div class="text-xs text-gray-500 flex items-center gap-2">
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<IconLucideClipboardList class="h-4 w-4" aria-hidden="true" />
|
<IconLucideClipboardList class="h-4 w-4" aria-hidden="true" />
|
||||||
{{ type.components?.length || 0 }} composant(s)
|
{{ type.componentRequirements?.length || 0 }} famille(s)
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<IconLucideList class="h-4 w-4" aria-hidden="true" />
|
<IconLucideList class="h-4 w-4" aria-hidden="true" />
|
||||||
{{ type.machinePieces?.length || 0 }} pièce(s) machine
|
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,8 +93,8 @@ const createEmptyType = () => ({
|
|||||||
category: '',
|
category: '',
|
||||||
maintenanceFrequency: '',
|
maintenanceFrequency: '',
|
||||||
customFields: [],
|
customFields: [],
|
||||||
machinePieces: [],
|
componentRequirements: [],
|
||||||
components: []
|
pieceRequirements: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const draftType = ref(createEmptyType())
|
const draftType = ref(createEmptyType())
|
||||||
@@ -141,36 +141,36 @@ const normalizeCustomFields = (fields = []) =>
|
|||||||
options: parseOptions(field)
|
options: parseOptions(field)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const normalizePrice = (value) => {
|
const toIntegerOrNull = (value, fallback = null) => {
|
||||||
if (value === undefined || value === null || value === '') return null
|
if (value === '' || value === undefined || value === null) {
|
||||||
const num = Number(value)
|
return fallback
|
||||||
return Number.isFinite(num) ? num : null
|
}
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizePieces = (pieces = []) =>
|
const normalizeComponentRequirements = (requirements = []) =>
|
||||||
pieces
|
requirements
|
||||||
.filter(piece => piece?.name && piece.name.trim() !== '')
|
.filter(req => req?.typeComposantId)
|
||||||
.map(piece => ({
|
.map(req => ({
|
||||||
name: piece.name,
|
typeComposantId: req.typeComposantId,
|
||||||
reference: piece.reference || '',
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
constructeur: piece.constructeur || '',
|
minCount: toIntegerOrNull(req.minCount, 1),
|
||||||
emplacement: piece.emplacement || '',
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
prix: normalizePrice(piece.prix),
|
required: req.required ?? true,
|
||||||
customFields: normalizeCustomFields(piece.customFields || [])
|
allowNewModels: req.allowNewModels ?? true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const normalizeComponents = (components = []) =>
|
const normalizePieceRequirements = (requirements = []) =>
|
||||||
components
|
requirements
|
||||||
.filter(component => component?.name && component.name.trim() !== '')
|
.filter(req => req?.typePieceId)
|
||||||
.map(component => ({
|
.map(req => ({
|
||||||
name: component.name,
|
typePieceId: req.typePieceId,
|
||||||
reference: component.reference || '',
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
constructeur: component.constructeur || '',
|
minCount: toIntegerOrNull(req.minCount, 0),
|
||||||
emplacement: component.emplacement || '',
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
prix: normalizePrice(component.prix),
|
required: req.required ?? false,
|
||||||
customFields: normalizeCustomFields(component.customFields || []),
|
allowNewModels: req.allowNewModels ?? true,
|
||||||
pieces: normalizePieces(component.pieces || []),
|
|
||||||
subComponents: normalizeComponents(component.subComponents || [])
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const buildPayload = (typeData) => ({
|
const buildPayload = (typeData) => ({
|
||||||
@@ -179,8 +179,8 @@ const buildPayload = (typeData) => ({
|
|||||||
category: typeData.category,
|
category: typeData.category,
|
||||||
maintenanceFrequency: typeData.maintenanceFrequency,
|
maintenanceFrequency: typeData.maintenanceFrequency,
|
||||||
customFields: normalizeCustomFields(typeData.customFields),
|
customFields: normalizeCustomFields(typeData.customFields),
|
||||||
machinePieces: normalizePieces(typeData.machinePieces),
|
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
|
||||||
components: normalizeComponents(typeData.components)
|
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements)
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
|||||||
@@ -372,12 +372,12 @@
|
|||||||
<h4 class="font-semibold text-sm mb-2">Structure du type sélectionné :</h4>
|
<h4 class="font-semibold text-sm mb-2">Structure du type sélectionné :</h4>
|
||||||
<div class="text-xs space-y-1">
|
<div class="text-xs space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium">Composants :</span>
|
<span class="font-medium">Familles de composants :</span>
|
||||||
<span class="badge badge-sm">{{ selectedMachineType.components?.length || 0 }}</span>
|
<span class="badge badge-sm">{{ selectedMachineType.componentRequirements?.length || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium">Pièces critiques :</span>
|
<span class="font-medium">Groupes de pièces :</span>
|
||||||
<span class="badge badge-sm">{{ selectedMachineType.criticalParts?.length || 0 }}</span>
|
<span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium">Catégorie :</span>
|
<span class="font-medium">Catégorie :</span>
|
||||||
|
|||||||
@@ -300,6 +300,99 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Requirement Summary -->
|
||||||
|
<div
|
||||||
|
v-if="componentRequirementGroups.length || pieceRequirementGroups.length"
|
||||||
|
class="card bg-base-100 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">Structure sélectionnée</h2>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Synthèse des familles définies dans le type et des modèles utilisés pour cette machine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="componentRequirementGroups.length" class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">Composants</h3>
|
||||||
|
<div
|
||||||
|
v-for="group in componentRequirementGroups"
|
||||||
|
:key="group.requirement.id"
|
||||||
|
class="rounded-lg border border-base-200 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-sm">
|
||||||
|
{{ group.requirement.label || group.requirement.typeComposant?.name || 'Famille de composants' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ group.requirement.typeComposant?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline badge-sm">{{ group.components.length }} composant(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="group.components.length" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="component in group.components"
|
||||||
|
:key="component.id"
|
||||||
|
class="flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ component.name }}</span>
|
||||||
|
<span v-if="component.composantModel" class="badge badge-sm badge-primary badge-outline">
|
||||||
|
Modèle : {{ component.composantModel.name }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="badge badge-sm badge-outline">Défini manuellement</span>
|
||||||
|
<span v-if="component.parentComposantId" class="text-xs text-gray-500">
|
||||||
|
(Sous-composant)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">Aucun composant rattaché à ce groupe.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pieceRequirementGroups.length" class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">Pièces principales</h3>
|
||||||
|
<div
|
||||||
|
v-for="group in pieceRequirementGroups"
|
||||||
|
:key="group.requirement.id"
|
||||||
|
class="rounded-lg border border-base-200 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-sm">
|
||||||
|
{{ group.requirement.label || group.requirement.typePiece?.name || 'Groupe de pièces' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ group.requirement.typePiece?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline badge-sm">{{ group.pieces.length }} pièce(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="group.pieces.length" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="piece in group.pieces"
|
||||||
|
:key="piece.id"
|
||||||
|
class="flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ piece.name }}</span>
|
||||||
|
<span v-if="piece.pieceModel" class="badge badge-sm badge-primary badge-outline">
|
||||||
|
Modèle : {{ piece.pieceModel.name }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="badge badge-sm badge-outline">Définie manuellement</span>
|
||||||
|
<span v-if="piece.parentComponentName" class="text-xs text-gray-500">
|
||||||
|
(Rattachée à {{ piece.parentComponentName }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">Aucune pièce rattachée à ce groupe.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Components Section -->
|
<!-- Components Section -->
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -327,8 +420,14 @@
|
|||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
:collapse-all="componentsCollapsed"
|
:collapse-all="componentsCollapsed"
|
||||||
:toggle-token="collapseToggleToken"
|
:toggle-token="collapseToggleToken"
|
||||||
|
:component-model-options-provider="getComponentModelOptions"
|
||||||
|
:piece-model-options-provider="getPieceModelOptions"
|
||||||
@update="updateComponent"
|
@update="updateComponent"
|
||||||
@edit-piece="updatePieceFromComponent"
|
@edit-piece="updatePieceFromComponent"
|
||||||
|
@assign-model="assignComponentModel"
|
||||||
|
@assign-piece-model="assignPieceModel"
|
||||||
|
@custom-field-update="updatePieceCustomField"
|
||||||
|
@create-model-from-component="openSaveComponentModelModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,9 +445,11 @@
|
|||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
:piece="piece"
|
:piece="piece"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
|
:piece-model-options="getPieceModelOptions(piece)"
|
||||||
@update="updatePieceInfo"
|
@update="updatePieceInfo"
|
||||||
@edit="editPiece"
|
@edit="editPiece"
|
||||||
@custom-field-update="updatePieceCustomField"
|
@custom-field-update="updatePieceCustomField"
|
||||||
|
@assign-model="assignPieceModel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,7 +479,73 @@
|
|||||||
@select-all="setAllPrintSelection(true)"
|
@select-all="setAllPrintSelection(true)"
|
||||||
@deselect-all="setAllPrintSelection(false)"
|
@deselect-all="setAllPrintSelection(false)"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
|
<div v-if="saveComponentAsModelModal.open" class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="font-bold text-lg">
|
||||||
|
Enregistrer « {{ saveComponentAsModelModal.component?.name || 'Composant' }} » comme modèle
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-4">
|
||||||
|
Le modèle sera associé au type {{ saveComponentAsModelModal.typeLabel || 'de composant' }} et
|
||||||
|
pourra être réutilisé lors de la configuration d'autres machines.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="submitSaveComponentModelModal">
|
||||||
|
<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 modèle</span></label>
|
||||||
|
<input
|
||||||
|
v-model="saveComponentAsModelModal.form.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Type de composant</span></label>
|
||||||
|
<select
|
||||||
|
v-model="saveComponentAsModelModal.form.typeComposantId"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
required
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<option :value="saveComponentAsModelModal.form.typeComposantId">
|
||||||
|
{{ saveComponentAsModelModal.typeLabel || 'Type de composant' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Description</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="saveComponentAsModelModal.form.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Notes optionnelles"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-base-200/60 border border-base-200 rounded-lg p-3 space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span class="badge badge-outline badge-sm">{{ saveComponentStructureSummary }}</span>
|
||||||
|
</div>
|
||||||
|
<ModelStructureViewer :structure="saveComponentAsModelModal.structure" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-outline" @click="closeSaveComponentModelModal">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :class="{ loading: saveComponentAsModelModal.submitting }">
|
||||||
|
Sauvegarder et assigner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed, watch, nextTick } from 'vue'
|
import { ref, reactive, onMounted, computed, watch, nextTick } from 'vue'
|
||||||
@@ -405,6 +572,14 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
|
|||||||
import IconLucideEye from '~icons/lucide/eye'
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
import IconLucidePrinter from '~icons/lucide/printer'
|
import IconLucidePrinter from '~icons/lucide/printer'
|
||||||
|
import ModelStructureViewer from '~/components/ModelStructureViewer.vue'
|
||||||
|
import { useComponentModels } from '~/composables/useComponentModels'
|
||||||
|
import { usePieceModels } from '~/composables/usePieceModels'
|
||||||
|
import {
|
||||||
|
defaultStructure,
|
||||||
|
extractStructureFromComponent,
|
||||||
|
formatStructurePreview,
|
||||||
|
} from '~/shared/modelUtils'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const machineId = route.params.id
|
const machineId = route.params.id
|
||||||
@@ -433,6 +608,16 @@ const {
|
|||||||
loadDocumentsByComponent,
|
loadDocumentsByComponent,
|
||||||
loadDocumentsByPiece
|
loadDocumentsByPiece
|
||||||
} = useDocuments()
|
} = useDocuments()
|
||||||
|
const {
|
||||||
|
loadComponentModels,
|
||||||
|
getComponentModelsForType,
|
||||||
|
createComponentModel,
|
||||||
|
} = useComponentModels()
|
||||||
|
const {
|
||||||
|
loadPieceModels,
|
||||||
|
getPieceModelsForType,
|
||||||
|
} = usePieceModels()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -473,11 +658,62 @@ const printSelection = reactive({
|
|||||||
pieces: {},
|
pieces: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const saveComponentAsModelModal = reactive({
|
||||||
|
open: false,
|
||||||
|
submitting: false,
|
||||||
|
component: null,
|
||||||
|
typeLabel: '',
|
||||||
|
structure: defaultStructure(),
|
||||||
|
form: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typeComposantId: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveComponentStructureSummary = computed(() =>
|
||||||
|
formatStructurePreview(saveComponentAsModelModal.structure)
|
||||||
|
)
|
||||||
|
|
||||||
const handleMachineConstructeurChange = async (value) => {
|
const handleMachineConstructeurChange = async (value) => {
|
||||||
machineConstructeurId.value = value
|
machineConstructeurId.value = value
|
||||||
await updateMachineInfo()
|
await updateMachineInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSaveComponentModelModal = (component) => {
|
||||||
|
if (!component) return
|
||||||
|
const requirement = component.typeMachineComponentRequirement || null
|
||||||
|
const typeComposantId = requirement?.typeComposantId
|
||||||
|
|| component.typeComposantId
|
||||||
|
|| component.typeComposant?.id
|
||||||
|
|| ''
|
||||||
|
|
||||||
|
saveComponentAsModelModal.open = true
|
||||||
|
saveComponentAsModelModal.submitting = false
|
||||||
|
saveComponentAsModelModal.component = component
|
||||||
|
saveComponentAsModelModal.typeLabel = requirement?.typeComposant?.name
|
||||||
|
|| component.typeComposant?.name
|
||||||
|
|| 'Composant'
|
||||||
|
saveComponentAsModelModal.form = {
|
||||||
|
name: component.name || '',
|
||||||
|
description: component.description || '',
|
||||||
|
typeComposantId,
|
||||||
|
}
|
||||||
|
saveComponentAsModelModal.structure = extractStructureFromComponent(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSaveComponentModelModal = () => {
|
||||||
|
saveComponentAsModelModal.open = false
|
||||||
|
saveComponentAsModelModal.component = null
|
||||||
|
saveComponentAsModelModal.typeLabel = ''
|
||||||
|
saveComponentAsModelModal.structure = defaultStructure()
|
||||||
|
saveComponentAsModelModal.form = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typeComposantId: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mode d'édition
|
// Mode d'édition
|
||||||
const isEditMode = ref(false)
|
const isEditMode = ref(false)
|
||||||
const debug = ref(false) // Ajout de debug pour afficher les infos de debug
|
const debug = ref(false) // Ajout de debug pour afficher les infos de debug
|
||||||
@@ -521,16 +757,178 @@ const machinePieces = computed(() => {
|
|||||||
const machineDocumentsList = computed(() => machine.value?.documents || [])
|
const machineDocumentsList = computed(() => machine.value?.documents || [])
|
||||||
const documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
const documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||||
|
|
||||||
const allComponents = computed(() => {
|
const flattenComponents = (list = []) => {
|
||||||
return components.value
|
const result = []
|
||||||
})
|
const traverse = (items) => {
|
||||||
|
items.forEach((item) => {
|
||||||
const subComponents = (parentId) => {
|
result.push(item)
|
||||||
return components.value.filter(comp => comp.parentComposantId === parentId)
|
if (item.subComponents && item.subComponents.length) {
|
||||||
|
traverse(item.subComponents)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
traverse(list)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentPieces = (composantId) => {
|
const flattenedComponents = computed(() => flattenComponents(components.value))
|
||||||
return pieces.value.filter(piece => piece.composantId === composantId)
|
|
||||||
|
const preloadModelsForTypeMachine = async (typeMachine) => {
|
||||||
|
if (!typeMachine) return
|
||||||
|
const componentTypeIds = new Set(
|
||||||
|
(typeMachine.componentRequirements || [])
|
||||||
|
.map((requirement) => requirement.typeComposantId)
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
const pieceTypeIds = new Set(
|
||||||
|
(typeMachine.pieceRequirements || [])
|
||||||
|
.map((requirement) => requirement.typePieceId)
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...Array.from(componentTypeIds).map((id) => loadComponentModels(id)),
|
||||||
|
...Array.from(pieceTypeIds).map((id) => loadPieceModels(id)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureModelsForExistingEntities = async () => {
|
||||||
|
const componentTypeIds = new Set()
|
||||||
|
const gatherComponentTypes = (items = []) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
const typeId = item.typeMachineComponentRequirement?.typeComposantId
|
||||||
|
|| item.typeComposantId
|
||||||
|
|| item.typeComposant?.id
|
||||||
|
if (typeId) {
|
||||||
|
componentTypeIds.add(typeId)
|
||||||
|
}
|
||||||
|
if (item.subComponents?.length) {
|
||||||
|
gatherComponentTypes(item.subComponents)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
gatherComponentTypes(components.value)
|
||||||
|
|
||||||
|
const pieceTypeIds = new Set()
|
||||||
|
pieces.value.forEach((piece) => {
|
||||||
|
const typeId = piece.typeMachinePieceRequirement?.typePieceId
|
||||||
|
|| piece.typePieceId
|
||||||
|
|| piece.typePiece?.id
|
||||||
|
if (typeId) {
|
||||||
|
pieceTypeIds.add(typeId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...Array.from(componentTypeIds).map((id) => loadComponentModels(id)),
|
||||||
|
...Array.from(pieceTypeIds).map((id) => loadPieceModels(id)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentRequirementGroups = computed(() => {
|
||||||
|
const requirements = machine.value?.typeMachine?.componentRequirements || []
|
||||||
|
if (!requirements.length) return []
|
||||||
|
|
||||||
|
const groups = requirements.map((requirement) => ({
|
||||||
|
requirement,
|
||||||
|
components: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const map = new Map(groups.map((group) => [group.requirement.id, group]))
|
||||||
|
|
||||||
|
flattenedComponents.value.forEach((component) => {
|
||||||
|
const reqId = component.typeMachineComponentRequirementId
|
||||||
|
if (reqId && map.has(reqId)) {
|
||||||
|
map.get(reqId).components.push(component)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieceRequirementGroups = computed(() => {
|
||||||
|
const requirements = machine.value?.typeMachine?.pieceRequirements || []
|
||||||
|
if (!requirements.length) return []
|
||||||
|
|
||||||
|
const groups = requirements.map((requirement) => ({
|
||||||
|
requirement,
|
||||||
|
pieces: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const map = new Map(groups.map((group) => [group.requirement.id, group]))
|
||||||
|
|
||||||
|
const collectPieces = () => {
|
||||||
|
const collected = []
|
||||||
|
|
||||||
|
// Pièces rattachées à la machine directement
|
||||||
|
machinePieces.value.forEach((piece) => {
|
||||||
|
collected.push({ ...piece, parentComponentName: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pièces rattachées aux composants
|
||||||
|
flattenedComponents.value.forEach((component) => {
|
||||||
|
if (component.pieces && component.pieces.length) {
|
||||||
|
component.pieces.forEach((piece) => {
|
||||||
|
collected.push({ ...piece, parentComponentName: component.name })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
|
||||||
|
collectPieces().forEach((piece) => {
|
||||||
|
const reqId = piece.typeMachinePieceRequirementId
|
||||||
|
if (reqId && map.has(reqId)) {
|
||||||
|
map.get(reqId).pieces.push(piece)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const getComponentModelOptions = (entity) => {
|
||||||
|
const requirement = entity?.typeMachineComponentRequirement
|
||||||
|
const typeId = requirement?.typeComposantId
|
||||||
|
|| entity?.typeComposantId
|
||||||
|
|| entity?.typeComposant?.id
|
||||||
|
if (!typeId) return []
|
||||||
|
return getComponentModelsForType(typeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPieceModelOptions = (entity) => {
|
||||||
|
const requirement = entity?.typeMachinePieceRequirement
|
||||||
|
const typeId = requirement?.typePieceId
|
||||||
|
|| entity?.typePieceId
|
||||||
|
|| entity?.typePiece?.id
|
||||||
|
if (!typeId) return []
|
||||||
|
return getPieceModelsForType(typeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const findComponentById = (items, id) => {
|
||||||
|
for (const item of items || []) {
|
||||||
|
if (item.id === id) return item
|
||||||
|
const found = findComponentById(item.subComponents, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const findPieceById = (pieceId) => {
|
||||||
|
const direct = pieces.value.find((piece) => piece.id === pieceId)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const searchInComponents = (items) => {
|
||||||
|
for (const item of items || []) {
|
||||||
|
const match = (item.pieces || []).find((piece) => piece.id === pieceId)
|
||||||
|
if (match) return match
|
||||||
|
const nested = searchInComponents(item.subComponents)
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchInComponents(components.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshMachineDocuments = async () => {
|
const refreshMachineDocuments = async () => {
|
||||||
@@ -756,7 +1154,12 @@ const transformComponentCustomFields = (componentsData) => {
|
|||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Transform pieces for the current component
|
// Transform pieces for the current component
|
||||||
const pieces = component.pieces ? transformCustomFields(component.pieces) : [];
|
const pieces = component.pieces
|
||||||
|
? transformCustomFields(component.pieces).map((piece) => ({
|
||||||
|
...piece,
|
||||||
|
parentComponentName: component.name,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
// Recursively transform sub-components (using 'sousComposants' from backend)
|
// Recursively transform sub-components (using 'sousComposants' from backend)
|
||||||
const subComponents = component.sousComposants ? transformComponentCustomFields(component.sousComposants) : [];
|
const subComponents = component.sousComposants ? transformComponentCustomFields(component.sousComposants) : [];
|
||||||
@@ -798,6 +1201,8 @@ const loadMachineData = async () => {
|
|||||||
machine.value.documents = machine.value.documents || []
|
machine.value.documents = machine.value.documents || []
|
||||||
machineDocumentsLoaded.value = !!(machine.value.documents?.length)
|
machineDocumentsLoaded.value = !!(machine.value.documents?.length)
|
||||||
console.log('Machine trouvée et assignée:', machine.value)
|
console.log('Machine trouvée et assignée:', machine.value)
|
||||||
|
|
||||||
|
await preloadModelsForTypeMachine(machine.value.typeMachine)
|
||||||
} else {
|
} else {
|
||||||
console.error('Machine non trouvée:', machineId)
|
console.error('Machine non trouvée:', machineId)
|
||||||
console.error('Erreur API:', machineResult.error)
|
console.error('Erreur API:', machineResult.error)
|
||||||
@@ -833,6 +1238,7 @@ const loadMachineData = async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log('=== FIN HIÉRARCHIE ===')
|
console.log('=== FIN HIÉRARCHIE ===')
|
||||||
|
await ensureModelsForExistingEntities()
|
||||||
} else {
|
} else {
|
||||||
console.error('Erreur lors du chargement des composants:', componentsResult.error)
|
console.error('Erreur lors du chargement des composants:', componentsResult.error)
|
||||||
}
|
}
|
||||||
@@ -843,6 +1249,7 @@ const loadMachineData = async () => {
|
|||||||
pieces.value = transformCustomFields(machine.value.pieces)
|
pieces.value = transformCustomFields(machine.value.pieces)
|
||||||
console.log('Pièces transformées:', pieces.value)
|
console.log('Pièces transformées:', pieces.value)
|
||||||
console.log('Pièces chargées:', pieces.value.length)
|
console.log('Pièces chargées:', pieces.value.length)
|
||||||
|
await ensureModelsForExistingEntities()
|
||||||
} else {
|
} else {
|
||||||
console.log('Aucune pièce trouvée dans la réponse de la machine')
|
console.log('Aucune pièce trouvée dans la réponse de la machine')
|
||||||
}
|
}
|
||||||
@@ -887,10 +1294,12 @@ const updateComponent = async (updatedComponent) => {
|
|||||||
reference: updatedComponent.reference,
|
reference: updatedComponent.reference,
|
||||||
constructeurId: updatedComponent.constructeurId || updatedComponent.constructeur?.id || null,
|
constructeurId: updatedComponent.constructeurId || updatedComponent.constructeur?.id || null,
|
||||||
emplacement: updatedComponent.emplacement,
|
emplacement: updatedComponent.emplacement,
|
||||||
prix: prixValue && prixValue !== '' ? parseFloat(prixValue) : null
|
prix: prixValue && prixValue !== '' ? parseFloat(prixValue) : null,
|
||||||
|
composantModelId: updatedComponent.composantModelId || updatedComponent.composantModel?.id || null,
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
Object.assign(updatedComponent, result.data)
|
const transformed = transformComponentCustomFields([result.data])[0]
|
||||||
|
Object.assign(updatedComponent, transformed)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la mise à jour du composant:', error)
|
console.error('Erreur lors de la mise à jour du composant:', error)
|
||||||
@@ -904,10 +1313,12 @@ const updatePieceFromComponent = async (updatedPiece) => {
|
|||||||
reference: updatedPiece.reference,
|
reference: updatedPiece.reference,
|
||||||
constructeurId: updatedPiece.constructeurId || updatedPiece.constructeur?.id || null,
|
constructeurId: updatedPiece.constructeurId || updatedPiece.constructeur?.id || null,
|
||||||
emplacement: updatedPiece.emplacement,
|
emplacement: updatedPiece.emplacement,
|
||||||
prix: updatedPiece.prix && updatedPiece.prix !== '' ? parseFloat(updatedPiece.prix) : null
|
prix: updatedPiece.prix && updatedPiece.prix !== '' ? parseFloat(updatedPiece.prix) : null,
|
||||||
|
pieceModelId: updatedPiece.pieceModelId || updatedPiece.pieceModel?.id || null,
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
Object.assign(updatedPiece, result.data)
|
const transformed = transformCustomFields([result.data])[0]
|
||||||
|
Object.assign(updatedPiece, transformed)
|
||||||
// Si la pièce a des champs personnalisés mis à jour, les traiter
|
// Si la pièce a des champs personnalisés mis à jour, les traiter
|
||||||
if (updatedPiece.customFields) {
|
if (updatedPiece.customFields) {
|
||||||
for (const field of updatedPiece.customFields) {
|
for (const field of updatedPiece.customFields) {
|
||||||
@@ -934,16 +1345,131 @@ const updatePieceInfo = async (updatedPiece) => {
|
|||||||
reference: updatedPiece.reference,
|
reference: updatedPiece.reference,
|
||||||
constructeurId: updatedPiece.constructeurId || updatedPiece.constructeur?.id || null,
|
constructeurId: updatedPiece.constructeurId || updatedPiece.constructeur?.id || null,
|
||||||
emplacement: updatedPiece.emplacement,
|
emplacement: updatedPiece.emplacement,
|
||||||
prix: updatedPiece.prix && updatedPiece.prix !== '' ? parseFloat(updatedPiece.prix) : null
|
prix: updatedPiece.prix && updatedPiece.prix !== '' ? parseFloat(updatedPiece.prix) : null,
|
||||||
|
pieceModelId: updatedPiece.pieceModelId || updatedPiece.pieceModel?.id || null,
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
Object.assign(updatedPiece, result.data)
|
const transformed = transformCustomFields([result.data])[0]
|
||||||
|
Object.assign(updatedPiece, transformed)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assignComponentModel = async ({ componentId, composantModelId, previousModelId, previousModel }) => {
|
||||||
|
if (!componentId) return
|
||||||
|
try {
|
||||||
|
const result = await updateComposantApi(componentId, {
|
||||||
|
composantModelId: composantModelId || null,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
const transformed = transformComponentCustomFields([result.data])[0]
|
||||||
|
const target = findComponentById(components.value, componentId)
|
||||||
|
if (target) {
|
||||||
|
Object.assign(target, transformed)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const target = findComponentById(components.value, componentId)
|
||||||
|
if (target) {
|
||||||
|
target.composantModelId = previousModelId || null
|
||||||
|
target.composantModel = previousModel || null
|
||||||
|
}
|
||||||
|
toast.showError(result.error || 'Impossible de mettre à jour le modèle du composant')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'assignation du modèle de composant:', error)
|
||||||
|
const target = findComponentById(components.value, componentId)
|
||||||
|
if (target) {
|
||||||
|
target.composantModelId = previousModelId || null
|
||||||
|
target.composantModel = previousModel || null
|
||||||
|
}
|
||||||
|
toast.showError('Impossible de mettre à jour le modèle du composant')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitSaveComponentModelModal = async () => {
|
||||||
|
if (!saveComponentAsModelModal.form.typeComposantId) {
|
||||||
|
toast.showError('Type de composant manquant pour créer le modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveComponentAsModelModal.form.name.trim()) {
|
||||||
|
toast.showError('Veuillez renseigner un nom de modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saveComponentAsModelModal.submitting = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: saveComponentAsModelModal.form.name.trim(),
|
||||||
|
description: saveComponentAsModelModal.form.description.trim() || undefined,
|
||||||
|
typeComposantId: saveComponentAsModelModal.form.typeComposantId,
|
||||||
|
structure: saveComponentAsModelModal.structure,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createComponentModel(payload)
|
||||||
|
if (!result.success) {
|
||||||
|
toast.showError(result.error || 'Impossible de créer le modèle de composant')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newModel = result.data
|
||||||
|
if (newModel?.typeComposantId) {
|
||||||
|
await loadComponentModels(newModel.typeComposantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceComponent = saveComponentAsModelModal.component
|
||||||
|
if (sourceComponent && newModel?.id) {
|
||||||
|
await assignComponentModel({
|
||||||
|
componentId: sourceComponent.id,
|
||||||
|
composantModelId: newModel.id,
|
||||||
|
previousModelId: sourceComponent.composantModelId,
|
||||||
|
previousModel: sourceComponent.composantModel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSaveComponentModelModal()
|
||||||
|
toast.showSuccess('Composant enregistré comme modèle')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la création du modèle de composant:', error)
|
||||||
|
toast.showError('Impossible de créer le modèle de composant')
|
||||||
|
} finally {
|
||||||
|
saveComponentAsModelModal.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignPieceModel = async ({ pieceId, pieceModelId, previousModelId, previousModel }) => {
|
||||||
|
if (!pieceId) return
|
||||||
|
try {
|
||||||
|
const result = await updatePieceApi(pieceId, {
|
||||||
|
pieceModelId: pieceModelId || null,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
const transformed = transformCustomFields([result.data])[0]
|
||||||
|
const target = findPieceById(pieceId)
|
||||||
|
if (target) {
|
||||||
|
Object.assign(target, transformed)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const target = findPieceById(pieceId)
|
||||||
|
if (target) {
|
||||||
|
target.pieceModelId = previousModelId || null
|
||||||
|
target.pieceModel = previousModel || null
|
||||||
|
}
|
||||||
|
toast.showError(result.error || 'Impossible de mettre à jour le modèle de pièce')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'assignation du modèle de pièce:', error)
|
||||||
|
const target = findPieceById(pieceId)
|
||||||
|
if (target) {
|
||||||
|
target.pieceModelId = previousModelId || null
|
||||||
|
target.pieceModel = previousModel || null
|
||||||
|
}
|
||||||
|
toast.showError('Impossible de mettre à jour le modèle de pièce')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Méthodes pour les champs personnalisés de la machine
|
// Méthodes pour les champs personnalisés de la machine
|
||||||
const setMachineCustomFieldValue = (fieldValueId, value) => {
|
const setMachineCustomFieldValue = (fieldValueId, value) => {
|
||||||
const fieldValue = machine.value?.customFieldValues?.find(fv => fv.id === fieldValueId)
|
const fieldValue = machine.value?.customFieldValues?.find(fv => fv.id === fieldValueId)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
585
app/pages/models.vue
Normal file
585
app/pages/models.vue
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-8 space-y-8">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Catalogue de modèles</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Administrez les modèles de composants et de pièces disponibles lors de la création des machines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="tabs tabs-boxed w-full md:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'components' }"
|
||||||
|
@click="activeTab = 'components'"
|
||||||
|
>
|
||||||
|
Modèles de composants
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'pieces' }"
|
||||||
|
@click="activeTab = 'pieces'"
|
||||||
|
>
|
||||||
|
Modèles de pièces
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Component Models -->
|
||||||
|
<section v-if="activeTab === 'components'" class="space-y-4">
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
|
||||||
|
<label class="form-control w-full md:w-64">
|
||||||
|
<span class="label-text text-sm">Type de composant</span>
|
||||||
|
<select v-model="selectedComponentType" class="select select-bordered select-sm">
|
||||||
|
<option value="all">Tous les types</option>
|
||||||
|
<option
|
||||||
|
v-for="type in componentTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{ componentModelsList.length }} modèle(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="openComponentModal('create')">
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Nouveau modèle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingComponentModels" class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="componentModelsList.length === 0" class="py-12 text-center text-sm text-gray-500">
|
||||||
|
Aucun modèle trouvé pour ce filtre.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-sm text-gray-500">
|
||||||
|
<th>Nom</th>
|
||||||
|
<th class="hidden md:table-cell">Description</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="hidden lg:table-cell">Structure</th>
|
||||||
|
<th class="hidden lg:table-cell">Dernière modification</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="model in componentModelsList" :key="model.id">
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<IconLucideLayers class="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{{ model.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||||
|
<td>{{ model.typeComposant?.name || 'Non défini' }}</td>
|
||||||
|
<td class="hidden lg:table-cell">{{ formatStructurePreview(model.structure) }}</td>
|
||||||
|
<td class="hidden lg:table-cell">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @click="openComponentModal('edit', model)">
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-error" @click="confirmDeleteComponentModel(model)">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Piece Models -->
|
||||||
|
<section v-else class="space-y-4">
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
|
||||||
|
<label class="form-control w-full md:w-64">
|
||||||
|
<span class="label-text text-sm">Type de pièce</span>
|
||||||
|
<select v-model="selectedPieceType" class="select select-bordered select-sm">
|
||||||
|
<option value="all">Tous les types</option>
|
||||||
|
<option
|
||||||
|
v-for="type in pieceTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{ pieceModelsList.length }} modèle(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="openPieceModal('create')">
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Nouveau modèle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingPieceModels" class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="pieceModelsList.length === 0" class="py-12 text-center text-sm text-gray-500">
|
||||||
|
Aucun modèle trouvé pour ce filtre.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-sm text-gray-500">
|
||||||
|
<th>Nom</th>
|
||||||
|
<th class="hidden md:table-cell">Description</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="hidden lg:table-cell">Dernière modification</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="model in pieceModelsList" :key="model.id">
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<IconLucidePackage class="w-4 h-4 text-secondary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{{ model.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||||
|
<td>{{ model.typePiece?.name || 'Non défini' }}</td>
|
||||||
|
<td class="hidden lg:table-cell">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @click="openPieceModal('edit', model)">
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-error" @click="confirmDeletePieceModel(model)">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Component Model Modal -->
|
||||||
|
<div v-if="componentModal.open" class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-4xl">
|
||||||
|
<h3 class="font-bold text-lg mb-1">
|
||||||
|
{{ componentModal.mode === 'create' ? 'Nouveau modèle de composant' : 'Modifier le modèle de composant' }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-4">
|
||||||
|
Définissez le modèle de composant ainsi que sa structure par défaut (sous-composants, pièces et champs personnalisés).
|
||||||
|
</p>
|
||||||
|
<form class="space-y-5" @submit.prevent="submitComponentModal">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
|
<input v-model="componentModal.form.name" type="text" class="input input-bordered input-sm" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Description</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="componentModal.form.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Notes sur ce modèle"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Type de composant</span></label>
|
||||||
|
<select v-model="componentModal.form.typeComposantId" class="select select-bordered select-sm" required>
|
||||||
|
<option value="" disabled>Choisir un type</option>
|
||||||
|
<option
|
||||||
|
v-for="type in componentTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="divider my-0">Structure</div>
|
||||||
|
<ComponentModelStructureEditor v-model="componentModal.form.structure" />
|
||||||
|
<div class="rounded-lg border border-base-200 bg-base-200/60 p-3">
|
||||||
|
<ModelStructureViewer :structure="componentModal.form.structure" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-outline" @click="closeComponentModal">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :class="{ loading: componentModal.submitting }">
|
||||||
|
{{ componentModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Piece Model Modal -->
|
||||||
|
<div v-if="pieceModal.open" class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-md">
|
||||||
|
<h3 class="font-bold text-lg mb-1">
|
||||||
|
{{ pieceModal.mode === 'create' ? 'Nouveau modèle de pièce' : 'Modifier le modèle de pièce' }}
|
||||||
|
</h3>
|
||||||
|
<form class="space-y-4" @submit.prevent="submitPieceModal">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
|
<input v-model="pieceModal.form.name" type="text" class="input input-bordered input-sm" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Description</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="pieceModal.form.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Notes sur ce modèle"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Type de pièce</span></label>
|
||||||
|
<select v-model="pieceModal.form.typePieceId" class="select select-bordered select-sm" required>
|
||||||
|
<option value="" disabled>Choisir un type</option>
|
||||||
|
<option
|
||||||
|
v-for="type in pieceTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-outline" @click="closePieceModal">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :class="{ loading: pieceModal.submitting }">
|
||||||
|
{{ pieceModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
||||||
|
import { useComponentModels } from '~/composables/useComponentModels'
|
||||||
|
import { usePieceModels } from '~/composables/usePieceModels'
|
||||||
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideLayers from '~icons/lucide/layers'
|
||||||
|
import IconLucidePackage from '~icons/lucide/package'
|
||||||
|
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
|
||||||
|
import ModelStructureViewer from '~/components/ModelStructureViewer.vue'
|
||||||
|
import {
|
||||||
|
defaultStructure,
|
||||||
|
cloneStructure,
|
||||||
|
formatStructurePreview,
|
||||||
|
normalizeStructureForSave,
|
||||||
|
} from '~/shared/modelUtils'
|
||||||
|
|
||||||
|
const activeTab = ref('components')
|
||||||
|
const selectedComponentType = ref('all')
|
||||||
|
const selectedPieceType = ref('all')
|
||||||
|
|
||||||
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
|
|
||||||
|
const {
|
||||||
|
componentModels,
|
||||||
|
loadComponentModels,
|
||||||
|
createComponentModel,
|
||||||
|
updateComponentModel,
|
||||||
|
deleteComponentModel,
|
||||||
|
loadingComponentModels,
|
||||||
|
getComponentModelsForType,
|
||||||
|
} = useComponentModels()
|
||||||
|
|
||||||
|
const {
|
||||||
|
pieceModels,
|
||||||
|
loadPieceModels,
|
||||||
|
createPieceModel,
|
||||||
|
updatePieceModel,
|
||||||
|
deletePieceModel,
|
||||||
|
loadingPieceModels,
|
||||||
|
getPieceModelsForType,
|
||||||
|
} = usePieceModels()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const componentModal = reactive({
|
||||||
|
open: false,
|
||||||
|
mode: 'create',
|
||||||
|
submitting: false,
|
||||||
|
previousTypeId: null,
|
||||||
|
form: {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typeComposantId: '',
|
||||||
|
structure: defaultStructure(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieceModal = reactive({
|
||||||
|
open: false,
|
||||||
|
mode: 'create',
|
||||||
|
submitting: false,
|
||||||
|
previousTypeId: null,
|
||||||
|
form: {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typePieceId: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const componentModelsList = computed(() => {
|
||||||
|
if (selectedComponentType.value === 'all') {
|
||||||
|
return componentModels.value
|
||||||
|
}
|
||||||
|
return getComponentModelsForType(selectedComponentType.value) || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieceModelsList = computed(() => {
|
||||||
|
if (selectedPieceType.value === 'all') {
|
||||||
|
return pieceModels.value
|
||||||
|
}
|
||||||
|
return getPieceModelsForType(selectedPieceType.value) || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureTypeSelected = (typeId, list) => {
|
||||||
|
if (typeId && list.some((type) => type.id === typeId)) {
|
||||||
|
return typeId
|
||||||
|
}
|
||||||
|
return list[0]?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openComponentModal = (mode, model) => {
|
||||||
|
componentModal.mode = mode
|
||||||
|
componentModal.open = true
|
||||||
|
componentModal.submitting = false
|
||||||
|
componentModal.previousTypeId = model?.typeComposantId || null
|
||||||
|
if (mode === 'edit' && model) {
|
||||||
|
componentModal.form = {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
description: model.description || '',
|
||||||
|
typeComposantId: model.typeComposantId || model.typeComposant?.id || '',
|
||||||
|
structure: cloneStructure(model.structure || defaultStructure()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
componentModal.form = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typeComposantId: ensureTypeSelected(selectedComponentType.value !== 'all' ? selectedComponentType.value : '', componentTypes.value),
|
||||||
|
structure: defaultStructure(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeComponentModal = () => {
|
||||||
|
componentModal.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitComponentModal = async () => {
|
||||||
|
if (!componentModal.form.typeComposantId) {
|
||||||
|
toast.showError('Veuillez sélectionner un type de composant')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
componentModal.submitting = true
|
||||||
|
try {
|
||||||
|
if (componentModal.mode === 'create') {
|
||||||
|
await createComponentModel({
|
||||||
|
name: componentModal.form.name.trim(),
|
||||||
|
description: componentModal.form.description.trim() || undefined,
|
||||||
|
typeComposantId: componentModal.form.typeComposantId,
|
||||||
|
structure: normalizeStructureForSave(componentModal.form.structure),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await updateComponentModel(componentModal.form.id, {
|
||||||
|
name: componentModal.form.name.trim(),
|
||||||
|
description: componentModal.form.description.trim() || undefined,
|
||||||
|
typeComposantId: componentModal.form.typeComposantId,
|
||||||
|
structure: normalizeStructureForSave(componentModal.form.structure),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshComponentModels(componentModal.form.typeComposantId)
|
||||||
|
if (selectedComponentType.value === 'all') {
|
||||||
|
await refreshComponentModels()
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
componentModal.mode === 'edit' &&
|
||||||
|
componentModal.previousTypeId &&
|
||||||
|
componentModal.previousTypeId !== componentModal.form.typeComposantId
|
||||||
|
) {
|
||||||
|
await refreshComponentModels(componentModal.previousTypeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeComponentModal()
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError('Impossible d\'enregistrer le modèle de composant')
|
||||||
|
} finally {
|
||||||
|
componentModal.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteComponentModel = async (model) => {
|
||||||
|
if (!confirm(`Supprimer le modèle "${model.name}" ?`)) return
|
||||||
|
try {
|
||||||
|
const result = await deleteComponentModel(model.id)
|
||||||
|
if (result.success) {
|
||||||
|
await refreshComponentModels(model.typeComposantId)
|
||||||
|
if (selectedComponentType.value === 'all') {
|
||||||
|
await refreshComponentModels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError('Impossible de supprimer ce modèle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPieceModal = (mode, model) => {
|
||||||
|
pieceModal.mode = mode
|
||||||
|
pieceModal.open = true
|
||||||
|
pieceModal.submitting = false
|
||||||
|
pieceModal.previousTypeId = model?.typePieceId || null
|
||||||
|
if (mode === 'edit' && model) {
|
||||||
|
pieceModal.form = {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
description: model.description || '',
|
||||||
|
typePieceId: model.typePieceId || model.typePiece?.id || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pieceModal.form = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typePieceId: ensureTypeSelected(selectedPieceType.value !== 'all' ? selectedPieceType.value : '', pieceTypes.value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePieceModal = () => {
|
||||||
|
pieceModal.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPieceModal = async () => {
|
||||||
|
if (!pieceModal.form.typePieceId) {
|
||||||
|
toast.showError('Veuillez sélectionner un type de pièce')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pieceModal.submitting = true
|
||||||
|
try {
|
||||||
|
if (pieceModal.mode === 'create') {
|
||||||
|
await createPieceModel({
|
||||||
|
name: pieceModal.form.name.trim(),
|
||||||
|
description: pieceModal.form.description.trim() || undefined,
|
||||||
|
typePieceId: pieceModal.form.typePieceId,
|
||||||
|
structure: {},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await updatePieceModel(pieceModal.form.id, {
|
||||||
|
name: pieceModal.form.name.trim(),
|
||||||
|
description: pieceModal.form.description.trim() || undefined,
|
||||||
|
typePieceId: pieceModal.form.typePieceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshPieceModels(pieceModal.form.typePieceId)
|
||||||
|
if (selectedPieceType.value === 'all') {
|
||||||
|
await refreshPieceModels()
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pieceModal.mode === 'edit' &&
|
||||||
|
pieceModal.previousTypeId &&
|
||||||
|
pieceModal.previousTypeId !== pieceModal.form.typePieceId
|
||||||
|
) {
|
||||||
|
await refreshPieceModels(pieceModal.previousTypeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
closePieceModal()
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError('Impossible d\'enregistrer le modèle de pièce')
|
||||||
|
} finally {
|
||||||
|
pieceModal.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeletePieceModel = async (model) => {
|
||||||
|
if (!confirm(`Supprimer le modèle "${model.name}" ?`)) return
|
||||||
|
try {
|
||||||
|
const result = await deletePieceModel(model.id)
|
||||||
|
if (result.success) {
|
||||||
|
await refreshPieceModels(model.typePieceId)
|
||||||
|
if (selectedPieceType.value === 'all') {
|
||||||
|
await refreshPieceModels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError('Impossible de supprimer ce modèle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshComponentModels = async (typeId) => {
|
||||||
|
if (typeId) {
|
||||||
|
await loadComponentModels(typeId)
|
||||||
|
} else {
|
||||||
|
await loadComponentModels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshPieceModels = async (typeId) => {
|
||||||
|
if (typeId) {
|
||||||
|
await loadPieceModels(typeId)
|
||||||
|
} else {
|
||||||
|
await loadPieceModels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedComponentType, async (value) => {
|
||||||
|
await refreshComponentModels(value === 'all' ? undefined : value)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(selectedPieceType, async (value) => {
|
||||||
|
await refreshPieceModels(value === 'all' ? undefined : value)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadComponentTypes(),
|
||||||
|
loadPieceTypes(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -29,49 +29,63 @@
|
|||||||
<!-- Current Type Info -->
|
<!-- Current Type Info -->
|
||||||
<TypeInfoDisplay :type="type" />
|
<TypeInfoDisplay :type="type" />
|
||||||
|
|
||||||
<div v-if="hasExpandableContent" class="flex justify-end mb-6">
|
<!-- Familles de composants -->
|
||||||
<button
|
<div v-if="componentRequirementCount > 0" class="mb-8 space-y-3">
|
||||||
type="button"
|
<h3 class="text-lg font-semibold">Familles de composants</h3>
|
||||||
class="btn btn-outline btn-sm"
|
<div class="space-y-3">
|
||||||
@click="toggleGlobalExpand"
|
<div
|
||||||
>
|
v-for="requirement in type.componentRequirements"
|
||||||
<IconLucideMinus
|
:key="requirement.id"
|
||||||
v-if="globalExpandState.expanded"
|
class="border border-base-200 rounded-lg p-4 bg-base-100"
|
||||||
class="w-4 h-4 mr-2"
|
>
|
||||||
aria-hidden="true"
|
<div class="flex items-start justify-between gap-2">
|
||||||
/>
|
<div>
|
||||||
<IconLucidePlus
|
<h4 class="text-sm font-semibold">
|
||||||
v-else
|
{{ requirement.label || requirement.typeComposant?.name || 'Famille' }}
|
||||||
class="w-4 h-4 mr-2"
|
</h4>
|
||||||
aria-hidden="true"
|
<p class="text-xs text-gray-500">
|
||||||
/>
|
Type : {{ requirement.typeComposant?.name || 'Non défini' }}
|
||||||
{{ globalExpandState.expanded ? 'Tout plier' : 'Tout déplier' }}
|
</p>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<span class="badge badge-outline badge-sm">
|
||||||
|
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }} •
|
||||||
<!-- Affichage des composants existants -->
|
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
|
||||||
<div v-if="type.components && type.components.length > 0" class="mb-8">
|
</span>
|
||||||
<h3 class="text-lg font-semibold mb-4">Composants existants</h3>
|
</div>
|
||||||
<div class="space-y-4">
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
<TypeComponentDisplay
|
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
|
||||||
v-for="(component, componentIndex) in type.components"
|
</p>
|
||||||
:key="componentIndex"
|
</div>
|
||||||
:component="component"
|
|
||||||
:global-expand-state="globalExpandState"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Affichage des pièces principales existantes -->
|
<!-- Groupes de pièces -->
|
||||||
<div v-if="type.machinePieces && type.machinePieces.length > 0" class="mb-8">
|
<div v-if="pieceRequirementCount > 0" class="mb-8 space-y-3">
|
||||||
<h3 class="text-lg font-semibold mb-4">Pièces principales existantes</h3>
|
<h3 class="text-lg font-semibold">Groupes de pièces</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="space-y-3">
|
||||||
<TypeMachinePieceDisplay
|
<div
|
||||||
v-for="(piece, pieceIndex) in type.machinePieces"
|
v-for="requirement in type.pieceRequirements"
|
||||||
:key="pieceIndex"
|
:key="requirement.id"
|
||||||
:piece="piece"
|
class="border border-base-200 rounded-lg p-4 bg-base-100"
|
||||||
:global-expand-state="globalExpandState"
|
>
|
||||||
/>
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
{{ requirement.label || requirement.typePiece?.name || 'Groupe' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ requirement.typePiece?.name || 'Non défini' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }} •
|
||||||
|
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,8 +113,6 @@ import { useRoute } from 'vue-router'
|
|||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||||
import IconLucideMinus from '~icons/lucide/minus'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { getMachineTypeById } = useMachineTypesApi()
|
const { getMachineTypeById } = useMachineTypesApi()
|
||||||
@@ -109,20 +121,14 @@ const { showError } = useToast()
|
|||||||
const type = ref(null)
|
const type = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
const globalExpandState = reactive({
|
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
|
||||||
expanded: true,
|
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
|
||||||
id: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasExpandableContent = computed(() => {
|
const toDisplayCount = (value, fallback) => {
|
||||||
const componentCount = type.value?.components?.length || 0
|
if (value === null || value === undefined) {
|
||||||
const pieceCount = type.value?.machinePieces?.length || 0
|
return fallback
|
||||||
return componentCount + pieceCount > 0
|
}
|
||||||
})
|
return value
|
||||||
|
|
||||||
const toggleGlobalExpand = () => {
|
|
||||||
globalExpandState.expanded = !globalExpandState.expanded
|
|
||||||
globalExpandState.id += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -67,10 +67,69 @@ const editedType = ref({
|
|||||||
category: '',
|
category: '',
|
||||||
maintenanceFrequency: '',
|
maintenanceFrequency: '',
|
||||||
customFields: [],
|
customFields: [],
|
||||||
machinePieces: [],
|
componentRequirements: [],
|
||||||
components: []
|
pieceRequirements: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const parseOptions = (field = {}) => {
|
||||||
|
if (field.type !== 'select') return []
|
||||||
|
if (field.optionsText && typeof field.optionsText === 'string') {
|
||||||
|
return field.optionsText
|
||||||
|
.split('\n')
|
||||||
|
.map(option => option.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
if (Array.isArray(field.options)) {
|
||||||
|
return field.options
|
||||||
|
.map(option => String(option).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeCustomFields = (fields = []) =>
|
||||||
|
fields
|
||||||
|
.filter(field => field?.name && field.name.trim() !== '')
|
||||||
|
.map(field => ({
|
||||||
|
name: field.name,
|
||||||
|
type: field.type || '',
|
||||||
|
required: !!field.required,
|
||||||
|
defaultValue: field.defaultValue || '',
|
||||||
|
options: parseOptions(field)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toIntegerOrNull = (value, fallback = null) => {
|
||||||
|
if (value === '' || value === undefined || value === null) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeComponentRequirements = (requirements = []) =>
|
||||||
|
requirements
|
||||||
|
.filter(req => req?.typeComposantId)
|
||||||
|
.map(req => ({
|
||||||
|
typeComposantId: req.typeComposantId,
|
||||||
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
|
minCount: toIntegerOrNull(req.minCount, 1),
|
||||||
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
|
required: req.required ?? true,
|
||||||
|
allowNewModels: req.allowNewModels ?? true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const normalizePieceRequirements = (requirements = []) =>
|
||||||
|
requirements
|
||||||
|
.filter(req => req?.typePieceId)
|
||||||
|
.map(req => ({
|
||||||
|
typePieceId: req.typePieceId,
|
||||||
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
|
minCount: toIntegerOrNull(req.minCount, 0),
|
||||||
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
|
required: req.required ?? false,
|
||||||
|
allowNewModels: req.allowNewModels ?? true,
|
||||||
|
}))
|
||||||
|
|
||||||
const saveChanges = async () => {
|
const saveChanges = async () => {
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -80,80 +139,9 @@ const saveChanges = async () => {
|
|||||||
// Préparer les données pour l'API
|
// Préparer les données pour l'API
|
||||||
const updatedType = {
|
const updatedType = {
|
||||||
...currentEditedType,
|
...currentEditedType,
|
||||||
// Traiter les champs personnalisés
|
customFields: normalizeCustomFields(currentEditedType.customFields),
|
||||||
customFields: (currentEditedType.customFields || [])
|
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
|
||||||
.filter(field => field.name.trim() !== '')
|
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
|
||||||
.map(field => ({
|
|
||||||
name: field.name,
|
|
||||||
type: field.type,
|
|
||||||
required: field.required || false,
|
|
||||||
defaultValue: field.defaultValue || '',
|
|
||||||
options: field.type === 'select' && field.optionsText
|
|
||||||
? field.optionsText.split('\n').filter(opt => opt.trim() !== '')
|
|
||||||
: []
|
|
||||||
})),
|
|
||||||
// Traiter les pièces principales
|
|
||||||
machinePieces: (currentEditedType.machinePieces || [])
|
|
||||||
.filter(piece => piece.name.trim() !== '')
|
|
||||||
.map(piece => ({
|
|
||||||
name: piece.name,
|
|
||||||
reference: piece.reference || '',
|
|
||||||
constructeur: piece.constructeur || '',
|
|
||||||
emplacement: piece.emplacement || '',
|
|
||||||
prix: piece.prix || null,
|
|
||||||
customFields: (piece.customFields || [])
|
|
||||||
.filter(field => field.name.trim() !== '')
|
|
||||||
.map(field => ({
|
|
||||||
name: field.name,
|
|
||||||
type: field.type,
|
|
||||||
required: field.required || false,
|
|
||||||
defaultValue: field.defaultValue || '',
|
|
||||||
options: field.type === 'select' && field.optionsText
|
|
||||||
? field.optionsText.split('\n').filter(opt => opt.trim() !== '')
|
|
||||||
: []
|
|
||||||
}))
|
|
||||||
})),
|
|
||||||
// Traiter les composants
|
|
||||||
components: (currentEditedType.components || [])
|
|
||||||
.filter(comp => comp.name.trim() !== '')
|
|
||||||
.map(comp => ({
|
|
||||||
name: comp.name,
|
|
||||||
reference: comp.reference || '',
|
|
||||||
constructeur: comp.constructeur || '',
|
|
||||||
emplacement: comp.emplacement || '',
|
|
||||||
prix: comp.prix || null,
|
|
||||||
customFields: (comp.customFields || [])
|
|
||||||
.filter(field => field.name.trim() !== '')
|
|
||||||
.map(field => ({
|
|
||||||
name: field.name,
|
|
||||||
type: field.type,
|
|
||||||
required: field.required || false,
|
|
||||||
defaultValue: field.defaultValue || '',
|
|
||||||
options: field.type === 'select' && field.optionsText
|
|
||||||
? field.optionsText.split('\n').filter(opt => opt.trim() !== '')
|
|
||||||
: []
|
|
||||||
})),
|
|
||||||
pieces: (comp.pieces || [])
|
|
||||||
.filter(piece => piece.name.trim() !== '')
|
|
||||||
.map(piece => ({
|
|
||||||
name: piece.name,
|
|
||||||
reference: piece.reference || '',
|
|
||||||
constructeur: piece.constructeur || '',
|
|
||||||
emplacement: piece.emplacement || '',
|
|
||||||
prix: piece.prix || null,
|
|
||||||
customFields: (piece.customFields || [])
|
|
||||||
.filter(field => field.name.trim() !== '')
|
|
||||||
.map(field => ({
|
|
||||||
name: field.name,
|
|
||||||
type: field.type,
|
|
||||||
required: field.required || false,
|
|
||||||
defaultValue: field.defaultValue || '',
|
|
||||||
options: field.type === 'select' && field.optionsText
|
|
||||||
? field.optionsText.split('\n').filter(opt => opt.trim() !== '')
|
|
||||||
: []
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateMachineType(type.value.id, updatedType)
|
const result = await updateMachineType(type.value.id, updatedType)
|
||||||
@@ -193,8 +181,8 @@ onMounted(async () => {
|
|||||||
category: type.value.category || '',
|
category: type.value.category || '',
|
||||||
maintenanceFrequency: type.value.maintenanceFrequency || '',
|
maintenanceFrequency: type.value.maintenanceFrequency || '',
|
||||||
customFields: type.value.customFields || [],
|
customFields: type.value.customFields || [],
|
||||||
machinePieces: type.value.machinePieces || [],
|
componentRequirements: type.value.componentRequirements || [],
|
||||||
components: type.value.components || []
|
pieceRequirements: type.value.pieceRequirements || [],
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to load type:', result.error)
|
console.error('Failed to load type:', result.error)
|
||||||
|
|||||||
@@ -42,13 +42,14 @@
|
|||||||
<div class="badge badge-primary">{{ type.category }}</div>
|
<div class="badge badge-primary">{{ type.category }}</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-600 mb-4">{{ type.description }}</p>
|
<p class="text-gray-600 mb-4">{{ type.description }}</p>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2 text-sm text-gray-500">
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center gap-2">
|
||||||
<IconLucidePackage
|
<IconLucidePackage class="w-4 h-4" aria-hidden="true" />
|
||||||
class="w-4 h-4 mr-2"
|
<span>{{ type.componentRequirements?.length || 0 }} famille(s) de composants</span>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
/>
|
<div class="flex items-center gap-2">
|
||||||
{{ type.machinePieces?.length || 0 }} pièces totales
|
<IconLucideLayoutGrid class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<span>{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
|
|||||||
289
app/shared/modelUtils.ts
Normal file
289
app/shared/modelUtils.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelStructurePreview {
|
||||||
|
customFields: number
|
||||||
|
pieces: number
|
||||||
|
subComponents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultStructure = () => ({
|
||||||
|
customFields: [],
|
||||||
|
pieces: [],
|
||||||
|
subComponents: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const cloneStructure = (input: any) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(input ?? defaultStructure()))
|
||||||
|
} catch (error) {
|
||||||
|
return defaultStructure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeCustomFields = (fields: any[]): any[] => {
|
||||||
|
if (!Array.isArray(fields)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
.map((field) => {
|
||||||
|
const name = typeof field?.name === 'string' ? field.name.trim() : ''
|
||||||
|
if (!name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof field?.type === 'string' && field.type ? field.type : 'text'
|
||||||
|
const required = !!field?.required
|
||||||
|
const defaultValue = typeof field?.defaultValue === 'string' && field.defaultValue.trim().length > 0
|
||||||
|
? field.defaultValue.trim()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let options: string[] | undefined
|
||||||
|
if (type === 'select') {
|
||||||
|
const rawOptions = typeof field?.optionsText === 'string'
|
||||||
|
? field.optionsText
|
||||||
|
: Array.isArray(field?.options)
|
||||||
|
? field.options.join('\n')
|
||||||
|
: ''
|
||||||
|
const parsed = rawOptions
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((option) => option.trim())
|
||||||
|
.filter((option) => option.length > 0)
|
||||||
|
options = parsed.length > 0 ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = { name, type, required }
|
||||||
|
if (defaultValue !== undefined) {
|
||||||
|
result.defaultValue = defaultValue
|
||||||
|
}
|
||||||
|
if (options) {
|
||||||
|
result.options = options
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizePieces = (pieces: any[]): any[] => {
|
||||||
|
if (!Array.isArray(pieces)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces
|
||||||
|
.map((piece) => {
|
||||||
|
const name = typeof piece?.name === 'string' ? piece.name.trim() : ''
|
||||||
|
if (!name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0
|
||||||
|
? piece.reference.trim()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const quantity = Number(piece?.quantity)
|
||||||
|
const normalizedQuantity = Number.isFinite(quantity) && quantity > 0 ? quantity : undefined
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = { name }
|
||||||
|
if (reference !== undefined) {
|
||||||
|
result.reference = reference
|
||||||
|
}
|
||||||
|
if (normalizedQuantity !== undefined) {
|
||||||
|
result.quantity = normalizedQuantity
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeSubComponents = (components: any[]): any[] => {
|
||||||
|
if (!Array.isArray(components)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return components
|
||||||
|
.map((component) => {
|
||||||
|
const name = typeof component?.name === 'string' ? component.name.trim() : ''
|
||||||
|
if (!name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = typeof component?.description === 'string' && component.description.trim().length > 0
|
||||||
|
? component.description.trim()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const quantity = Number(component?.quantity)
|
||||||
|
const normalizedQuantity = Number.isFinite(quantity) && quantity > 0 ? quantity : undefined
|
||||||
|
|
||||||
|
const customFields = sanitizeCustomFields(component?.customFields)
|
||||||
|
const pieces = sanitizePieces(component?.pieces)
|
||||||
|
const subComponents = sanitizeSubComponents(component?.subComponents)
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
customFields,
|
||||||
|
pieces,
|
||||||
|
subComponents,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description !== undefined) {
|
||||||
|
result.description = description
|
||||||
|
}
|
||||||
|
if (normalizedQuantity !== undefined) {
|
||||||
|
result.quantity = normalizedQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeStructureForSave = (input: any) => {
|
||||||
|
const source = cloneStructure(input)
|
||||||
|
|
||||||
|
return {
|
||||||
|
customFields: sanitizeCustomFields(source.customFields),
|
||||||
|
pieces: sanitizePieces(source.pieces),
|
||||||
|
subComponents: sanitizeSubComponents(source.subComponents),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateCustomFields = (fields: any[]): any[] => {
|
||||||
|
if (!Array.isArray(fields)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields.map((field) => ({
|
||||||
|
name: field?.name ?? '',
|
||||||
|
type: field?.type ?? 'text',
|
||||||
|
required: !!field?.required,
|
||||||
|
defaultValue: field?.defaultValue ?? '',
|
||||||
|
options: Array.isArray(field?.options) ? field.options : [],
|
||||||
|
optionsText: Array.isArray(field?.options) ? field.options.join('\n') : (field?.optionsText ?? ''),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydratePieces = (pieces: any[]): any[] => {
|
||||||
|
if (!Array.isArray(pieces)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces.map((piece) => ({
|
||||||
|
name: piece?.name ?? '',
|
||||||
|
reference: piece?.reference ?? '',
|
||||||
|
quantity: piece?.quantity ?? undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateSubComponents = (components: any[]): any[] => {
|
||||||
|
if (!Array.isArray(components)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return components.map((component) => ({
|
||||||
|
name: component?.name ?? '',
|
||||||
|
description: component?.description ?? '',
|
||||||
|
quantity: component?.quantity ?? undefined,
|
||||||
|
customFields: hydrateCustomFields(component?.customFields),
|
||||||
|
pieces: hydratePieces(component?.pieces),
|
||||||
|
subComponents: hydrateSubComponents(component?.subComponents),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hydrateStructureForEditor = (input: any) => {
|
||||||
|
const source = cloneStructure(input)
|
||||||
|
return {
|
||||||
|
customFields: hydrateCustomFields(source.customFields),
|
||||||
|
pieces: hydratePieces(source.pieces),
|
||||||
|
subComponents: hydrateSubComponents(source.subComponents),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toOptionsText = (field: any) => {
|
||||||
|
if (typeof field?.optionsText === 'string') {
|
||||||
|
return field.optionsText
|
||||||
|
}
|
||||||
|
if (Array.isArray(field?.options)) {
|
||||||
|
return field.options.join('\n')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapComponentCustomFields = (fields: any[]) => {
|
||||||
|
if (!Array.isArray(fields)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return fields.map((field) => ({
|
||||||
|
name: field?.name ?? '',
|
||||||
|
type: field?.type ?? 'text',
|
||||||
|
required: !!field?.required,
|
||||||
|
defaultValue: field?.defaultValue ?? '',
|
||||||
|
options: Array.isArray(field?.options) ? field.options : [],
|
||||||
|
optionsText: toOptionsText(field),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapComponentPieces = (pieces: any[]) => {
|
||||||
|
if (!Array.isArray(pieces)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return pieces.map((piece) => ({
|
||||||
|
name: piece?.name ?? '',
|
||||||
|
reference: piece?.reference ?? '',
|
||||||
|
quantity: piece?.quantity ?? piece?.quantite ?? undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapSubComponents = (components: any[]): any[] => {
|
||||||
|
if (!Array.isArray(components)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return components.map((component) => ({
|
||||||
|
name: component?.name ?? '',
|
||||||
|
description: component?.description ?? '',
|
||||||
|
quantity: component?.quantity ?? component?.quantite ?? undefined,
|
||||||
|
customFields: mapComponentCustomFields(component?.customFields),
|
||||||
|
pieces: mapComponentPieces(component?.pieces),
|
||||||
|
subComponents: mapSubComponents(component?.subComponents),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractStructureFromComponent = (component: any) => {
|
||||||
|
if (!component) {
|
||||||
|
return defaultStructure()
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = {
|
||||||
|
customFields: mapComponentCustomFields(component.customFields),
|
||||||
|
pieces: mapComponentPieces(component.pieces),
|
||||||
|
subComponents: mapSubComponents(component.subComponents),
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeStructureForSave(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeStructureStats = (structure: any): ModelStructurePreview => {
|
||||||
|
if (!structure || typeof structure !== 'object') {
|
||||||
|
return { customFields: 0, pieces: 0, subComponents: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
|
||||||
|
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
|
||||||
|
subComponents: Array.isArray(structure.subComponents) ? structure.subComponents.length : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatStructurePreview = (structure: any) => {
|
||||||
|
const stats = computeStructureStats(structure)
|
||||||
|
if (!stats.customFields && !stats.pieces && !stats.subComponents) {
|
||||||
|
return 'Structure vide'
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: string[] = []
|
||||||
|
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
|
||||||
|
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
|
||||||
|
if (stats.subComponents) segments.push(`${stats.subComponents} sous-composant(s)`)
|
||||||
|
return segments.join(' • ')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user