set up new view for skeleton hiearchi

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

View File

@@ -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"

View File

@@ -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>

View File

@@ -27,6 +27,18 @@
<span v-if="component.constructeur" class="badge badge-outline badge-sm">{{ component.constructeur?.name }}</span> <span v-if="component.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>

View File

@@ -0,0 +1,242 @@
<template>
<div class="space-y-6">
<section class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Champs personnalisés du composant</h3>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(localStructure.customFields?.length)" class="text-xs text-gray-500">
Aucun champ n'a encore été défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(field, index) in localStructure.customFields"
:key="`root-field-${index}`"
class="border border-base-200 rounded-md p-3 space-y-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">Texte</option>
<option value="number">Nombre</option>
<option value="select">Liste</option>
<option value="boolean">Oui/Non</option>
<option value="date">Date</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<label class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</label>
<input
v-model="field.defaultValue"
type="text"
class="input input-bordered input-xs"
placeholder="Valeur par défaut"
/>
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
></textarea>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeCustomField(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Pièces incluses par défaut</h3>
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(localStructure.pieces?.length)" class="text-xs text-gray-500">Aucune pièce définie.</p>
<div v-else class="space-y-2">
<div
v-for="(piece, index) in localStructure.pieces"
:key="`root-piece-${index}`"
class="border border-base-200 rounded-md p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 grid grid-cols-1 md:grid-cols-3 gap-2">
<input
v-model="piece.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom de la pièce"
/>
<input
v-model="piece.reference"
type="text"
class="input input-bordered input-xs"
placeholder="Référence"
/>
<input
v-model.number="piece.quantity"
type="number"
min="0"
step="1"
class="input input-bordered input-xs"
placeholder="Quantité"
/>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Sous-composants</h3>
<button type="button" class="btn btn-outline btn-xs" @click="addSubComponent">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(localStructure.subComponents?.length)" class="text-xs text-gray-500">
Aucun sous-composant défini.
</p>
<div v-else class="space-y-3">
<StructureSubComponentEditor
v-for="(subComponent, index) in localStructure.subComponents"
:key="`root-sub-${index}`"
:node="subComponent"
:depth="0"
@remove="removeSubComponent(index)"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import StructureSubComponentEditor from '~/components/StructureSubComponentEditor.vue'
import {
defaultStructure,
hydrateStructureForEditor,
normalizeStructureForSave,
} from '~/shared/modelUtils'
defineOptions({ name: 'ComponentModelStructureEditor' })
const props = defineProps({
modelValue: {
type: Object,
default: () => defaultStructure(),
},
})
const emit = defineEmits(['update:modelValue'])
const localStructure = reactive(hydrateStructureForEditor(props.modelValue))
const syncFromProps = (value: any) => {
const hydrated = hydrateStructureForEditor(value)
localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces
localStructure.subComponents = hydrated.subComponents
lastEmitted = JSON.stringify(normalizeStructureForSave(value))
}
watch(
() => props.modelValue,
(value) => {
syncFromProps(value)
},
{ deep: true }
)
let lastEmitted = JSON.stringify(normalizeStructureForSave(props.modelValue))
watch(
localStructure,
(value) => {
const normalized = normalizeStructureForSave(value)
const serialized = JSON.stringify(normalized)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', normalized)
}
},
{ deep: true }
)
const ensureArray = (key: 'customFields' | 'pieces' | 'subComponents') => {
if (!Array.isArray(localStructure[key])) {
localStructure[key] = []
}
}
const addCustomField = () => {
ensureArray('customFields')
localStructure.customFields.push({
name: '',
type: 'text',
required: false,
defaultValue: '',
optionsText: '',
options: [],
})
}
const removeCustomField = (index: number) => {
if (!Array.isArray(localStructure.customFields)) return
localStructure.customFields.splice(index, 1)
}
const addPiece = () => {
ensureArray('pieces')
localStructure.pieces.push({
name: '',
reference: '',
quantity: undefined,
})
}
const removePiece = (index: number) => {
if (!Array.isArray(localStructure.pieces)) return
localStructure.pieces.splice(index, 1)
}
const addSubComponent = () => {
ensureArray('subComponents')
localStructure.subComponents.push({
name: '',
description: '',
quantity: undefined,
customFields: [],
pieces: [],
subComponents: [],
})
}
const removeSubComponent = (index: number) => {
if (!Array.isArray(localStructure.subComponents)) return
localStructure.subComponents.splice(index, 1)
}
</script>

View File

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

View File

@@ -24,6 +24,23 @@
{{ pieceData.name }} {{ 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 || ''

View File

@@ -0,0 +1,260 @@
<template>
<div class="border border-base-200 rounded-lg bg-base-100" :class="depthPadding">
<div class="flex items-start justify-between gap-3 border-b border-base-200 px-4 py-3">
<div class="flex flex-col gap-1 flex-1">
<div class="flex items-center gap-2">
<button type="button" class="btn btn-ghost btn-xs" @click="toggle">
<IconLucideChevronRight
class="w-4 h-4 transition-transform"
:class="{ 'rotate-90': expanded }"
aria-hidden="true"
/>
</button>
<input
v-model="node.name"
type="text"
class="input input-sm input-bordered w-full"
placeholder="Nom du sous-composant"
/>
</div>
<span v-if="!expanded && node.description" class="text-xs text-gray-500 truncate">
{{ node.description }}
</span>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="emit('remove')">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="expanded" class="space-y-5 px-4 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label"><span class="label-text">Description</span></label>
<textarea
v-model="node.description"
class="textarea textarea-bordered textarea-sm"
rows="2"
placeholder="Notes optionnelles"
></textarea>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Quantité</span></label>
<input
v-model.number="node.quantity"
type="number"
min="0"
step="1"
class="input input-bordered input-sm"
placeholder="Quantité (optionnel)"
/>
</div>
</div>
<section class="space-y-2">
<h4 class="text-sm font-semibold">Champs personnalisés</h4>
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
Aucun champ défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(field, fieldIndex) in node.customFields"
:key="`sub-${depth}-${fieldIndex}`"
class="border border-base-200 rounded-md p-3 space-y-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">Texte</option>
<option value="number">Nombre</option>
<option value="select">Liste</option>
<option value="boolean">Oui/Non</option>
<option value="date">Date</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<label class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</label>
<input
v-model="field.defaultValue"
type="text"
class="input input-bordered input-xs"
placeholder="Valeur par défaut"
/>
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
></textarea>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeCustomField(fieldIndex)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter un champ
</button>
</section>
<section class="space-y-2">
<h4 class="text-sm font-semibold">Pièces associées</h4>
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">Aucune pièce définie.</p>
<div v-else class="space-y-2">
<div
v-for="(piece, pieceIndex) in node.pieces"
:key="`piece-${depth}-${pieceIndex}`"
class="border border-base-200 rounded-md p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 grid grid-cols-1 md:grid-cols-3 gap-2">
<input
v-model="piece.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom de la pièce"
/>
<input
v-model="piece.reference"
type="text"
class="input input-bordered input-xs"
placeholder="Référence"
/>
<input
v-model.number="piece.quantity"
type="number"
min="0"
step="1"
class="input input-bordered input-xs"
placeholder="Quantité"
/>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(pieceIndex)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter une pièce
</button>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold">Sous-composants</h4>
<button type="button" class="btn btn-outline btn-xs" @click="addSubComponent">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(node.subComponents?.length)" class="text-xs text-gray-500">Aucun sous-composant défini.</p>
<div v-else class="space-y-3">
<StructureSubComponentEditor
v-for="(sub, index) in node.subComponents"
:key="`sub-${depth}-${index}`"
:node="sub"
:depth="depth + 1"
@remove="removeSubComponent(index)"
/>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
defineOptions({ name: 'StructureSubComponentEditor' })
const props = defineProps({
node: {
type: Object,
required: true,
},
depth: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['remove'])
const expanded = ref(true)
const depthPadding = computed(() => (props.depth > 0 ? 'ml-4' : ''))
const toggle = () => {
expanded.value = !expanded.value
}
const ensureArray = (key: 'customFields' | 'pieces' | 'subComponents') => {
if (!Array.isArray(props.node[key])) {
props.node[key] = []
}
}
const addCustomField = () => {
ensureArray('customFields')
props.node.customFields.push({
name: '',
type: 'text',
required: false,
defaultValue: '',
optionsText: '',
options: [],
})
}
const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1)
}
const addPiece = () => {
ensureArray('pieces')
props.node.pieces.push({
name: '',
reference: '',
quantity: undefined,
})
}
const removePiece = (index: number) => {
if (!Array.isArray(props.node.pieces)) return
props.node.pieces.splice(index, 1)
}
const addSubComponent = () => {
ensureArray('subComponents')
props.node.subComponents.push({
name: '',
description: '',
quantity: undefined,
customFields: [],
pieces: [],
subComponents: [],
})
}
const removeSubComponent = (index: number) => {
if (!Array.isArray(props.node.subComponents)) return
props.node.subComponents.splice(index, 1)
}
</script>

View File

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

View File

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

View File

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

View File

@@ -16,18 +16,14 @@
@update:model-value="(value) => (formData.customFields = value)" @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))

View File

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

View File

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

View File

@@ -5,8 +5,8 @@
<div class="text-sm"> <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>

View File

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

View File

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

View 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,
}
}

View 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,
}
}

View File

@@ -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
}
}

View 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,
}
}

View 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,
}
}

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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 () => {

View File

@@ -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)

View File

@@ -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
View 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(' • ')
}