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:
Matthieu
2026-03-05 17:25:23 +01:00
parent 6f1bac381d
commit 32d03b480d
49 changed files with 1058 additions and 6093 deletions

View File

@@ -887,7 +887,6 @@ const submitEdition = async () => {
updatedComponent.id,
[
updatedComponent?.typeComposant?.customFields,
updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -1219,7 +1219,6 @@ const saveCustomFieldValues = async (createdComponent: any) => {
}
registerDefinitions(createdComponent?.typeComposant?.customFields)
registerDefinitions(createdComponent?.typeMachineComponentRequirement?.typeComposant?.customFields)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) {

View File

@@ -49,35 +49,18 @@
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<span class="label-text">Site</span>
</label>
<select v-model="selectedType" class="select select-bordered">
<select v-model="selectedSiteFilter" class="select select-bordered">
<option value="">
Tous les types
Tous les sites
</option>
<option
v-for="type in machineTypes"
:key="type.id"
:value="type.id"
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie</span>
</label>
<select v-model="selectedCategory" class="select select-bordered">
<option value="">
Toutes les catégories
</option>
<option
v-for="category in categories"
:key="category"
:value="category"
>
{{ category }}
{{ site.name }}
</option>
</select>
</div>
@@ -208,27 +191,9 @@
<h4 class="font-semibold text-sm">
{{ machine.name }}
</h4>
<div
class="badge badge-sm"
:class="
getCategoryBadgeClass(machine.typeMachine?.category)
"
>
{{ machine.typeMachine?.category || "N/A" }}
</div>
</div>
<div class="space-y-1 text-xs text-gray-600">
<div class="flex items-center gap-1">
<IconLucideSettings2
class="w-3 h-3"
aria-hidden="true"
/>
<span>{{
machine.typeMachine?.name || "Type inconnu"
}}</span>
</div>
<div
v-if="machine.reference"
class="flex items-center gap-1"
@@ -373,78 +338,17 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
</label>
<select
v-model="newMachine.typeMachineId"
class="select select-bordered"
:disabled="!canEdit"
required
>
<option value="">
Sélectionner un type
</option>
<option
v-for="type in machineTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }} ({{ type.category }})
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
</div>
<!-- Type Preview -->
<div
v-if="selectedMachineType"
class="mb-4 p-4 bg-gray-50 rounded-lg"
>
<h4 class="font-semibold text-sm mb-2">
Structure du type sélectionné :
</h4>
<div class="text-xs space-y-1">
<div class="flex items-center gap-2">
<span class="font-medium">Familles de composants :</span>
<span class="badge badge-sm">{{
selectedMachineType.componentRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">Groupes de pièces :</span>
<span class="badge badge-sm">{{
selectedMachineType.pieceRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{
selectedMachineType.productRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{
selectedMachineType.category
}}</span>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
<div class="modal-action">
@@ -469,7 +373,6 @@
import { ref, reactive, onMounted, computed } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
@@ -479,22 +382,19 @@ import IconLucideUser from '~icons/lucide/user'
import IconLucidePhone from '~icons/lucide/phone'
import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false)
const searchTerm = ref('')
const selectedType = ref('')
const selectedCategory = ref('')
const selectedSiteFilter = ref('')
const collapsedSites = ref([])
const newSite = reactive({
@@ -509,48 +409,14 @@ const newSite = reactive({
const newMachine = reactive({
name: '',
siteId: '',
typeMachineId: '',
reference: ''
})
// Computed
const selectedMachineType = computed(() => {
if (!newMachine.typeMachineId) { return null }
return machineTypes.value.find(
type => type.id === newMachine.typeMachineId
)
})
const categories = computed(() => {
const cats = new Set()
machineTypes.value.forEach((type) => {
if (type.category) { cats.add(type.category) }
})
return Array.from(cats)
})
const machinesWithType = computed(() => {
return machines.value.map((machine) => {
const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
const resolvedTypeMachine = resolvedTypeMachineId
? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
: null
return {
...machine,
typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
typeMachine:
machine.typeMachine && typeof machine.typeMachine === 'object'
? machine.typeMachine
: resolvedTypeMachine
}
})
})
const machinesBySiteId = computed(() => {
const map = new Map()
machinesWithType.value.forEach((machine) => {
machines.value.forEach((machine) => {
const siteId = machine.siteId || extractRelationId(machine.site)
if (!siteId) { return }
@@ -588,6 +454,11 @@ const formatPhoneDisplay = (value) => {
const filteredSites = computed(() => {
let filtered = sitesWithMachines.value
// Filtrer par site
if (selectedSiteFilter.value) {
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
}
// Filtrer par terme de recherche
if (searchTerm.value) {
filtered = filtered.filter((site) => {
@@ -616,33 +487,6 @@ const filteredSites = computed(() => {
})
}
// Filtrer par type de machine
if (selectedType.value) {
filtered = filtered
.map(site => ({
...site,
machines:
site.machines?.filter(
machine => machine.typeMachineId === selectedType.value
) || []
}))
.filter(site => site.machines.length > 0)
}
// Filtrer par catégorie
if (selectedCategory.value) {
filtered = filtered
.map(site => ({
...site,
machines:
site.machines?.filter(
machine =>
machine.typeMachine?.category === selectedCategory.value
) || []
}))
.filter(site => site.machines.length > 0)
}
return filtered
})
@@ -670,27 +514,15 @@ const handleCreateSite = async () => {
}
const handleCreateMachine = async () => {
if (!selectedMachineType.value) {
console.error('Aucun type de machine sélectionné')
return
}
const machineData = {
const result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference
}
const result = await createMachineFromType(
machineData,
selectedMachineType.value
)
})
if (result.success) {
// Reset form
newMachine.name = ''
newMachine.siteId = ''
newMachine.typeMachineId = ''
newMachine.reference = ''
showAddMachineModal.value = false
await loadMachines()
@@ -745,19 +577,8 @@ const addMachineToSite = (site) => {
showAddMachineModal.value = true
}
const getCategoryBadgeClass = (category) => {
const classes = {
Production: 'badge-primary',
Transformation: 'badge-secondary',
Manutention: 'badge-accent',
Traitement: 'badge-info',
Contrôle: 'badge-warning'
}
return classes[category] || 'badge-neutral'
}
// Lifecycle
onMounted(async () => {
await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
await Promise.all([loadSites(), loadMachines()])
})
</script>

View File

@@ -1,164 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8">
<!-- Machine Types List -->
<div class="my-8">
<!-- Header with Add Button -->
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">Squelettes de machine</h2>
<NuxtLink to="/machine-skeleton/new" class="btn btn-primary">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Créer un type
</NuxtLink>
</div>
<!-- Categories Tabs -->
<div class="tabs tabs-boxed mb-6">
<a
v-for="category in categories"
:key="category"
class="tab"
:class="{ 'tab-active': selectedCategory === category }"
@click="selectedCategory = category"
>
{{ category }}
</a>
</div>
<!-- Machine Types Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="type in filteredTypes"
:key="type.id"
class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer"
>
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg">
{{ type.name }}
</h3>
<div class="badge badge-primary">
{{ type.category }}
</div>
</div>
<p class="text-gray-600 mb-4">
{{ type.description }}
</p>
<div class="space-y-2 text-sm text-gray-500">
<div class="flex items-center gap-2">
<IconLucidePackage class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.componentRequirements?.length || 0 }} famille(s) de
composants</span
>
</div>
<div class="flex items-center gap-2">
<IconLucideLayoutGrid class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.pieceRequirements?.length || 0 }} groupe(s) de
pièces</span
>
</div>
<div class="flex items-center gap-2">
<IconLucideBox class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.productRequirements?.length || 0 }} produit(s)
requis</span
>
</div>
</div>
<div class="card-actions justify-end mt-4">
<button
v-if="canEdit"
class="btn btn-sm btn-error"
@click.stop="confirmDeleteType(type)"
>
Supprimer
</button>
<NuxtLink :to="`/type/${type.id}`" class="btn btn-sm btn-outline">
Voir détails
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="filteredTypes.length === 0" class="text-center py-12">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-16">
<IconLucideLayoutGrid class="w-8 h-8" aria-hidden="true" />
</div>
</div>
<h3 class="text-lg font-semibold text-gray-600 mt-4">
Aucun type trouvé
</h3>
<p class="text-gray-500">
Aucun type de machine ne correspond à cette catégorie.
</p>
</div>
</div>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
import { useToast } from "~/composables/useToast";
import { humanizeError } from "~/shared/utils/errorMessages";
import IconLucidePlus from "~icons/lucide/plus";
import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box";
const { canEdit } = usePermissions();
const { machineTypes, loadMachineTypes, deleteMachineType } =
useMachineTypesApi();
const categories = ref([
"Toutes",
"Production",
"Transformation",
"Manutention",
"Traitement",
"Contrôle",
]);
const selectedCategory = ref("Toutes");
const filteredTypes = computed(() => {
if (selectedCategory.value === "Toutes") {
return machineTypes.value;
}
return machineTypes.value.filter(
(type) => type.category === selectedCategory.value
);
});
const { confirm: confirmDialog } = useConfirm();
const confirmDeleteType = async (type) => {
const { showError, showSuccess } = useToast();
if (
await confirmDialog({
message: `Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.`,
})
) {
try {
const result = await deleteMachineType(type.id);
if (result.success) {
showSuccess(`Type "${type.name}" supprimé avec succès`);
} else {
showError(`Impossible de supprimer le type : ${result.error}`);
}
} catch (error) {
showError(`Impossible de supprimer le type : ${humanizeError(error.message)}`);
}
}
};
// Load machine types on mount
onMounted(async () => {
await loadMachineTypes();
});
</script>

View File

@@ -1,265 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8 space-y-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body space-y-6">
<div class="flex items-center gap-3">
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<IconLucidePlus
class="h-5 w-5"
aria-hidden="true"
/>
</span>
<div>
<h2 class="card-title text-2xl">
Nouveau type de machine
</h2>
<p class="text-sm text-gray-500">
Complétez les informations puis enregistrez pour générer le nouveau type.
</p>
</div>
</div>
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
<TypeEditForm
:key="formKey"
v-model="draftType"
:saving="!canEdit || creating"
:resettable="false"
submit-label="Créer le type"
submit-loading-label="Création..."
@submit="handleSubmit"
/>
</div>
</div>
</div>
<section class="space-y-4">
<div v-if="initialLoading" class="text-center py-12 text-sm text-gray-500">
Chargement des types existants...
</div>
<template v-else>
<div v-if="recentTypes.length" class="space-y-4">
<h3 class="text-xl font-semibold">
Types générés récemment
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<article
v-for="type in recentTypes"
:key="type.id"
class="card bg-base-100 shadow-md border border-base-200"
>
<div class="card-body space-y-2">
<div class="flex items-center justify-between">
<h4 class="card-title text-base">
{{ type.name }}
</h4>
<span v-if="type.category" class="badge badge-outline badge-sm">{{ type.category }}</span>
</div>
<p class="text-sm text-gray-600 line-clamp-3">
{{ type.description || 'Aucune description' }}
</p>
<div class="text-xs text-gray-500 flex items-center gap-2">
<span class="inline-flex items-center gap-1">
<IconLucideClipboardList class="h-4 w-4" aria-hidden="true" />
{{ type.componentRequirements?.length || 0 }} famille(s)
</span>
<span class="inline-flex items-center gap-1">
<IconLucideList class="h-4 w-4" aria-hidden="true" />
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
</span>
<span class="inline-flex items-center gap-1">
<IconLucideBox class="h-4 w-4" aria-hidden="true" />
{{ type.productRequirements?.length || 0 }} produit(s)
</span>
</div>
</div>
</article>
</div>
</div>
<div v-else class="text-center py-12 text-sm text-gray-500">
Aucun type généré récemment.
</div>
</template>
</section>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideList from '~icons/lucide/list'
import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast()
const { canEdit } = usePermissions()
const formKey = ref(0)
const creating = ref(false)
const initialLoading = ref(true)
const createEmptyType = () => ({
name: '',
description: '',
category: '',
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
pieceRequirements: [],
productRequirements: []
})
const draftType = ref(createEmptyType())
const recentTypes = computed(() => machineTypes.value.slice(-3).reverse())
onMounted(async () => {
if (!machineTypes.value.length) {
try {
initialLoading.value = true
await loadMachineTypes()
} finally {
initialLoading.value = false
}
} else {
initialLoading.value = false
}
})
const parseOptions = (field = {}) => {
if (field.type !== 'select') { return [] }
if (field.optionsText && typeof field.optionsText === 'string') {
return field.optionsText
.split('\n')
.map(option => option.trim())
.filter(Boolean)
}
if (Array.isArray(field.options)) {
return field.options
.map(option => String(option).trim())
.filter(Boolean)
}
return []
}
const toModelTypeIri = (value) => {
if (!value) {
return undefined
}
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
return value
}
const relationId = extractRelationId(value)
if (relationId) {
return `/api/model_types/${relationId}`
}
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
}
const normalizeCustomFields = (fields = []) =>
fields
.filter(field => field?.name && field.name.trim() !== '')
.map((field, index) => ({
name: field.name,
type: field.type || '',
required: !!field.required,
options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) {
return fallback
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
const normalizeComponentRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeComposantId || req?.typeComposant)
.map((req, index) => ({
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? true,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizePieceRequirements = (requirements = []) =>
requirements
.filter(req => req?.typePieceId || req?.typePiece)
.map((req, index) => ({
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId || req?.typeProduct)
.map((req, index) => ({
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const buildPayload = typeData => ({
name: typeData.name,
description: typeData.description,
category: typeData.category,
maintenanceFrequency: typeData.maintenanceFrequency,
customFields: normalizeCustomFields(typeData.customFields),
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
productRequirements: normalizeProductRequirements(typeData.productRequirements)
})
const resetForm = () => {
draftType.value = createEmptyType()
formKey.value += 1
}
const handleSubmit = async () => {
if (!draftType.value.name?.trim()) {
showError('Le nom du type est requis.')
return
}
const payload = buildPayload(draftType.value)
creating.value = true
const result = await createMachineType(payload)
creating.value = false
if (result?.success) {
resetForm()
} else if (result?.error) {
showError(result.error)
} else {
showError('Impossible de créer le type.')
}
}
</script>

View File

@@ -17,117 +17,120 @@
<!-- Header with actions -->
<MachineDetailHeader
:title="machineViewTitle"
:is-details-view="s.isDetailsView.value"
:is-skeleton-view="s.isSkeletonView.value"
:is-edit-mode="d.isEditMode.value"
:has-skeleton-requirements="d.machineHasSkeletonRequirements.value"
@change-view="s.changeMachineView"
@toggle-edit="d.toggleEditMode"
@open-print="d.openPrintModal"
/>
<template v-if="s.isDetailsView.value">
<!-- Debug info -->
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
<p>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Debug info -->
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
<p>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description || d.machine.value.typeMachine?.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div v-if="d.machine.value.typeMachine?.category" class="badge badge-outline">
{{ d.machine.value.typeMachine?.category }}
</div>
<div v-if="d.machine.value.site?.name" class="badge badge-outline">
{{ d.machine.value.site?.name }}
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
{{ d.machine.value.reference }}
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div v-if="d.machine.value.site?.name" class="badge badge-outline">
{{ d.machine.value.site?.name }}
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
{{ d.machine.value.reference }}
</div>
</PageHero>
<!-- Machine Info Card -->
<MachineInfoCard
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField"
/>
<!-- Documents -->
<MachineDocumentsCard
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="d.removeMachineDocument"
/>
<!-- Produits associés -->
<MachineProductsCard :products="d.machineDirectProducts.value" />
<!-- Components Section -->
<MachineComponentsCard
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField"
/>
<!-- Machine Pieces Section -->
<MachinePiecesCard
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</template>
</PageHero>
<template v-else>
<MachineSkeletonSummary
:component-requirement-groups="d.componentRequirementGroups.value"
:piece-requirement-groups="d.pieceRequirementGroups.value"
:product-requirement-groups="d.productRequirementGroups.value"
<!-- Machine Info Card -->
<MachineInfoCard
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField"
/>
<!-- Documents -->
<MachineDocumentsCard
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="d.removeMachineDocument"
/>
<!-- Produits associés -->
<MachineProductsCard
:products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="d.removeProductLink"
/>
<!-- Components Section -->
<MachineComponentsCard
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField"
@add-component="openAddModal('component')"
@remove-component="d.removeComponentLink"
/>
<!-- Machine Pieces Section -->
<MachinePiecesCard
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value"
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField"
@add-piece="openAddModal('piece')"
@remove-piece="d.removePieceLink"
@toggle-collapse="d.toggleAllPieces"
/>
<!-- Add Entity Modal -->
<AddEntityToMachineModal
:open="addModalOpen"
:entity-kind="addModalKind"
@close="addModalOpen = false"
@confirm="handleAddEntity"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</template>
</div>
</div>
<!-- Error State -->
@@ -156,10 +159,9 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useMachineSkeletonEditor } from '~/composables/useMachineSkeletonEditor'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
@@ -169,7 +171,7 @@ import MachineDocumentsCard from '~/components/machine/MachineDocumentsCard.vue'
import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
import MachineSkeletonSummary from '~/components/machine/MachineSkeletonSummary.vue'
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute()
@@ -182,41 +184,25 @@ if (!machineId) {
const d = useMachineDetailData(machineId)
const s = useMachineSkeletonEditor({
machine: d.machine,
components: d.components,
pieces: d.pieces,
machineComponentLinks: d.machineComponentLinks,
machinePieceLinks: d.machinePieceLinks,
machineProductLinks: d.machineProductLinks,
machineType: d.machineType,
machineHasSkeletonRequirements: d.machineHasSkeletonRequirements,
componentRequirements: d.componentRequirements,
pieceRequirements: d.pieceRequirements,
productRequirements: d.productRequirements,
componentTypeLabelMap: d.componentTypeLabelMap,
pieceTypeLabelMap: d.pieceTypeLabelMap,
productInventory: d.productInventory,
flattenedComponents: d.flattenedComponents,
machinePieces: d.machinePieces,
machineDocumentsLoaded: d.machineDocumentsLoaded,
findProductById: d.findProductById,
findComponentById: d.findComponentById,
findPieceById: d.findPieceById,
transformCustomFields: d.transformCustomFields,
transformComponentCustomFields: d.transformComponentCustomFields,
applyMachineLinks: d.applyMachineLinks,
collapseAllComponents: d.collapseAllComponents,
initMachineFields: d.initMachineFields,
collectPiecesForSkeleton: d.collectPiecesForSkeleton,
constructeurs: d.constructeurs,
loadProducts: d.loadProducts,
reconfigureMachineSkeleton: d.reconfigureMachineSkeleton,
toast: d.toast,
})
const addModalOpen = ref(false)
const addModalKind = ref('component')
const openAddModal = (kind) => {
addModalKind.value = kind
addModalOpen.value = true
}
const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
} else {
await d.addProductLink(entityId)
}
}
const machineViewTitle = computed(() => {
if (s.isSkeletonView.value) return 'Squelette de la machine'
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
})

View File

@@ -13,7 +13,7 @@
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
@@ -29,29 +29,14 @@
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<span class="label-text">Recherche</span>
</label>
<select v-model="selectedType" class="select select-bordered">
<option value="">
Tous les types
</option>
<option v-for="type in machineTypes" :key="type.id" :value="type.id">
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie</span>
</label>
<select v-model="selectedCategory" class="select select-bordered">
<option value="">
Toutes les catégories
</option>
<option v-for="category in categories" :key="category" :value="category">
{{ category }}
</option>
</select>
<input
v-model="searchQuery"
type="text"
placeholder="Rechercher par nom ou référence..."
class="input input-bordered"
>
</div>
</div>
</div>
@@ -88,26 +73,14 @@
<h3 class="card-title text-lg">
{{ machine.name }}
</h3>
<div class="badge badge-primary badge-sm">
{{ machine.typeMachine?.category || 'N/A' }}
</div>
</div>
<p class="text-sm text-gray-600 mb-3">
{{ machine.description || machine.typeMachine?.description }}
</p>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<IconLucideMapPin class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.site?.name || 'Site inconnu' }}</span>
</div>
<div class="flex items-center gap-2">
<IconLucideSettings2 class="w-4 h-4 text-green-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.typeMachine?.name || 'Type inconnu' }}</span>
</div>
<div v-if="machine.reference" class="flex items-center gap-2">
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.reference }}</span>
@@ -136,44 +109,28 @@
import { ref, computed, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
const { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const toast = useToast()
const selectedSite = ref('')
const selectedType = ref('')
const selectedCategory = ref('')
const searchQuery = ref('')
const categories = computed(() => {
const cats = new Set()
machineTypes.value.forEach((type) => {
if (type.category) {
cats.add(type.category)
}
})
return Array.from(cats)
})
// Enrichir les machines avec les objets site et typeMachine complets
// Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => {
return machines.value.map((machine) => {
const site = sites.value.find(s => s.id === machine.siteId)
const typeMachine = machineTypes.value.find(t => t.id === machine.typeMachineId)
return {
...machine,
site: site || null,
typeMachine: typeMachine || null
}
})
})
@@ -185,12 +142,12 @@ const filteredMachines = computed(() => {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
}
if (selectedType.value) {
filtered = filtered.filter(machine => machine.typeMachineId === selectedType.value)
}
if (selectedCategory.value) {
filtered = filtered.filter(machine => machine.typeMachine?.category === selectedCategory.value)
if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine =>
machine.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term),
)
}
return filtered
@@ -227,7 +184,6 @@ onMounted(async () => {
await Promise.all([
loadMachines(),
loadSites(),
loadMachineTypes()
])
})
</script>

View File

@@ -1,13 +1,13 @@
<template>
<main class="container mx-auto px-6 py-8">
<div class="max-w-5xl mx-auto">
<div class="max-w-3xl mx-auto">
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<div>
<h1 class="text-2xl font-bold">
Nouvelle machine
</h1>
<p class="text-sm text-gray-500">
Renseignez les informations et la configuration avant de créer la machine.
Renseignez les informations de base pour créer la machine.
</p>
</div>
<NuxtLink to="/machines" class="btn btn-ghost">
@@ -15,21 +15,25 @@
</NuxtLink>
</div>
<form class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div v-if="c.loading" class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg" />
</div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-6">
<!-- Basic fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="machine-field-name">
<span class="label-text">Nom de la machine</span>
<span class="label-text">Nom de la machine <span class="text-error">*</span></span>
</label>
<input
id="machine-field-name"
v-model="c.newMachine.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
required
>
@@ -37,9 +41,15 @@
<div class="form-control">
<label class="label" for="machine-field-site">
<span class="label-text">Site</span>
<span class="label-text">Site <span class="text-error">*</span></span>
</label>
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" :disabled="!canEdit" required>
<select
id="machine-field-site"
v-model="c.newMachine.siteId"
class="select select-bordered select-sm md:select-md"
:disabled="!canEdit"
required
>
<option value="">
Sélectionner un site
</option>
@@ -52,129 +62,43 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<label class="label" for="machine-field-reference">
<span class="label-text">Référence</span>
</label>
<SearchSelect
v-model="c.newMachine.typeMachineId"
:options="c.machineTypes"
:loading="c.machineTypesLoading"
<input
id="machine-field-reference"
v-model="c.newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
placeholder="Rechercher un type…"
empty-text="Aucun type trouvé"
:option-label="c.machineTypeLabel"
:option-description="c.machineTypeDescription"
/>
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
<span class="label-text">Cloner depuis une machine</span>
</label>
<input
v-model="c.newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
<SearchSelect
v-model="c.newMachine.cloneFromMachineId"
:options="c.machines"
:disabled="!canEdit"
>
:clearable="true"
placeholder="Rechercher une machine..."
empty-text="Aucune machine trouvée"
/>
</div>
</div>
<!-- Type structure summary -->
<div v-if="c.selectedMachineType" class="p-4 bg-gray-50 rounded-lg space-y-2 text-sm">
<h4 class="font-semibold text-sm">
Structure du type sélectionné :
</h4>
<div class="flex flex-wrap gap-3">
<span class="inline-flex items-center gap-2">
<span class="font-medium">Familles de composants :</span>
<span class="badge badge-sm">{{ c.selectedMachineType.componentRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Groupes de pièces :</span>
<span class="badge badge-sm">{{ c.selectedMachineType.pieceRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{ c.selectedMachineType.productRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{ c.selectedMachineType.category || 'N/A' }}</span>
</span>
</div>
<p
v-if="(c.selectedMachineType.componentRequirements?.length || 0) === 0 && (c.selectedMachineType.pieceRequirements?.length || 0) === 0 && (c.selectedMachineType.productRequirements?.length || 0) === 0"
class="text-xs text-gray-500"
>
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
</p>
</div>
<!-- Requirement selectors -->
<RequirementComponentSelector
:requirements="c.selectedMachineType?.componentRequirements || []"
:loading="c.composantsLoading"
:get-entries="c.getComponentRequirementEntries"
:get-options="c.getComponentOptions"
:resolve-type-label="c.resolveComponentRequirementTypeLabel"
:find-by-id="c.findComponentById"
:option-label="c.componentOptionLabel"
:option-description="c.componentOptionDescription"
@add-entry="c.addComponentSelectionEntry"
@remove-entry="c.removeComponentSelectionEntry"
@set-component="c.setComponentRequirementComponent"
/>
<RequirementPieceSelector
:requirements="c.selectedMachineType?.pieceRequirements || []"
:loading="c.piecesLoading"
:piece-loading-by-key="c.pieceLoadingByKey"
:get-entries="c.getPieceRequirementEntries"
:get-options="c.getPieceOptions"
:get-piece-key="c.getPieceKey"
:resolve-type-label="c.resolvePieceRequirementTypeLabel"
:find-by-id="c.findPieceById"
:option-label="c.pieceOptionLabel"
:option-description="c.pieceOptionDescription"
@add-entry="c.addPieceSelectionEntry"
@remove-entry="c.removePieceSelectionEntry"
@set-piece="c.setPieceRequirementPiece"
@search="c.fetchPieceOptions"
/>
<RequirementProductSelector
:requirements="c.selectedMachineType?.productRequirements || []"
:products-loading="c.productsLoading"
:get-entries="c.getProductRequirementEntries"
:get-product-options="c.getProductOptions"
:find-by-id="c.findProductById"
@add-entry="c.addProductSelectionEntry"
@remove-entry="c.removeProductSelectionEntry"
@set-product="c.setProductRequirementProduct"
/>
<!-- Preview -->
<MachineCreatePreview :preview="c.machinePreview" />
<!-- Blocking issues warning -->
<div
v-if="c.blockingPreviewIssues.length"
class="text-xs text-error bg-error/10 border border-error/20 rounded-md px-3 py-2"
>
Compléter les informations bloquantes avant de créer la machine.
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
<NuxtLink to="/machines" class="btn btn-outline">
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
Annuler
</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="!canEdit || !c.canCreateMachine || c.submitting"
class="btn btn-sm md:btn-md btn-primary"
:disabled="!canEdit || !c.newMachine.name?.trim() || c.submitting"
:class="{ loading: c.submitting }"
>
Créer la machine
@@ -191,10 +115,6 @@
import { proxyRefs } from 'vue'
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
import SearchSelect from '~/components/common/SearchSelect.vue'
import RequirementComponentSelector from '~/components/machine/create/RequirementComponentSelector.vue'
import RequirementPieceSelector from '~/components/machine/create/RequirementPieceSelector.vue'
import RequirementProductSelector from '~/components/machine/create/RequirementProductSelector.vue'
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
const c = proxyRefs(useMachineCreatePage())
const { canEdit } = usePermissions()

View File

@@ -945,7 +945,6 @@ const submitEdition = async () => {
updatedPiece.id,
[
updatedPiece?.typePiece?.pieceCustomFields,
updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -573,7 +573,6 @@ const submitCreation = async () => {
createdPiece.id,
[
createdPiece?.typePiece?.pieceCustomFields,
createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -1,215 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8">
<!-- Loading State -->
<div v-if="loading" class="my-8 text-center">
<div class="loading loading-spinner loading-lg" />
<p class="mt-4 text-gray-600">
Chargement du type...
</p>
</div>
<!-- Type Details -->
<div v-else-if="type" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
{{ typePageTitle }}
</h2>
<div class="flex gap-2">
<NuxtLink :to="`/type/edit/${type.id}`" class="btn btn-secondary">
<IconLucideSquarePen class="w-4 h-4 mr-2" aria-hidden="true" />
Éditer complètement
</NuxtLink>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
</div>
<!-- Current Type Info -->
<TypeInfoDisplay :type="type" />
<!-- Familles de composants -->
<div v-if="componentRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Familles de composants
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.componentRequirements"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typeComposant?.name || 'Famille' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeComposant?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
</p>
</div>
</div>
</div>
<!-- Groupes de pièces -->
<div v-if="pieceRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Groupes de pièces
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.pieceRequirements"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typePiece?.name || 'Groupe' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typePiece?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
</p>
</div>
</div>
</div>
<!-- Produits requis -->
<div v-if="productRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Produits requis
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.productRequirements"
:key="requirement.id || requirement.typeProductId"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typeProduct?.name || 'Produit' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeProduct?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de produits autorisée' : 'Produits existants uniquement' }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Commentaires -->
<CommentSection
entity-type="machine_skeleton"
:entity-id="type.id"
:entity-name="type.name"
/>
</div>
<!-- Error State -->
<div v-else class="my-8 text-center">
<div class="alert alert-error">
<div>
<h3 class="font-bold">
Type non trouvé
</h3>
<p>Le type de machine demandé n'existe pas.</p>
</div>
</div>
<NuxtLink to="/machine-skeleton" class="btn btn-primary mt-4">
Retour aux types
</NuxtLink>
</div>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import IconLucideSquarePen from '~icons/lucide/square-pen'
const { canEdit } = usePermissions()
const route = useRoute()
const { getMachineTypeById } = useMachineTypesApi()
const { showError } = useToast()
const type = ref(null)
const loading = ref(true)
const isEditView = computed(() => {
const editQuery = Array.isArray(route.query.edit) ? route.query.edit[0] : route.query.edit
const modeQuery = Array.isArray(route.query.mode) ? route.query.mode[0] : route.query.mode
return editQuery === 'true' || editQuery === '1' || modeQuery === 'edit'
})
const typePageTitle = computed(() => {
const currentName = type.value?.name || 'Type de squelette'
return isEditView.value ? `Modification : ${currentName}` : currentName
})
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
const toDisplayCount = (value, fallback) => {
if (value === null || value === undefined) {
return fallback
}
return value
}
onMounted(async () => {
try {
const typeId = route.params.id
if (!typeId) {
showError('Aucun identifiant de type fourni')
loading.value = false
return
}
const result = await getMachineTypeById(typeId)
if (result.success) {
type.value = result.data
} else {
showError('Type non trouvé')
}
} catch (error) {
console.error('Erreur lors du chargement:', error)
showError('Erreur lors du chargement')
} finally {
loading.value = false
}
})
</script>

View File

@@ -1,265 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8">
<!-- Loading State -->
<div v-if="loading" class="my-8 text-center">
<div class="loading loading-spinner loading-lg" />
<p class="mt-4 text-gray-600">
Chargement du type...
</p>
</div>
<!-- Locked: machines linked -->
<div v-else-if="type && hasMachines" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
{{ type.name }}
</h2>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
<div class="alert alert-warning">
<IconLucideTriangleAlert class="w-5 h-5" />
<span>Ce squelette ne peut pas être modifié car des machines y sont rattachées.</span>
</div>
</div>
</div>
</div>
<!-- Edit Form -->
<div v-else-if="type" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
Modifier : {{ type.name }}
</h2>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
<TypeEditForm
v-model="editedType"
:saving="saving"
@submit="saveChanges"
/>
</div>
</div>
</div>
<!-- Error State -->
<div v-else class="my-8 text-center">
<div class="alert alert-error">
<div>
<h3 class="font-bold">
Type non trouvé
</h3>
<p>Le type de machine demandé n'existe pas.</p>
</div>
</div>
<NuxtLink to="/machine-skeleton" class="btn btn-primary mt-4">
Retour aux types
</NuxtLink>
</div>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
const { showSuccess, showError } = useToast()
const type = ref(null)
const loading = ref(true)
const saving = ref(false)
const hasMachines = computed(() => {
const machines = type.value?.machines
return Array.isArray(machines) && machines.length > 0
})
// Données éditées du type
const editedType = ref({
name: '',
description: '',
category: '',
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
pieceRequirements: [],
productRequirements: []
})
const parseOptions = (field = {}) => {
if (field.type !== 'select') { return [] }
if (field.optionsText && typeof field.optionsText === 'string') {
return field.optionsText
.split('\n')
.map(option => option.trim())
.filter(Boolean)
}
if (Array.isArray(field.options)) {
return field.options
.map(option => String(option).trim())
.filter(Boolean)
}
return []
}
const toModelTypeIri = (value) => {
if (!value) {
return undefined
}
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
return value
}
const relationId = extractRelationId(value)
if (relationId) {
return `/api/model_types/${relationId}`
}
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
}
const normalizeCustomFields = (fields = []) =>
fields
.filter(field => field?.name && field.name.trim() !== '')
.map((field, index) => ({
name: field.name,
type: field.type || '',
required: !!field.required,
options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) {
return fallback
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
const normalizeComponentRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeComposantId || req?.typeComposant)
.map((req, index) => ({
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? true,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizePieceRequirements = (requirements = []) =>
requirements
.filter(req => req?.typePieceId || req?.typePiece)
.map((req, index) => ({
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId || req?.typeProduct)
.map((req, index) => ({
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const saveChanges = async () => {
try {
saving.value = true
const currentEditedType = editedType.value
// Préparer les données pour l'API
const updatedType = {
...currentEditedType,
customFields: normalizeCustomFields(currentEditedType.customFields),
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
}
const result = await updateMachineType(type.value.id, updatedType)
if (result.success) {
showSuccess('Type mis à jour avec succès !')
router.push('/machine-skeleton')
} else {
showError('Erreur lors de la mise à jour du type')
}
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
showError('Erreur lors de la sauvegarde')
} finally {
saving.value = false
}
}
// Charger le type au montage
onMounted(async () => {
if (!canEdit.value) {
router.replace(`/type/${route.params.id}`)
return
}
try {
const typeId = route.params.id
const result = await getMachineTypeById(typeId, true)
if (result.success) {
type.value = result.data
// Initialiser les données éditées
editedType.value = {
name: type.value.name || '',
description: type.value.description || '',
category: type.value.category || '',
maintenanceFrequency: type.value.maintenanceFrequency || '',
customFields: type.value.customFields || [],
componentRequirements: type.value.componentRequirements || [],
pieceRequirements: type.value.pieceRequirements || [],
productRequirements: type.value.productRequirements || [],
}
} else {
console.error('Failed to load type:', result.error)
showError('Type non trouvé')
}
} catch (error) {
console.error('Erreur lors du chargement:', error)
showError('Erreur lors du chargement')
} finally {
loading.value = false
}
})
</script>