refactor(machines) : remove TypeMachine skeleton system, simplify machine creation
- Remove TypeEdit*, TypeInfoDisplay, MachineSkeletonSummary, MachineCreatePreview components - Remove machine-skeleton pages and type pages - Remove useMachineTypesApi, useMachineSkeletonEditor, useMachineCreateSelections composables - Add AddEntityToMachineModal for direct entity linking - Update machine detail/create pages for direct custom fields - Fix SearchSelect, category display, and ipartial search filters Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,12 +26,6 @@
|
||||
{{ component.name }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span
|
||||
v-if="component.skeletonOnly"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
Défini dans le catalogue
|
||||
</span>
|
||||
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
||||
<template v-if="componentConstructeursDisplay.length">
|
||||
<span
|
||||
@@ -49,12 +43,6 @@
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="component.typeMachineComponentRequirement"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,8 +389,7 @@
|
||||
v-for="piece in component.pieces"
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode && !piece.skeletonOnly"
|
||||
|
||||
:is-edit-mode="isEditMode"
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@@ -420,7 +407,7 @@
|
||||
v-for="subComponent in childComponents"
|
||||
:key="subComponent.id"
|
||||
:component="subComponent"
|
||||
:is-edit-mode="isEditMode && !subComponent.skeletonOnly"
|
||||
:is-edit-mode="isEditMode"
|
||||
:collapse-all="collapseAll"
|
||||
:toggle-token="toggleToken"
|
||||
@update="$emit('update', $event)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="space-y-4">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
@@ -7,58 +7,53 @@
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" />
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="`piece-name-${piece.id}`"
|
||||
v-model="pieceData.name"
|
||||
type="text"
|
||||
class="font-semibold text-lg input input-sm input-bordered"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
:aria-expanded="!isCollapsed"
|
||||
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
{{ pieceData.name }}
|
||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ pieceData.name }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
||||
<template v-if="pieceConstructeursDisplay.length">
|
||||
<span
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
v-if="piece.skeletonOnly"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
Défini dans le catalogue
|
||||
</span>
|
||||
<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.parentComponentName"
|
||||
class="badge badge-ghost badge-sm"
|
||||
>
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-show="!isCollapsed" class="space-y-4">
|
||||
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="font-medium">Référence:</span>
|
||||
<input
|
||||
@@ -236,6 +231,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs personnalisés de la pièce -->
|
||||
<div
|
||||
@@ -480,16 +476,17 @@
|
||||
Aucun document lié à cette pièce.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted, watch, computed } from 'vue'
|
||||
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
@@ -523,6 +520,8 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
const props = defineProps({
|
||||
piece: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
|
||||
@@ -576,6 +575,23 @@ const {
|
||||
updateCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) refreshDocuments()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) refreshDocuments()
|
||||
}
|
||||
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<div class="card-actions justify-end">
|
||||
<button type="button" class="btn btn-outline" @click="$emit('reset')">
|
||||
Réinitialiser
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
<IconLucideRefreshCw
|
||||
v-if="saving"
|
||||
class="w-5 h-5 mr-2 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideCheck v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ saving ? 'Sauvegarde...' : 'Sauvegarder les modifications' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconLucideRefreshCw from '~icons/lucide/refresh-cw'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
|
||||
defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['reset'])
|
||||
</script>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">
|
||||
Informations de base
|
||||
</h3>
|
||||
|
||||
<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 type</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="nameModel"
|
||||
type="text"
|
||||
placeholder="Nom du type de machine"
|
||||
class="input input-bordered"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="categoryModel"
|
||||
type="text"
|
||||
placeholder="Catégorie du type"
|
||||
class="input input-bordered"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="descriptionModel"
|
||||
placeholder="Description du type de machine"
|
||||
class="textarea textarea-bordered h-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fréquence de maintenance</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="maintenanceModel"
|
||||
type="text"
|
||||
placeholder="ex: Mensuelle, Trimestrielle"
|
||||
class="input input-bordered"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
maintenanceFrequency: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:name', 'update:category', 'update:description', 'update:maintenanceFrequency'])
|
||||
|
||||
const nameModel = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value)
|
||||
})
|
||||
|
||||
const categoryModel = computed({
|
||||
get: () => props.category,
|
||||
set: value => emit('update:category', value)
|
||||
})
|
||||
|
||||
const descriptionModel = computed({
|
||||
get: () => props.description,
|
||||
set: value => emit('update:description', value)
|
||||
})
|
||||
|
||||
const maintenanceModel = computed({
|
||||
get: () => props.maintenanceFrequency,
|
||||
set: value => emit('update:maintenanceFrequency', value)
|
||||
})
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="componentTypes"
|
||||
type-field="typeComposantId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="true"
|
||||
:min-fallback="1"
|
||||
:type-loading="loadingComponentTypes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typeComposantId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typeComposantId: null,
|
||||
label: '',
|
||||
minCount: 1,
|
||||
maxCount: null,
|
||||
required: true,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Familles de composants',
|
||||
addButton: 'Ajouter une famille',
|
||||
description:
|
||||
"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.",
|
||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de composants.',
|
||||
typeSelectLabel: 'Type de composant',
|
||||
typePlaceholder: 'Sélectionner un type',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex: Sangles principales',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!componentTypes.value.length) {
|
||||
await loadComponentTypes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,356 +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">
|
||||
Champs personnalisés du type
|
||||
</h3>
|
||||
<span class="badge badge-primary">{{ fields.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="space-y-4">
|
||||
<div
|
||||
v-for="(field, fieldIndex) in fields"
|
||||
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
|
||||
class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
|
||||
:class="fieldReorderClass(fieldIndex)"
|
||||
draggable="true"
|
||||
@dragstart="onFieldDragStart(fieldIndex, $event)"
|
||||
@dragenter="onFieldDragEnter(fieldIndex)"
|
||||
@dragover.prevent
|
||||
@drop="onFieldDrop(fieldIndex)"
|
||||
@dragend="onFieldDragEnd"
|
||||
>
|
||||
<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 btn-square cursor-grab active:cursor-grabbing"
|
||||
title="Réorganiser"
|
||||
draggable="false"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-1"
|
||||
title="Plier / déplier le champ"
|
||||
@click="toggleField(fieldIndex)"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': isFieldExpanded(fieldIndex) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<IconLucideListChecks class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
||||
<h5 class="text-sm font-medium">
|
||||
Champ personnalisé {{ fieldIndex + 1 }}
|
||||
</h5>
|
||||
<span v-if="!isFieldExpanded(fieldIndex)" class="text-xs text-gray-500 truncate max-w-[160px]">
|
||||
{{ field.name || 'Sans nom' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
title="Supprimer ce champ"
|
||||
@click="removeField(fieldIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isFieldExpanded(fieldIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du champ</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="field.name"
|
||||
type="text"
|
||||
placeholder="Nom du champ"
|
||||
class="input input-bordered input-sm"
|
||||
required
|
||||
@input="updateField(fieldIndex, { name: $event.target.value })"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type de champ</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered select-sm"
|
||||
required
|
||||
:value="field.type"
|
||||
@change="updateField(fieldIndex, { type: $event.target.value })"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner un type
|
||||
</option>
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
<option value="number">
|
||||
Nombre
|
||||
</option>
|
||||
<option value="select">
|
||||
Liste déroulante
|
||||
</option>
|
||||
<option value="boolean">
|
||||
Oui/Non
|
||||
</option>
|
||||
<option value="date">
|
||||
Date
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isFieldExpanded(fieldIndex)" class="mt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="field.required"
|
||||
@change="updateField(fieldIndex, { required: $event.target.checked })"
|
||||
>
|
||||
<span class="text-sm">Champ obligatoire</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFieldExpanded(fieldIndex) && field.type === 'select'"
|
||||
class="mt-3"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">Options de la liste</span>
|
||||
<span class="label-text-alt">Une option par ligne</span>
|
||||
</label>
|
||||
<textarea
|
||||
:value="field.optionsText || ''"
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
class="textarea textarea-bordered textarea-sm w-full h-20"
|
||||
@input="updateOptions(fieldIndex, $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addField">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-end">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addField">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideListChecks from '~icons/lucide/list-checks'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
expandAllTrigger: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const fields = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||
|
||||
const expanded = ref(false)
|
||||
const expandedFields = ref([])
|
||||
const draggingFieldIndex = ref(null)
|
||||
const fieldDropTargetIndex = ref(null)
|
||||
|
||||
const applyOrderIndex = (list = []) => {
|
||||
if (!Array.isArray(list)) { return [] }
|
||||
list.forEach((field, index) => {
|
||||
if (field && typeof field === 'object') {
|
||||
field.orderIndex = index
|
||||
if (typeof field.__key !== 'string' || !field.__key) {
|
||||
field.__key = createFieldKey()
|
||||
}
|
||||
}
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
const createEmptyField = () => ({
|
||||
name: '',
|
||||
type: '',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
orderIndex: fields.value.length,
|
||||
__key: createFieldKey()
|
||||
})
|
||||
|
||||
const resetDragState = () => {
|
||||
draggingFieldIndex.value = null
|
||||
fieldDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const reorderFields = (from, to) => {
|
||||
const list = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
const [moved] = list.splice(from, 1)
|
||||
list.splice(to, 0, moved)
|
||||
if (Array.isArray(expandedFields.value)) {
|
||||
const expandedCopy = expandedFields.value.slice()
|
||||
const [expandedState] = expandedCopy.splice(from, 1)
|
||||
expandedCopy.splice(to, 0, expandedState)
|
||||
expandedFields.value = expandedCopy
|
||||
}
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.expandAllTrigger,
|
||||
() => {
|
||||
expanded.value = props.allExpanded
|
||||
expandedFields.value = fields.value.map(() => props.allExpanded)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => fields.value.length,
|
||||
(length) => {
|
||||
expandedFields.value = Array.from({ length }, (_, index) => expandedFields.value[index] ?? props.allExpanded)
|
||||
}
|
||||
)
|
||||
|
||||
const toggleSection = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const ensureFieldState = (index) => {
|
||||
if (expandedFields.value[index] === undefined) {
|
||||
expandedFields.value[index] = props.allExpanded
|
||||
}
|
||||
}
|
||||
|
||||
const isFieldExpanded = (index) => {
|
||||
ensureFieldState(index)
|
||||
return expandedFields.value[index]
|
||||
}
|
||||
|
||||
const toggleField = (index) => {
|
||||
ensureFieldState(index)
|
||||
expandedFields.value[index] = !expandedFields.value[index]
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||
next.push(createEmptyField())
|
||||
fields.value = applyOrderIndex(next)
|
||||
expandedFields.value.push(true)
|
||||
expanded.value = true
|
||||
}
|
||||
|
||||
const removeField = (index) => {
|
||||
const next = Array.isArray(fields.value)
|
||||
? fields.value.filter((_, i) => i !== index)
|
||||
: []
|
||||
fields.value = applyOrderIndex(next)
|
||||
expandedFields.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const updateField = (index, patch) => {
|
||||
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||
next[index] = { ...next[index], ...patch }
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
const updateOptions = (index, value) => {
|
||||
updateField(index, {
|
||||
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
})
|
||||
}
|
||||
|
||||
const onFieldDragStart = (index, event) => {
|
||||
draggingFieldIndex.value = index
|
||||
fieldDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onFieldDragEnter = (index) => {
|
||||
if (draggingFieldIndex.value === null) { return }
|
||||
fieldDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onFieldDrop = (index) => {
|
||||
if (draggingFieldIndex.value === null) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
reorderFields(draggingFieldIndex.value, index)
|
||||
}
|
||||
|
||||
const onFieldDragEnd = () => {
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const fieldReorderClass = (index) => {
|
||||
if (draggingFieldIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingFieldIndex.value !== null &&
|
||||
fieldDropTargetIndex.value === index &&
|
||||
draggingFieldIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<TypeEditToolbar :all-expanded="allExpanded" @toggle="toggleAllSections" />
|
||||
|
||||
<TypeEditBaseInfoSection
|
||||
v-model:name="formData.name"
|
||||
v-model:category="formData.category"
|
||||
v-model:description="formData.description"
|
||||
v-model:maintenance-frequency="formData.maintenanceFrequency"
|
||||
/>
|
||||
|
||||
<TypeEditCustomFieldsSection
|
||||
:model-value="formData.customFields"
|
||||
:all-expanded="allExpanded"
|
||||
:expand-all-trigger="expandAllTrigger"
|
||||
@update:model-value="(value) => (formData.customFields = value)"
|
||||
/>
|
||||
|
||||
<TypeEditComponentRequirementsSection
|
||||
:model-value="formData.componentRequirements"
|
||||
@update:model-value="(value) => (formData.componentRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditPieceRequirementsSection
|
||||
:model-value="formData.pieceRequirements"
|
||||
@update:model-value="(value) => (formData.pieceRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditProductRequirementsSection
|
||||
:model-value="formData.productRequirements"
|
||||
@update:model-value="(value) => (formData.productRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
|
||||
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
|
||||
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
|
||||
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
||||
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
|
||||
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
|
||||
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit'])
|
||||
|
||||
const deepClone = value => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||
|
||||
const normalizeCustomField = (field = {}, index = 0) => {
|
||||
const clone = deepClone(field)
|
||||
if (clone.type === 'select') {
|
||||
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
|
||||
if (Array.isArray(clone.options)) {
|
||||
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
|
||||
} else {
|
||||
clone.optionsText = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const currentOrder =
|
||||
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
||||
clone.orderIndex = currentOrder
|
||||
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
||||
clone.__key = createFieldKey()
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
const withNormalizedOrder = (items = []) => {
|
||||
if (!Array.isArray(items)) { return [] }
|
||||
return items
|
||||
.map((item, index) => normalizeCustomField(item, index))
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.map((item, index) => ({ ...item, orderIndex: index }))
|
||||
}
|
||||
|
||||
const createDefaultForm = (source = {}) => ({
|
||||
name: source.name || '',
|
||||
description: source.description || '',
|
||||
category: source.category || '',
|
||||
maintenanceFrequency: source.maintenanceFrequency || '',
|
||||
customFields: withNormalizedOrder(source.customFields || []),
|
||||
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
|
||||
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
|
||||
productRequirements: withNormalizedOrder(source.productRequirements || []),
|
||||
})
|
||||
|
||||
const formData = reactive(createDefaultForm(props.modelValue))
|
||||
const allExpanded = ref(false)
|
||||
const expandAllTrigger = ref(0)
|
||||
|
||||
let syncingFromParent = false
|
||||
const toPlainObject = value => JSON.parse(JSON.stringify(value))
|
||||
const lastSnapshot = ref(toPlainObject(createDefaultForm(props.modelValue)))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const normalized = createDefaultForm(value)
|
||||
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
syncingFromParent = true
|
||||
Object.assign(formData, normalized)
|
||||
lastSnapshot.value = toPlainObject(normalized)
|
||||
syncingFromParent = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
formData,
|
||||
(value) => {
|
||||
if (syncingFromParent) { return }
|
||||
const normalized = createDefaultForm(value)
|
||||
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSnapshot.value = toPlainObject(normalized)
|
||||
emit('update:modelValue', normalized)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const toggleAllSections = () => {
|
||||
allExpanded.value = !allExpanded.value
|
||||
expandAllTrigger.value += 1
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
const normalized = createDefaultForm(props.modelValue)
|
||||
syncingFromParent = true
|
||||
Object.assign(formData, normalized)
|
||||
lastSnapshot.value = toPlainObject(normalized)
|
||||
syncingFromParent = false
|
||||
allExpanded.value = false
|
||||
expandAllTrigger.value += 1
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="pieceTypes"
|
||||
type-field="typePieceId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="false"
|
||||
:min-fallback="0"
|
||||
:type-loading="loadingPieceTypes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typePieceId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typePieceId: null,
|
||||
label: '',
|
||||
minCount: 0,
|
||||
maxCount: null,
|
||||
required: false,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Pièces principales',
|
||||
addButton: 'Ajouter un groupe',
|
||||
description:
|
||||
"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.",
|
||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de pièces.',
|
||||
typeSelectLabel: 'Type de pièce',
|
||||
typePlaceholder: 'Sélectionner un type',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex: Vis principale',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!pieceTypes.value.length) {
|
||||
await loadPieceTypes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="productTypes"
|
||||
type-field="typeProductId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="false"
|
||||
:min-fallback="0"
|
||||
:type-loading="loadingProductTypes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typeProductId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typeProductId: null,
|
||||
label: '',
|
||||
minCount: 0,
|
||||
maxCount: null,
|
||||
required: false,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Produits requis',
|
||||
addButton: 'Ajouter un produit',
|
||||
description:
|
||||
"Définissez les produits catalogue attendus pour ce type de machine. Sélectionnez la catégorie de produit, précisez les quantités minimales et maximales, puis indiquez si de nouveaux produits peuvent être créés à l'usage.",
|
||||
emptyState: 'Aucun produit requis configuré pour le moment.',
|
||||
typeSelectLabel: 'Catégorie de produit',
|
||||
typePlaceholder: 'Sélectionner une catégorie',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex : Lubrifiant recommandé',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux produits lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!productTypes.value.length) {
|
||||
await loadProductTypes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="$emit('toggle')">
|
||||
<IconLucideMinus v-if="allExpanded" class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
<IconLucidePlus v-else class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
{{ allExpanded ? 'Tout plier' : 'Tout déplier' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconLucideMinus from '~icons/lucide/minus'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
defineProps({
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<div class="alert alert-info mb-6">
|
||||
<div>
|
||||
<h3 class="font-bold">
|
||||
Type existant
|
||||
</h3>
|
||||
<div class="text-sm">
|
||||
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
|
||||
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
|
||||
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
|
||||
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
|
||||
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
|
||||
<p v-if="type.description">
|
||||
<strong>Description:</strong> {{ type.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -18,6 +18,15 @@
|
||||
@keydown.enter.prevent="selectHighlighted"
|
||||
@input="handleInput"
|
||||
>
|
||||
<button
|
||||
v-if="clearable && modelValue"
|
||||
type="button"
|
||||
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
|
||||
aria-label="Effacer la sélection"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="toggleButtonClasses"
|
||||
@@ -77,6 +86,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -111,6 +121,10 @@ const props = defineProps({
|
||||
type: [String, Function],
|
||||
default: null
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
@@ -155,7 +169,8 @@ const displayedOptions = computed(() => {
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const base = ['input', 'input-bordered', 'w-full', 'pr-10']
|
||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||
if (props.size === 'xs') base.push('input-xs')
|
||||
if (props.size === 'sm') base.push('input-sm')
|
||||
if (props.size === 'lg') base.push('input-lg')
|
||||
@@ -269,9 +284,17 @@ function handleInput () {
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
function clearSelection () {
|
||||
emit('update:modelValue', '')
|
||||
searchTerm.value = ''
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (selectedOption.value) {
|
||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||
emit('update:modelValue', '')
|
||||
} else if (selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||
|
||||
import IconLucideCpu from '~icons/lucide/cpu'
|
||||
import IconLucidePuzzle from '~icons/lucide/puzzle'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
@@ -280,7 +280,6 @@ interface NavGroup {
|
||||
const simpleLinks: NavLink[] = [
|
||||
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||
{ to: '/machine-skeleton', label: 'Squelettes', icon: IconLucideClipboardList },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
|
||||
207
app/components/machine/AddEntityToMachineModal.vue
Normal file
207
app/components/machine/AddEntityToMachineModal.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box max-w-xl w-full" style="overflow: visible">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
|
||||
@click="handleClose"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h3 class="font-bold text-lg mb-6">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- Step 1: Choose category -->
|
||||
<div class="form-control mb-5" style="position: relative; z-index: 20">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">Catégorie</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedTypeId"
|
||||
:options="types"
|
||||
:loading="loadingTypes"
|
||||
:max-visible="8"
|
||||
placeholder="Rechercher une catégorie..."
|
||||
empty-text="Aucune catégorie disponible"
|
||||
:option-label="(t: any) => t.name"
|
||||
:option-description="(t: any) => t.code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Choose entity (visible only after category selected) -->
|
||||
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">{{ entityLabel }}</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedEntityId"
|
||||
:options="entities"
|
||||
:loading="loadingEntities"
|
||||
:max-visible="8"
|
||||
:placeholder="`Rechercher ${entityLabelLower}...`"
|
||||
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
||||
:option-label="entityOptionLabel"
|
||||
:option-description="entityOptionDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary of selection -->
|
||||
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
|
||||
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
|
||||
Réf : {{ selectedEntitySummary.reference }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
|
||||
<button type="button" class="btn btn-ghost" @click="handleClose">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedEntityId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
|
||||
type EntityKind = 'component' | 'piece' | 'product'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityKind: EntityKind
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [entityId: string]
|
||||
}>()
|
||||
|
||||
const selectedTypeId = ref('')
|
||||
const selectedEntityId = ref('')
|
||||
const loadingEntities = ref(false)
|
||||
const entities = ref<any[]>([])
|
||||
|
||||
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
|
||||
const { loadComposants } = useComposants()
|
||||
const { loadPieces } = usePieces()
|
||||
const { loadProducts } = useProducts()
|
||||
|
||||
const title = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Ajouter un composant',
|
||||
piece: 'Ajouter une pièce',
|
||||
product: 'Ajouter un produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabel = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Composant',
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
|
||||
|
||||
const types = computed(() => {
|
||||
if (props.entityKind === 'component') return componentTypes.value
|
||||
if (props.entityKind === 'piece') return pieceTypes.value
|
||||
return productTypes.value
|
||||
})
|
||||
|
||||
const loadingTypes = computed(() => {
|
||||
if (props.entityKind === 'component') return loadingComponentTypes.value
|
||||
if (props.entityKind === 'piece') return loadingPieceTypes.value
|
||||
return loadingProductTypes.value
|
||||
})
|
||||
|
||||
const selectedTypeName = computed(() => {
|
||||
if (!selectedTypeId.value) return ''
|
||||
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
|
||||
return found?.name || ''
|
||||
})
|
||||
|
||||
const entityOptionLabel = (e: any) => e.name || '(sans nom)'
|
||||
const entityOptionDescription = (e: any) => e.reference || ''
|
||||
|
||||
const selectedEntitySummary = computed(() => {
|
||||
if (!selectedEntityId.value || !entities.value.length) return null
|
||||
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
|
||||
if (!found) return null
|
||||
return { name: found.name || '(sans nom)', reference: found.reference || null }
|
||||
})
|
||||
|
||||
// Load types when modal opens
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
if (props.entityKind === 'component') await loadComponentTypes()
|
||||
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||
else await loadProductTypes()
|
||||
})
|
||||
|
||||
// Load entities when type changes
|
||||
watch(selectedTypeId, async () => {
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
|
||||
if (!selectedTypeName.value) return
|
||||
|
||||
loadingEntities.value = true
|
||||
try {
|
||||
if (props.entityKind === 'component') {
|
||||
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else if (props.entityKind === 'piece') {
|
||||
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else {
|
||||
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
}
|
||||
} finally {
|
||||
loadingEntities.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedEntityId.value) return
|
||||
emit('confirm', selectedEntityId.value)
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
selectedTypeId.value = ''
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
}
|
||||
</script>
|
||||
@@ -3,32 +3,57 @@
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Composants</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-component')"
|
||||
>
|
||||
Ajouter un composant
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComponentHierarchy
|
||||
:components="components"
|
||||
:is-edit-mode="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-component', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
/>
|
||||
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucun composant associé à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="component in components" :key="component.id" class="relative">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
|
||||
title="Supprimer ce composant"
|
||||
@click="$emit('remove-component', component.linkId || component.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<ComponentHierarchy
|
||||
:components="[component]"
|
||||
:is-edit-mode="false"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,5 +74,7 @@ defineEmits<{
|
||||
'update-component': [component: any]
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-component': []
|
||||
'remove-component': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -4,25 +4,6 @@
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<div class="btn-group w-full max-w-xs print:hidden" data-print-hide>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="isDetailsView ? 'btn-primary' : 'btn-outline'"
|
||||
@click="$emit('change-view', 'details')"
|
||||
>
|
||||
Vue machine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="isSkeletonView ? 'btn-primary' : 'btn-outline'"
|
||||
:disabled="!hasSkeletonRequirements"
|
||||
@click="$emit('change-view', 'skeleton')"
|
||||
>
|
||||
Squelette
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||
<button
|
||||
@@ -43,7 +24,7 @@
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isDetailsView && !isEditMode"
|
||||
v-if="!isEditMode"
|
||||
@click="$emit('open-print')"
|
||||
type="button"
|
||||
class="btn btn-outline btn-secondary"
|
||||
@@ -60,18 +41,12 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucidePrinter from '~icons/lucide/printer'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
isDetailsView: boolean
|
||||
isSkeletonView: boolean
|
||||
isEditMode: boolean
|
||||
hasSkeletonRequirements: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'change-view': [view: 'details' | 'skeleton']
|
||||
'toggle-edit': []
|
||||
'open-print': []
|
||||
}>()
|
||||
|
||||
@@ -3,32 +3,75 @@
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Pièces de la machine</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-piece')"
|
||||
>
|
||||
Ajouter une pièce
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<PieceItem
|
||||
v-for="piece in pieces"
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode"
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
/>
|
||||
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucune pièce associée à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="piece in pieces" :key="piece.id" class="relative">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
|
||||
title="Supprimer cette pièce"
|
||||
@click="$emit('remove-piece', piece.linkId || piece.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<PieceItem
|
||||
:piece="piece"
|
||||
:is-edit-mode="false"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
|
||||
defineProps<{
|
||||
pieces: any[]
|
||||
isEditMode: boolean
|
||||
collapsed: boolean
|
||||
collapseToggleToken: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update-piece': [piece: any]
|
||||
'toggle-collapse': []
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-piece': []
|
||||
'remove-piece': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="previewDocumentList"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Produits associés</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
Produits sélectionnés directement pour cette machine selon le squelette.
|
||||
Produits sélectionnés directement pour cette machine.
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline" v-if="products.length">
|
||||
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-product')"
|
||||
>
|
||||
Ajouter un produit
|
||||
</button>
|
||||
<span class="badge badge-outline" v-if="products.length">
|
||||
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="products.length" class="space-y-3">
|
||||
<div
|
||||
v-for="product in products"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-1"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2 relative"
|
||||
>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs absolute top-2 right-2"
|
||||
title="Supprimer ce produit"
|
||||
@click="$emit('remove-product', (product.linkId || product.id) as string)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<p class="font-semibold text-base-content">
|
||||
{{ product.name }}
|
||||
</p>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -39,6 +65,53 @@
|
||||
<span class="font-medium">Prix indicatif :</span>
|
||||
<span class="ml-1">{{ product.priceLabel }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Documents liés au produit -->
|
||||
<div v-if="product.documents?.length" class="mt-2 space-y-1">
|
||||
<p class="text-xs font-medium text-base-content/70">Documents :</p>
|
||||
<div
|
||||
v-for="doc in product.documents"
|
||||
:key="doc.id || doc.name"
|
||||
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
|
||||
>
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-4 w-4"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-base-content">{{ doc.name }}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="openPreview(doc, product.documents || [])"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(doc)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
@@ -49,14 +122,53 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
products: Array<{
|
||||
id?: string | null
|
||||
linkId?: string | null
|
||||
name?: string
|
||||
reference?: string | null
|
||||
supplierLabel?: string | null
|
||||
priceLabel?: string | null
|
||||
groupLabel?: string
|
||||
documents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
fileUrl?: string
|
||||
downloadUrl?: string
|
||||
}>
|
||||
}>
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-product': []
|
||||
'remove-product': [linkId: string]
|
||||
}>()
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewDocumentList = ref<any[]>([])
|
||||
|
||||
const openPreview = (doc: any, docs: any[]) => {
|
||||
previewDocument.value = doc
|
||||
previewDocumentList.value = docs
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="componentRequirementGroups.length || pieceRequirementGroups.length || productRequirementGroups.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>
|
||||
|
||||
<!-- Component requirement groups -->
|
||||
<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.parentComposantId" class="text-xs text-gray-500">
|
||||
(Sous-composant)
|
||||
</span>
|
||||
<div
|
||||
v-if="summarizeCustomFields(component.customFields || []).length"
|
||||
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
||||
>
|
||||
<span
|
||||
v-for="field in summarizeCustomFields(component.customFields || [])"
|
||||
:key="field.key"
|
||||
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
||||
>
|
||||
<span class="font-medium">{{ field.label }} :</span>
|
||||
<span class="ml-1">{{ field.value }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<SkeletonProductDisplay :product-display="component.__productDisplay" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">Aucun composant rattaché à ce groupe.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Piece requirement groups -->
|
||||
<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.parentComponentName" class="text-xs text-gray-500">
|
||||
(Rattachée à {{ piece.parentComponentName }})
|
||||
</span>
|
||||
<div
|
||||
v-if="summarizeCustomFields(piece.customFields || []).length"
|
||||
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
||||
>
|
||||
<span
|
||||
v-for="field in summarizeCustomFields(piece.customFields || [])"
|
||||
:key="field.key"
|
||||
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
||||
>
|
||||
<span class="font-medium">{{ field.label }} :</span>
|
||||
<span class="ml-1">{{ field.value }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<SkeletonProductDisplay :product-display="piece.__productDisplay" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">Aucune pièce rattachée à ce groupe.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product requirement groups -->
|
||||
<div v-if="productRequirementGroups.length" class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Produits requis</h3>
|
||||
<div
|
||||
v-for="group in productRequirementGroups"
|
||||
: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.typeProduct?.name || 'Groupe de produits' }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
Catégorie : {{ group.requirement.typeProduct?.name || 'Non définie' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-outline badge-sm">Total {{ group.totalCount }}</span>
|
||||
<span class="badge badge-ghost badge-sm">Direct {{ group.directProducts.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 mb-3">
|
||||
Via composants : {{ group.componentCount }} • Via pièces : {{ group.pieceCount }}
|
||||
</div>
|
||||
|
||||
<div v-if="group.directProducts.length" class="space-y-2">
|
||||
<div
|
||||
v-for="product in group.directProducts"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm"
|
||||
>
|
||||
<div class="font-medium">{{ product.name }}</div>
|
||||
<div v-if="product.reference" class="text-xs text-gray-500">
|
||||
Référence : {{ product.reference }}
|
||||
</div>
|
||||
<div v-if="product.supplierLabel" class="text-xs text-gray-500">
|
||||
Fournisseurs : {{ product.supplierLabel }}
|
||||
</div>
|
||||
<div v-if="product.priceLabel" class="text-xs text-gray-500">
|
||||
Prix indicatif : {{ product.priceLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Aucune sélection directe. Couverture assurée via composants ou pièces associés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { summarizeCustomFields } from '~/shared/utils/customFieldUtils'
|
||||
|
||||
defineProps<{
|
||||
componentRequirementGroups: any[]
|
||||
pieceRequirementGroups: any[]
|
||||
productRequirementGroups: any[]
|
||||
}>()
|
||||
|
||||
const SkeletonProductDisplay = defineComponent({
|
||||
name: 'SkeletonProductDisplay',
|
||||
props: {
|
||||
productDisplay: { type: Object, default: null },
|
||||
},
|
||||
template: `
|
||||
<div v-if="productDisplay" class="w-full text-xs text-gray-600 space-y-1">
|
||||
<div><span class="font-medium">Produit :</span> <span>{{ productDisplay.name }}</span></div>
|
||||
<div v-if="productDisplay.category"><span class="font-medium">Catégorie :</span> <span>{{ productDisplay.category }}</span></div>
|
||||
<div v-if="productDisplay.reference"><span class="font-medium">Référence :</span> <span>{{ productDisplay.reference }}</span></div>
|
||||
<div v-if="productDisplay.suppliers"><span class="font-medium">Fournisseurs :</span> <span>{{ productDisplay.suppliers }}</span></div>
|
||||
<div v-if="productDisplay.price"><span class="font-medium">Prix indicatif :</span> <span>{{ productDisplay.price }}</span></div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
</script>
|
||||
@@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<div v-if="preview" class="space-y-4">
|
||||
<div class="border border-base-200 rounded-lg bg-base-100/80">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<IconLucideEye class="w-4 h-4" aria-hidden="true" />
|
||||
<span>Prévisualisation avant création</span>
|
||||
</div>
|
||||
<span class="badge" :class="getStatusBadgeClass(preview.status)">
|
||||
{{ preview.status === 'ready' ? 'Prête à créer' : preview.status === 'warning' ? 'À compléter' : 'Bloquante' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="field in preview.base.fields"
|
||||
:key="field.key"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<span class="text-[11px] uppercase tracking-wide text-gray-500">{{ field.label }}</span>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="field.status === 'missing'
|
||||
? 'text-error'
|
||||
: field.status === 'optional'
|
||||
? 'text-gray-500 italic'
|
||||
: 'text-gray-900'"
|
||||
>
|
||||
{{ field.display }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<span class="badge badge-ghost badge-sm">Type : {{ preview.type.name }}</span>
|
||||
<span v-if="preview.type.category" class="badge badge-ghost badge-sm">Catégorie : {{ preview.type.category }}</span>
|
||||
<span class="badge badge-ghost badge-sm">Structure JSON : {{ preview.type.hasStructuredDefinition ? 'Oui' : 'Legacy' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Base issues -->
|
||||
<div v-if="preview.base.issues.length" class="rounded-md bg-warning/10 border border-warning/30 p-3 text-xs text-warning">
|
||||
<p class="font-medium mb-1">
|
||||
Informations générales incomplètes :
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="issue in preview.base.issues" :key="issue.message">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 text-left hover:underline"
|
||||
@click="handleIssueClick(issue)"
|
||||
>
|
||||
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
||||
<span>{{ issue.message }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Component groups -->
|
||||
<div v-if="preview.componentGroups.length" class="space-y-3">
|
||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Composants hérités
|
||||
</h5>
|
||||
<PreviewRequirementGroup
|
||||
v-for="group in preview.componentGroups"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500">
|
||||
Aucun composant n'est requis pour ce type de machine.
|
||||
</div>
|
||||
|
||||
<!-- Piece groups -->
|
||||
<div v-if="preview.pieceGroups.length" class="space-y-3">
|
||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Pièces associées
|
||||
</h5>
|
||||
<PreviewRequirementGroup
|
||||
v-for="group in preview.pieceGroups"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500">
|
||||
Aucun groupe de pièces à configurer pour ce type.
|
||||
</div>
|
||||
|
||||
<!-- Product groups -->
|
||||
<div v-if="preview.productGroups.length" class="space-y-3">
|
||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Produits requis
|
||||
</h5>
|
||||
<div
|
||||
v-for="group in preview.productGroups"
|
||||
:key="group.id"
|
||||
:id="`product-group-${group.id}`"
|
||||
class="border border-base-200 rounded-md p-3 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">
|
||||
{{ group.label }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
|
||||
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
||||
Couverture : {{ group.count }}
|
||||
</span>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
Direct {{ group.completed }} / {{ group.total || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<li v-for="issue in group.issues" :key="issue.message">
|
||||
{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul v-if="group.entries?.length" class="space-y-2">
|
||||
<li
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.key"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<component
|
||||
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
||||
class="w-4 h-4 mt-0.5"
|
||||
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
||||
{{ entry.title }}
|
||||
</p>
|
||||
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
||||
{{ entry.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Couverture assurée via composants ou pièces liés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global issues -->
|
||||
<div
|
||||
v-if="preview.issues.length && preview.status !== 'ready'"
|
||||
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<IconLucideAlertTriangle class="w-4 h-4 mt-0.5" aria-hidden="true" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium">
|
||||
Points à vérifier avant la création :
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="issue in preview.issues" :key="`${issue.scope}-${issue.message}`">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 text-left hover:underline"
|
||||
@click="handleIssueClick(issue)"
|
||||
>
|
||||
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
||||
<span>
|
||||
<span class="font-medium">{{ issue.scope }} :</span> {{ issue.message }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getStatusBadgeClass,
|
||||
handleIssueClick,
|
||||
} from '~/composables/useMachineCreatePreview'
|
||||
import PreviewRequirementGroup from './PreviewRequirementGroup.vue'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||
import IconLucideCircle from '~icons/lucide/circle'
|
||||
|
||||
defineProps<{
|
||||
preview: any
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div class="border border-base-200 rounded-md p-3 space-y-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">
|
||||
{{ group.label }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ group.typeName }} · Min {{ group.min }} ·
|
||||
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
||||
{{ group.completed }} / {{ group.total || 0 }} complétée(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<li v-for="issue in group.issues" :key="issue.message">
|
||||
{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.key"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<component
|
||||
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
||||
class="w-4 h-4 mt-0.5"
|
||||
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
||||
{{ entry.title }}
|
||||
</p>
|
||||
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
||||
{{ entry.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getStatusBadgeClass } from '~/composables/useMachineCreatePreview'
|
||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||
import IconLucideCircle from '~icons/lucide/circle'
|
||||
|
||||
defineProps<{
|
||||
group: any
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div v-if="requirements?.length" class="space-y-4">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Sélection des composants
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-for="requirement in requirements"
|
||||
:id="`component-group-${requirement.id}`"
|
||||
:key="requirement.id"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h5 class="font-medium text-sm">
|
||||
{{ requirement.label || requirement.typeComposant?.name || 'Famille de composants' }}
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ requirement.typeComposant?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||
@click="$emit('add-entry', requirement)"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||
Aucun composant sélectionné pour ce groupe.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||
:key="`${requirement.id}-${entryIndex}`"
|
||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
Type appliqué :
|
||||
{{ resolveTypeLabel(requirement, entry) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-xs btn-error"
|
||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Composant existant</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
:model-value="entry.composantId || ''"
|
||||
:options="getOptions(requirement, entry)"
|
||||
:loading="loading"
|
||||
size="sm"
|
||||
placeholder="Rechercher un composant…"
|
||||
empty-text="Aucun composant disponible"
|
||||
:option-label="optionLabel"
|
||||
:option-description="optionDescription"
|
||||
@update:modelValue="$emit('set-component', requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="getOptions(requirement, entry).length === 0"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Aucun composant disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entry.composantId"
|
||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ findById(entry.composantId)?.name || "Composant" }}
|
||||
</div>
|
||||
<div>
|
||||
Référence : {{ findById(entry.composantId)?.reference || "—" }}
|
||||
</div>
|
||||
<div>
|
||||
Fournisseur :
|
||||
{{ findById(entry.composantId)?.constructeur?.name || findById(entry.composantId)?.constructeurName || "—" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
defineProps<{
|
||||
requirements: any[]
|
||||
loading: boolean
|
||||
getEntries: (requirementId: string) => any[]
|
||||
getOptions: (requirement: any, entry: any) => any[]
|
||||
resolveTypeLabel: (requirement: any, entry: any) => string
|
||||
findById: (id: string) => any
|
||||
optionLabel: (item: any) => string
|
||||
optionDescription: (item: any) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-entry': [requirement: any]
|
||||
'remove-entry': [requirementId: string, entryIndex: number]
|
||||
'set-component': [requirement: any, entryIndex: number, componentId: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,130 +0,0 @@
|
||||
<template>
|
||||
<div v-if="requirements?.length" class="space-y-4">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Sélection des pièces principales
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-for="requirement in requirements"
|
||||
:id="`piece-group-${requirement.id}`"
|
||||
:key="requirement.id"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h5 class="font-medium text-sm">
|
||||
{{ requirement.label || requirement.typePiece?.name || 'Groupe de pièces' }}
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ requirement.typePiece?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||
@click="$emit('add-entry', requirement)"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||
Aucune pièce sélectionnée pour ce groupe.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||
:key="`${requirement.id}-piece-${entryIndex}`"
|
||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
Type appliqué :
|
||||
{{ resolveTypeLabel(requirement, entry) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-xs btn-error"
|
||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Pièce existante</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
:model-value="entry.pieceId || ''"
|
||||
:options="getOptions(requirement, entry, entryIndex)"
|
||||
:loading="loading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
|
||||
size="sm"
|
||||
placeholder="Rechercher une pièce…"
|
||||
empty-text="Aucune pièce disponible"
|
||||
:option-label="optionLabel"
|
||||
:option-description="optionDescription"
|
||||
@search="(term: string) => $emit('search', requirement, entryIndex, term)"
|
||||
@update:modelValue="$emit('set-piece', requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="getOptions(requirement, entry, entryIndex).length === 0"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Aucune pièce disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entry.pieceId"
|
||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ findById(entry.pieceId)?.name || "Pièce" }}
|
||||
</div>
|
||||
<div>
|
||||
Référence : {{ findById(entry.pieceId)?.reference || "—" }}
|
||||
</div>
|
||||
<div>
|
||||
Fournisseur :
|
||||
{{ findById(entry.pieceId)?.constructeur?.name || findById(entry.pieceId)?.constructeurName || "—" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
defineProps<{
|
||||
requirements: any[]
|
||||
loading: boolean
|
||||
pieceLoadingByKey: Record<string, boolean>
|
||||
getEntries: (requirementId: string) => any[]
|
||||
getOptions: (requirement: any, entry: any, entryIndex: number) => any[]
|
||||
getPieceKey: (requirement: any, entryIndex: number) => string
|
||||
resolveTypeLabel: (requirement: any, entry: any) => string
|
||||
findById: (id: string) => any
|
||||
optionLabel: (item: any) => string
|
||||
optionDescription: (item: any) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-entry': [requirement: any]
|
||||
'remove-entry': [requirementId: string, entryIndex: number]
|
||||
'set-piece': [requirement: any, entryIndex: number, pieceId: string]
|
||||
'search': [requirement: any, entryIndex: number, term: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div v-if="requirements?.length" class="space-y-4">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Produits catalogue requis
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-for="requirement in requirements"
|
||||
:id="`product-group-${requirement.id}`"
|
||||
:key="requirement.id"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h5 class="font-medium text-sm">
|
||||
{{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="(requirement.allowNewModels ?? true) === false"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Sélection de produits existants uniquement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||
@click="$emit('add-entry', requirement)"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||
Aucun produit sélectionné pour ce groupe.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||
:key="`${requirement.id}-product-${entryIndex}`"
|
||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
Catégorie appliquée :
|
||||
{{ requirement.typeProduct?.name || 'Non définie' }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-xs btn-error"
|
||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Produit existant</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="entry.productId || ''"
|
||||
:type-product-id="requirement.typeProductId || requirement.typeProduct?.id || null"
|
||||
:placeholder="productsLoading ? 'Chargement…' : 'Sélectionner un produit…'"
|
||||
empty-text="Aucun produit disponible pour cette catégorie"
|
||||
:disabled="productsLoading"
|
||||
@update:modelValue="$emit('set-product', requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="!productsLoading && getProductOptions(requirement).length === 0"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entry.productId"
|
||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ findById(entry.productId)?.name || 'Produit' }}
|
||||
</div>
|
||||
<div>
|
||||
Référence : {{ findById(entry.productId)?.reference || "—" }}
|
||||
</div>
|
||||
<div>
|
||||
Prix indicatif :
|
||||
<span
|
||||
v-if="findById(entry.productId)?.supplierPrice !== undefined && findById(entry.productId)?.supplierPrice !== null"
|
||||
>
|
||||
{{ Number(findById(entry.productId)?.supplierPrice).toFixed(2) }} €
|
||||
</span>
|
||||
<span v-else>
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Fournisseurs :
|
||||
<span v-if="findById(entry.productId)?.constructeurs?.length">
|
||||
{{ findById(entry.productId)?.constructeurs.map((constructeur: any) => constructeur?.name).filter(Boolean).join(', ') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
defineProps<{
|
||||
requirements: any[]
|
||||
productsLoading: boolean
|
||||
getEntries: (requirementId: string) => any[]
|
||||
getProductOptions: (requirement: any) => any[]
|
||||
findById: (id: string) => any
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-entry': [requirement: any]
|
||||
'remove-entry': [requirementId: string, entryIndex: number]
|
||||
'set-product': [requirement: any, entryIndex: number, productId: string]
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user