18 Commits

Author SHA1 Message Date
Matthieu
d4fc0f1fee fix(slots) : check API response before updating local state on slot selection
The save functions (savePieceSlotSelection, saveProductSlotSelection,
saveSubcomponentSlotSelection) were not checking result.success before
updating local state and showing success toast. Since useApi.patch()
never throws, the catch block was dead code and errors were silently
ignored while the UI showed success.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:31:19 +01:00
Matthieu
f8403ddfbc docs(changelog) : add v1.9.1 release notes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:53:21 +01:00
Matthieu
428da471d1 fix(component-edit) : force reload catalog to display pre-selected slot items
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:32:07 +01:00
Matthieu
271844efb1 feat(component-edit) : add interactive slot selectors for pieces, products and subcomponents
Replace read-only selections display with PieceSelect, ProductSelect, ComposantSelect
components that allow changing the assigned item in each slot directly from the edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:40:11 +01:00
Matthieu
07cad19988 feat(sync) : wire sync flow into category edit pages with confirmation modal 2026-03-13 13:57:58 +01:00
Matthieu
8dacad7a59 refactor(sync) : remove restrictedMode and add sync service + confirmation modal 2026-03-13 13:49:24 +01:00
Matthieu
5912216a89 fix(piece) : persist slot quantity on blur and send prix as string
- Save composant piece slot quantity via PATCH on blur
- Pass slotId through hierarchy and selection entries
- Send prix as string (not number) to match backend expectation
- Show quantity in view mode when > 1
- Allow quantity edit for all pieces (not just root-level)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:19:09 +01:00
Matthieu
139ba183de fix(custom-fields) : include orphan values with embedded definitions in edit pages
After JSON-to-tables migration, custom field definitions not linked to
a ModelType were invisible on edit pages because buildCustomFieldInputs
only mapped over structure definitions. Now also includes values whose
embedded customField definition has no matching structure entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:18:47 +01:00
Matthieu
9fef009610 feat(skeleton) : remove skeleton JSON field references — use structure API field directly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:07 +01:00
Matthieu
4a3bceffa1 feat(machine) : afficher quantité pièces + pièces incluses des composants
- MachinePiecesCard : passer isEditMode au PieceItem + forward event update
- useMachineHierarchy : mapper quantity depuis le backend + construire
  les pièces de structure du composant en lecture seule
- useMachineDetailUpdates : PATCH MachinePieceLink.quantity + fix reference null
- ComponentItem : séparer pièces liées / pièces incluses par défaut
- useEntityDocuments : skip chargement documents pour pièces de structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:22:20 +01:00
Matthieu
50d8dde6d5 fix(piece) : include structure in composant edit PATCH payload for quantity persistence 2026-03-12 15:02:09 +01:00
Matthieu
9b40f9f2c7 feat(piece) : add quantity display and input to composant edit page 2026-03-12 14:40:55 +01:00
Matthieu
721963449b feat(piece) : display and edit quantity on machine piece items 2026-03-12 14:32:50 +01:00
Matthieu
22ba9a8d05 feat(piece) : add quantity input to composant structure editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:11:58 +01:00
Matthieu
695d56a6d3 feat(piece) : add quantity field to piece types, sanitization and hydration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:08:43 +01:00
Matthieu
5c31045e83 fix(machine) : fix fournisseur display overflow in MachineInfoCard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:56:17 +01:00
Matthieu
b0124c11ba feat(ui) : add site colors, dark mode toggle and card styling improvements
- Site color field with color picker in create/edit modals
- Dark mode theme (mytheme-dark) with toggle in navbar
- Stronger site color visibility on cards (gradient, top border, badges)
- Bigger action buttons (btn-sm) on machine cards
- White card backgrounds with proper dark mode support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:51:29 +01:00
Matthieu
7e67b124f3 feat(machine) : allow site editing on machine detail page and align card buttons
- Add site select field in MachineInfoCard (edit mode)
- Include siteId in machine PATCH payload
- Align action buttons (Modifier/Supprimer/Détails) consistently at card bottom
- Use mt-auto + flex-col to push buttons to bottom across all machine cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:09:14 +01:00
62 changed files with 1780 additions and 1255 deletions

View File

@@ -56,6 +56,57 @@
--noise: 0;
}
@plugin "daisyui/theme" {
name: "mytheme-dark";
default: false;
prefersdark: true;
color-scheme: dark;
/* Surfaces — dark blue-gray */
--color-base-100: oklch(22% 0.015 260);
--color-base-200: oklch(18% 0.012 260);
--color-base-300: oklch(28% 0.018 260);
--color-base-content: oklch(92% 0.005 260);
/* Primary — Malio blue, brighter for dark */
--color-primary: oklch(55% 0.18 262);
--color-primary-content: oklch(98% 0.005 262);
/* Secondary — refined lavender */
--color-secondary: oklch(72% 0.06 275);
--color-secondary-content: oklch(22% 0.03 275);
/* Accent — warm amber-orange */
--color-accent: oklch(72% 0.17 55);
--color-accent-content: oklch(20% 0.04 55);
/* Neutral — lighter slate for dark mode */
--color-neutral: oklch(75% 0.02 260);
--color-neutral-content: oklch(18% 0.01 260);
/* Semantic */
--color-info: oklch(62% 0.14 255);
--color-info-content: oklch(98% 0.005 255);
--color-success: oklch(65% 0.19 150);
--color-success-content: oklch(98% 0.005 150);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(62% 0.22 25);
--color-error-content: oklch(98% 0.005 25);
/* Geometry — same as light */
--radius-selector: 0.75rem;
--radius-field: 0.375rem;
--radius-box: 0.625rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* ─── Typography ─── */
:root {
--font-heading: 'Outfit', system-ui, sans-serif;
@@ -139,6 +190,18 @@ h1, h2, h3, h4, h5, h6,
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.site-card {
background-color: oklch(100% 0 0);
}
[data-theme="mytheme-dark"] .site-card {
background-color: oklch(24% 0.015 260);
}
[data-theme="mytheme-dark"] .card {
border-color: oklch(30% 0.02 260 / 0.6);
}
.card:hover {
box-shadow:
0 4px 6px -1px oklch(22% 0.025 260 / 0.06),
@@ -153,6 +216,11 @@ h1, h2, h3, h4, h5, h6,
border-bottom: 1px solid oklch(91% 0.015 260 / 0.5);
}
[data-theme="mytheme-dark"] .navbar-glass {
background: oklch(22% 0.015 260 / 0.85);
border-bottom-color: oklch(30% 0.02 260 / 0.5);
}
/* ─── Buttons ─── */
.btn {
font-family: var(--font-heading);

View File

@@ -5,11 +5,13 @@
<ComponentItem
:component="component"
:is-edit-mode="isEditMode"
:show-delete="showDelete"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('delete')"
/>
</div>
</div>
@@ -27,6 +29,10 @@ defineProps({
type: Boolean,
default: false
},
showDelete: {
type: Boolean,
default: false
},
collapseAll: {
type: Boolean,
default: true
@@ -37,5 +43,5 @@ defineProps({
}
})
defineEmits(['update', 'edit-piece', 'custom-field-update'])
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="space-y-4">
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@@ -8,211 +8,175 @@
/>
<!-- Component Header -->
<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 du composant' : 'Replier les détails du composant'"
@click="toggleCollapse"
>
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} le composant</span>
</button>
<div class="flex-1">
<h3 class="text-lg font-semibold">
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
<IconLucideChevronRight
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
:class="{ 'rotate-90': !isCollapsed }"
aria-hidden="true"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold text-base-content truncate">
{{ component.name }}
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
<template v-if="componentConstructeursDisplay.length">
<span
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="badge badge-outline badge-sm"
>
{{ constructeur.name }}
</span>
</template>
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span>
</div>
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-xs text-base-content/50"
>
{{ constructeur.name }}
</span>
<span v-if="displayProductName" class="badge badge-info badge-xs">
{{ displayProductName }}
</span>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer ce composant"
@click.stop="$emit('delete')"
>
Supprimer
</button>
</div>
<div v-show="!isCollapsed" class="space-y-4">
<!-- Component Info Display - Editable or Read-only -->
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<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 font-medium">Nom</span></label>
<input
v-if="isEditMode"
v-model="component.name"
type="text"
class="input input-bordered input-sm"
@blur="updateComponent"
<!-- Expanded content -->
<div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only info -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
<div>
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
<p class="text-base-content">{{ component.name }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
<p class="text-base-content">{{ component.reference || '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
<p class="text-base-content">{{ component.prix ? `${component.prix}` : '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
<div v-if="componentConstructeursDisplay.length">
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-base-content"
>
<div v-else class="input input-bordered input-sm bg-base-200">
{{ component.name }}
</div>
{{ constructeur.name }}
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Référence</span></label>
<input
v-if="isEditMode"
v-model="component.reference"
type="text"
class="input input-bordered input-sm"
@blur="updateComponent"
<p v-else class="text-base-content"></p>
</div>
</div>
<!-- Product -->
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-xs text-base-content/40">Produit catalogue</p>
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/60"
>
<div v-else class="input input-bordered input-sm bg-base-200">
{{ component.reference || 'Non définie' }}
</div>
{{ info.label }} : {{ info.value }}
</p>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Prix</span></label>
<input
v-if="isEditMode"
v-model="component.prix"
type="number"
step="0.01"
class="input input-bordered input-sm"
@blur="updateComponent"
>
<div v-else class="input input-bordered input-sm bg-base-200">
{{ component.prix ? `${component.prix}` : 'Non défini' }}
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Fournisseur</span></label>
<ConstructeurSelect
v-if="isEditMode"
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
<div v-else class="input input-bordered input-sm bg-base-200">
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}/edit`"
class="btn btn-ghost btn-xs shrink-0"
>
Voir le produit
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<span class="font-medium">{{ constructeur.name }}</span>
<span
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-base-content/50"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span v-else class="font-medium">Non défini</span>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Produit catalogue</span>
</label>
<div class="input input-bordered input-sm bg-base-200 min-h-[2.75rem] flex flex-col justify-center space-y-1">
<template v-if="displayProduct">
<span class="font-semibold text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</span>
<span
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
{{ info.label }} : {{ info.value }}
</span>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}/edit`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</template>
<span v-else class="font-medium">Non défini</span>
</div>
<div
v-if="productDocuments.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h4 class="font-medium text-base-content">
Documents du produit
</h4>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.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-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields Display - Editable or Read-only -->
<!-- Custom Fields -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
@@ -220,18 +184,17 @@
@field-blur="updateComponentCustomField"
/>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="font-semibold text-sm text-base-content/80">
Documents
</h4>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement des documents...
Chargement...
</p>
<DocumentUpload
@@ -252,14 +215,14 @@
/>
</div>
<!-- Component Pieces -->
<div v-if="component.pieces && component.pieces.length > 0" class="space-y-2">
<h4 class="font-semibold text-base-content/80">
<!-- Component Pieces (real MachinePieceLinks) -->
<div v-if="linkedPieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces du composant
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in component.pieces"
v-for="piece in linkedPieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@@ -270,12 +233,27 @@
</div>
</div>
<!-- Structure pieces (read-only, from composant definition) -->
<div v-if="structurePieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces incluses par défaut
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in structurePieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="false"
/>
</div>
</div>
<!-- Sub Components -->
<div v-if="childComponents.length > 0" class="space-y-3">
<h4 class="font-semibold text-base-content/80">
<div v-if="childComponents.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Sous-composants
</h4>
<div class="space-y-3 pl-4 border-l-2 border-base-200">
</p>
<div class="space-y-2 pl-4 border-l-2 border-base-200">
<ComponentItem
v-for="subComponent in childComponents"
:key="subComponent.id"
@@ -321,11 +299,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
component: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update'])
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
// --- Shared composables ---
const {
@@ -377,6 +356,14 @@ const childComponents = computed(() => {
return Array.isArray(list) ? list : []
})
// --- Pieces split: real links vs structure definitions ---
const allPieces = computed(() => {
const list = props.component.pieces
return Array.isArray(list) ? list : []
})
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
// --- Constructeurs ---
const { constructeurs } = useConstructeurs()

View File

@@ -10,7 +10,6 @@
:locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
:restricted-mode="restrictedMode"
is-root
/>
</div>
@@ -56,10 +55,6 @@ const props = defineProps({
type: Number,
default: Infinity,
},
restrictedMode: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])

View File

@@ -0,0 +1,116 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="composantOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
option-label="name"
:disabled="disabled"
@update:modelValue="updateValue"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComposants } from '~/composables/useComposants'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typeComposantId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner un composant…',
emptyText: 'Aucun composant disponible',
helperText: '',
disabled: false,
typeComposantId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { composants, loading, loadComposants } = useComposants()
const composantOptions = computed(() => {
const baseOptions = Array.isArray(composants.value) ? composants.value : []
if (!props.typeComposantId) {
return baseOptions
}
const allowedTypeId = String(props.typeComposantId)
return baseOptions.filter((composant: any) => {
const typeId =
composant?.typeComposantId ||
composant?.typeComposant?.id ||
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => {
if (composantOptions.value.length === 0) {
loadComposants({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
})
watch(
() => props.modelValue,
(value) => {
if (typeof value === 'string' && value) {
const exists = composantOptions.value.some((c: any) => c.id === value)
if (!exists && !loading.value) {
loadComposants({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
}
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatDescription = (option: any) => {
const parts: string[] = []
if (option?.reference) {
parts.push(option.reference)
}
if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

View File

@@ -9,7 +9,7 @@
<!-- 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">
<div class="flex items-start gap-3 flex-1 min-w-0">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
@@ -21,9 +21,15 @@
<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">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold">
{{ pieceData.name }}
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
@@ -49,11 +55,37 @@
</div>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer cette pièce"
@click="$emit('delete')"
>
Supprimer
</button>
</div>
<div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control">
<label class="label">
<span class="label-text text-sm">Quantité</span>
</label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm md:input-md w-24"
@blur="updatePiece"
/>
</div>
<div v-else-if="displayQuantity > 1">
<span class="font-medium">Quantité:</span>
<span class="ml-2">{{ displayQuantity }}</span>
</div>
<div>
<span class="font-medium">Référence:</span>
<input
@@ -250,11 +282,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
// --- Local reactive data for editing ---
const pieceData = reactive({
@@ -262,6 +295,11 @@ const pieceData = reactive({
reference: props.piece.reference || '',
prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
})
// --- Products ---
@@ -422,13 +460,14 @@ const updatePiece = () => {
let parsedPrice = null
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
const numeric = Number(prixValue)
if (!Number.isNaN(numeric)) parsedPrice = numeric
if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
}
const product = selectedProduct.value ? { ...selectedProduct.value } : null
emit('update', {
...props.piece,
...pieceData,
prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
@@ -468,11 +507,12 @@ watch(
)
watch(
() => [props.piece.name, props.piece.reference, props.piece.prix],
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
},
)
@@ -480,6 +520,7 @@ onMounted(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments()

View File

@@ -29,7 +29,6 @@
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
:disabled="isProductLocked(product)"
@change="handleProductTypeSelect(product)"
>
<option value="">
@@ -46,26 +45,16 @@
</div>
</div>
<button
v-if="!isProductLocked(product)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</li>
</ul>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
@@ -111,7 +100,7 @@
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">
Texte
</option>
@@ -131,7 +120,7 @@
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
Obligatoire
</div>
@@ -140,27 +129,16 @@
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</li>
</ul>
@@ -183,7 +161,6 @@ defineOptions({ name: 'PieceModelStructureEditor' })
const props = defineProps<{
modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}>()
const emit = defineEmits<{
@@ -194,9 +171,6 @@ const {
fields,
products,
productTypeOptions,
restrictedMode,
isFieldLocked,
isProductLocked,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,

View File

@@ -0,0 +1,116 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="pieceOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
option-label="name"
:disabled="disabled"
@update:modelValue="updateValue"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieces } from '~/composables/usePieces'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typePieceId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner une pièce…',
emptyText: 'Aucune pièce disponible',
helperText: '',
disabled: false,
typePieceId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { pieces, loading, loadPieces } = usePieces()
const pieceOptions = computed(() => {
const baseOptions = Array.isArray(pieces.value) ? pieces.value : []
if (!props.typePieceId) {
return baseOptions
}
const allowedTypeId = String(props.typePieceId)
return baseOptions.filter((piece: any) => {
const typeId =
piece?.typePieceId ||
piece?.typePiece?.id ||
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => {
if (pieceOptions.value.length === 0) {
loadPieces({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
})
watch(
() => props.modelValue,
(value) => {
if (typeof value === 'string' && value) {
const exists = pieceOptions.value.some((piece: any) => piece.id === value)
if (!exists && !loading.value) {
loadPieces({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
}
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatDescription = (option: any) => {
const parts: string[] = []
if (option?.reference) {
parts.push(option.reference)
}
if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

View File

@@ -81,10 +81,10 @@ onMounted(() => {
watch(
() => props.modelValue,
(value) => {
if (typeof value === 'string') {
if (typeof value === 'string' && value) {
const exists = productOptions.value.some((product) => product.id === value)
if (!exists && productOptions.value.length === 0 && !loading.value) {
loadProducts().catch((error) => {
if (!exists && !loading.value) {
loadProducts({ force: true }).catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}

View File

@@ -109,7 +109,7 @@
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">Texte</option>
<option value="number">Nombre</option>
<option value="select">Liste</option>
@@ -118,7 +118,7 @@
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isCustomFieldLocked(index)" />
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</div>
<textarea
@@ -126,26 +126,15 @@
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isCustomFieldLocked(index)"
></textarea>
</div>
<button
v-if="!isCustomFieldLocked(index)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeCustomField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
@@ -189,7 +178,6 @@
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
:disabled="isProductLocked(index)"
@change="handleProductTypeSelect(product)"
>
<option value="">
@@ -205,22 +193,13 @@
</select>
</div>
</div>
<button v-if="!isProductLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
@@ -261,7 +240,6 @@
<select
v-model="piece.typePieceId"
class="select select-bordered select-xs"
:disabled="isPieceLocked(index)"
@change="handlePieceTypeSelect(piece)"
>
<option value="">
@@ -280,19 +258,26 @@
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Quantité</span></label>
<input
v-model.number="piece.quantity"
type="number"
:min="1"
step="1"
placeholder="Qté"
class="input input-bordered input-sm md:input-md w-20"
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
/>
</div>
</div>
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Cette pièce ne peut pas être supprimée">
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
@@ -334,14 +319,12 @@
:product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
:restricted-mode="restrictedMode"
:is-locked="isSubcomponentLocked(index)"
@remove="removeSubComponent(index)"
/>
</div>
</div>
<button
v-if="canManageSubcomponents && !restrictedMode"
v-if="canManageSubcomponents"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
@@ -374,7 +357,6 @@ const props = withDefaults(defineProps<{
lockedTypeLabel?: string
allowSubcomponents?: boolean
maxSubcomponentDepth?: number
restrictedMode?: boolean
isLocked?: boolean
}>(), {
depth: 0,
@@ -386,19 +368,13 @@ const props = withDefaults(defineProps<{
lockedTypeLabel: '',
allowSubcomponents: true,
maxSubcomponentDepth: Infinity,
restrictedMode: false,
isLocked: false,
})
const emit = defineEmits(['remove'])
const {
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
isLocked,
restrictedMode,
componentTypes,
pieceTypes,
productTypes,

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { SyncPreviewResult } from '~/services/modelTypes';
const props = defineProps<{
preview: SyncPreviewResult | null;
open: boolean;
loading: boolean;
}>();
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
const dialogRef = ref<HTMLDialogElement>();
watch(() => props.open, (isOpen) => {
if (isOpen) {
dialogRef.value?.showModal();
}
else {
dialogRef.value?.close();
}
});
const hasDeletions = computed(() => {
if (!props.preview) return false;
return Object.values(props.preview.deletions).some(v => v > 0);
});
const hasModifications = computed(() => {
if (!props.preview) return false;
return Object.values(props.preview.modifications).some(v => v > 0);
});
const totalAdditions = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.additions).reduce((sum, v) => sum + v, 0);
});
const totalDeletions = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.deletions).reduce((sum, v) => sum + v, 0);
});
const totalModifications = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.modifications).reduce((sum, v) => sum + v, 0);
});
function handleCancel() {
emit('cancel');
}
function handleConfirm() {
emit('confirm');
}
</script>
<template>
<dialog ref="dialogRef" class="modal" @close="handleCancel">
<div class="modal-box">
<h3 class="text-lg font-bold">
Synchronisation des éléments liés
</h3>
<div v-if="preview" class="py-4 space-y-3">
<p>
Cette modification impactera
<strong>{{ preview.itemCount }}</strong>
élément(s) lié(s).
</p>
<ul class="list-disc list-inside space-y-1">
<li v-if="totalAdditions > 0" class="text-success">
{{ totalAdditions }} ajout(s)
</li>
<li v-if="totalDeletions > 0" class="text-error">
{{ totalDeletions }} suppression(s)
</li>
<li v-if="totalModifications > 0" class="text-warning">
{{ totalModifications }} modification(s)
</li>
</ul>
<div v-if="hasDeletions" role="alert" class="alert alert-warning">
<span>Des éléments seront supprimés. Cette action est irréversible.</span>
</div>
<div v-if="hasModifications" role="alert" class="alert alert-info">
<span>Des valeurs de champs personnalisés seront réinitialisées.</span>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost" :disabled="loading" @click="handleCancel">
Annuler
</button>
<button class="btn btn-primary" :disabled="loading" @click="handleConfirm">
<span v-if="loading" class="loading loading-spinner loading-sm" />
Confirmer la synchronisation
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="handleCancel">
close
</button>
</form>
</dialog>
</template>

View File

@@ -10,6 +10,16 @@
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
</button>
</li>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@@ -163,6 +173,14 @@
<!-- Navbar end -->
<div class="navbar-end">
<div class="flex items-center gap-1.5">
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
:title="isDark ? 'Mode clair' : 'Mode sombre'"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
</button>
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
title="Paramètres d'affichage"
@@ -176,7 +194,7 @@
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-circle btn-sm avatar placeholder indicator"
class="indicator cursor-pointer"
>
<span
v-if="unresolvedCount > 0"
@@ -185,9 +203,9 @@
{{ unresolvedCount }}
</span>
<div
class="bg-primary text-primary-content rounded-full w-8 h-8 grid place-items-center"
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
>
<span class="text-xs font-semibold leading-none tracking-tight">
<span class="text-xs font-semibold">
{{ activeProfileInitials }}
</span>
</div>
@@ -257,6 +275,8 @@ import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link'
import IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{
@@ -334,6 +354,7 @@ const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = use
const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions()
const { fetchUnresolvedCount } = useComments()
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
const unresolvedCount = ref(0)
let pollInterval: ReturnType<typeof setInterval> | null = null
@@ -344,6 +365,7 @@ const refreshUnresolvedCount = async () => {
}
onMounted(() => {
initDarkMode()
refreshUnresolvedCount()
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
})

View File

@@ -25,22 +25,15 @@
</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>
<div v-for="component in components" :key="component.id">
<ComponentHierarchy
:components="[component]"
:is-edit-mode="false"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@edit-piece="$emit('edit-piece', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
/>
</div>
</div>

View File

@@ -84,7 +84,7 @@
<button
type="button"
class="btn btn-error btn-xs btn-square"
class="btn btn-ghost btn-xs btn-square text-error"
@click="$emit('remove-field', index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />

View File

@@ -0,0 +1,221 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="card-title">Champs personnalisés</h2>
<p class="text-xs text-gray-500">
Champs personnalisés propres à cette machine.
</p>
</div>
<span v-if="visibleCustomFields.length" class="badge badge-outline">
{{ visibleCustomFields.length }} champ{{ visibleCustomFields.length > 1 ? 's' : '' }}
</span>
</div>
<!-- View mode: display values -->
<template v-if="!isEditMode">
<div v-if="visibleCustomFields.length" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200">
{{ formatCustomFieldValue(field) }}
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">
Aucun champ personnalisé défini pour cette machine.
</p>
</template>
<!-- Edit mode: definition management + value editing -->
<template v-else>
<p v-if="!customFields.length" class="text-xs text-gray-500">
Aucun champ personnalisé défini.
</p>
<div v-else class="space-y-3">
<div
v-for="(field, index) in customFields"
:key="field.id || field.name || index"
class="border border-base-200 rounded-md p-3 space-y-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-2">
<!-- Definition fields -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<input
:value="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
/>
<select
:value="field.type || 'text'"
class="select select-bordered select-sm"
@change="handleDefinitionUpdate(field, 'type', ($event.target as HTMLSelectElement).value)"
>
<option value="text">Texte</option>
<option value="number">Nombre</option>
<option value="select">Liste</option>
<option value="boolean">Oui/Non</option>
<option value="date">Date</option>
</select>
<div class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="!!field.required"
@change="handleDefinitionUpdate(field, 'required', ($event.target as HTMLInputElement).checked)"
/>
Obligatoire
</div>
</div>
<!-- Options for select type -->
<textarea
v-if="(field.type || 'text') === 'select'"
:value="field.optionsText || (Array.isArray(field.options) ? field.options.join('\n') : '')"
class="textarea textarea-bordered textarea-sm h-20 w-full"
placeholder="Option 1&#10;Option 2"
@blur="handleOptionsUpdate(field, ($event.target as HTMLTextAreaElement).value)"
></textarea>
<!-- Value editing -->
<div class="pt-1 border-t border-base-200">
<label class="label py-0">
<span class="label-text text-xs text-base-content/60">Valeur</span>
</label>
<input
v-if="!field.type || field.type === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Valeur..."
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/>
<input
v-else-if="field.type === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm w-full"
placeholder="Valeur..."
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/>
<select
v-else-if="field.type === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm w-full"
@change="onSelectChange(field, ($event.target as HTMLSelectElement).value)"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label
v-else-if="field.type === 'boolean'"
class="flex items-center gap-3 cursor-pointer py-1"
>
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
>
<span
class="text-sm"
:class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'"
>
{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}
</span>
</label>
<input
v-else-if="field.type === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm w-full"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/>
</div>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-square flex-shrink-0 text-error"
title="Supprimer ce champ"
@click="$emit('delete-field', field.id || field.customFieldId)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-field')"
>
Ajouter un champ personnalisé
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideTrash from '~icons/lucide/trash'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
defineProps<{
customFields: any[]
visibleCustomFields: any[]
isEditMode: boolean
}>()
const emit = defineEmits<{
'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any]
'add-field': []
'delete-field': [fieldId: string]
'update-field-definition': [fieldId: string, data: Record<string, unknown>]
}>()
const handleDefinitionUpdate = (field: any, key: string, value: unknown) => {
const fieldId = field.id || field.customFieldId
if (!fieldId) return
emit('update-field-definition', fieldId, { ...field, [key]: value })
}
const handleOptionsUpdate = (field: any, raw: string) => {
const fieldId = field.id || field.customFieldId
if (!fieldId) return
const options = raw.split('\n').map((o: string) => o.trim()).filter((o: string) => o.length > 0)
emit('update-field-definition', fieldId, { ...field, options })
}
const onSelectChange = (field: any, value: string) => {
emit('set-custom-field-value', field, value)
emit('update-custom-field', field)
}
const onBooleanChange = (field: any, checked: boolean) => {
emit('set-custom-field-value', field, checked ? 'true' : 'false')
emit('update-custom-field', field)
}
</script>

View File

@@ -74,7 +74,7 @@
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs"
class="btn btn-ghost btn-xs text-error"
:disabled="uploading"
@click="$emit('remove', doc.id)"
>

View File

@@ -20,6 +20,29 @@
{{ machineName }}
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
</label>
<select
v-if="isEditMode"
:value="machineSiteId"
class="select select-bordered"
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"
>
<option value="">Sélectionner un site</option>
<option
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ site.name }}
</option>
</select>
<div v-else class="input input-bordered bg-base-200">
{{ machineSiteName || 'Non défini' }}
</div>
</div>
<div v-if="isEditMode || machineReference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
@@ -49,23 +72,23 @@
placeholder="Rechercher un ou plusieurs fournisseurs..."
@update:modelValue="$emit('update:constructeur-ids', $event)"
/>
<div v-else class="input input-bordered bg-base-200">
<div v-if="machineConstructeursDisplay.length" class="space-y-1">
<div
<div v-else class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
<div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2">
<span
v-for="constructeur in machineConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
class="badge badge-ghost gap-1"
>
<span class="font-medium">{{ constructeur.name }}</span>
{{ constructeur.name }}
<span
v-if="formatConstructeurContactSummary(constructeur)"
class="text-xs text-base-content/50"
class="text-xs opacity-60"
>
{{ formatConstructeurContactSummary(constructeur) }}
· {{ formatConstructeurContactSummary(constructeur) }}
</span>
</div>
</span>
</div>
<span v-else class="font-medium">Non défini</span>
<span v-else class="text-base-content/50">Non défini</span>
</div>
</div>
</div>
@@ -184,6 +207,9 @@ const props = defineProps<{
isEditMode: boolean
machineName: string
machineReference: string
machineSiteId: string
machineSiteName: string
sites: any[]
machineConstructeurIds: string[]
machineConstructeursDisplay: any[]
hasMachineConstructeur: boolean
@@ -196,6 +222,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:machine-name': [value: string]
'update:machine-reference': [value: string]
'update:machine-site-id': [value: string]
'update:constructeur-ids': [ids: unknown]
'blur-field': []
'set-custom-field-value': [field: any, value: unknown]

View File

@@ -25,22 +25,16 @@
</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>
<div v-for="piece in pieces" :key="piece.id">
<PieceItem
:piece="piece"
:is-edit-mode="false"
:is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
/>
</div>
</div>
@@ -69,6 +63,7 @@ defineProps<{
defineEmits<{
'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any]
'add-piece': []
'remove-piece': [linkId: string]

View File

@@ -24,24 +24,26 @@
<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-2 relative"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
>
<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 v-if="product.groupLabel" class="badge badge-ghost badge-sm">
{{ product.groupLabel }}
</span>
<div class="flex items-center gap-2">
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
{{ product.groupLabel }}
</span>
<button
v-if="isEditMode"
type="button"
class="btn btn-ghost btn-xs text-error"
title="Supprimer ce produit"
@click="$emit('remove-product', (product.linkId || product.id) as string)"
>
Supprimer
</button>
</div>
</div>
<p v-if="product.reference" class="text-xs text-base-content/70">
<span class="font-medium">Référence :</span>

View File

@@ -15,8 +15,7 @@
minlength="2"
maxlength="120"
required
:disabled="restrictedMode"
/>
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div>
<div>
@@ -48,7 +47,6 @@
rows="4"
name="notes"
maxlength="2000"
:disabled="restrictedMode"
></textarea>
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
</div>
@@ -83,7 +81,6 @@
v-model="componentStructure"
:allow-subcomponents="allowComponentSubcomponents"
:max-subcomponent-depth="componentSubcomponentMaxDepth"
:restricted-mode="restrictedMode"
/>
</div>
@@ -95,7 +92,7 @@
Aperçu :
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
<PieceModelStructureEditor v-model="pieceStructure" />
</div>
<div
@@ -106,30 +103,11 @@
Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
<PieceModelStructureEditor v-model="productStructure" />
</div>
</template>
</section>
<div
v-if="restrictedMode && restrictedModeMessage"
class="alert alert-info"
role="status"
aria-live="polite"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>{{ restrictedModeMessage }}</span>
</div>
<div
v-if="disableSubmit"
class="alert alert-warning"
role="alert"
aria-live="polite"
>
<span>{{ disableSubmitMessage }}</span>
</div>
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler
@@ -172,10 +150,6 @@ const props = withDefaults(defineProps<{
structureLoading?: boolean
allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number
disableSubmit?: boolean
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
readonly?: boolean
}>(), {
initialData: null,
@@ -184,10 +158,6 @@ const props = withDefaults(defineProps<{
structureLoading: false,
allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
readonly: false,
})
@@ -205,19 +175,7 @@ const componentSubcomponentMaxDepth = computed(() =>
? props.componentSubcomponentMaxDepth
: 1,
)
const disableSubmit = computed(() => props.disableSubmit === true)
const disableSubmitMessage = computed(() =>
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
)
const isReadonly = computed(() => props.readonly === true)
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
const restrictedModeMessage = computed(() =>
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
? props.restrictedModeMessage
: '',
)
const form = reactive<ModelTypePayload>({
name: '',
@@ -294,7 +252,7 @@ const resetForm = () => {
}
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || isReadonly.value)
const validate = () => {
errors.name = undefined

View File

@@ -1,11 +1,21 @@
<template>
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
<div
class="card site-card shadow-md hover:shadow-xl transition-shadow overflow-hidden"
:style="{
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
>
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg">
<h3 class="card-title text-lg text-base-content">
{{ site.name }}
</h3>
<div class="badge badge-primary badge-sm">
<div
class="badge font-bold"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ machineCount }} machines
</div>
</div>

View File

@@ -17,6 +17,46 @@
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Couleur</span>
</label>
<div v-if="siteRef.color" class="flex items-center gap-3">
<input
:value="siteRef.color"
type="color"
class="w-10 h-10 rounded cursor-pointer border border-base-300"
:disabled="disabled"
@input="(e: Event) => { siteRef.color = (e.target as HTMLInputElement).value }"
>
<input
v-model="siteRef.color"
type="text"
placeholder="#000000"
class="input input-bordered input-sm flex-1"
:disabled="disabled"
maxlength="7"
>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="disabled"
@click="siteRef.color = ''"
>
Effacer
</button>
</div>
<button
v-else
type="button"
class="btn btn-outline btn-sm w-fit"
:disabled="disabled"
@click="siteRef.color = '#3b82f6'"
>
Choisir une couleur
</button>
</div>
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
<div class="modal-action">
@@ -39,6 +79,7 @@ import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
type SiteForm = {
name: string
color: string
contactName: string
contactPhone: string
contactAddress: string

View File

@@ -20,6 +20,46 @@
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Couleur</span>
</label>
<div v-if="form.color" class="flex items-center gap-3">
<input
:value="form.color"
type="color"
class="w-10 h-10 rounded cursor-pointer border border-base-300"
:disabled="disabled"
@input="form.color = $event.target.value"
>
<input
v-model="form.color"
type="text"
placeholder="#000000"
class="input input-bordered input-sm flex-1"
:disabled="disabled"
maxlength="7"
>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="disabled"
@click="form.color = ''"
>
Effacer
</button>
</div>
<button
v-else
type="button"
class="btn btn-outline btn-sm w-fit"
:disabled="disabled"
@click="form.color = '#3b82f6'"
>
Choisir une couleur
</button>
</div>
<SiteContactFormFields :form="props.form" :disabled="disabled" />
<div class="border-t border-base-200 pt-4 space-y-4">

View File

@@ -1,114 +0,0 @@
import { computed, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
type GuardLabels = {
singular: string
plural: string
verifying: string
}
type GuardConfig = {
endpoint: string
filterKey: string
labels: GuardLabels
}
const extractTotal = (payload: any, fallbackLength: number) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
if (Array.isArray(payload?.member)) {
return payload.member.length
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member'].length
}
return fallbackLength
}
export function useCategoryEditGuard (config: GuardConfig) {
const { get } = useApi()
const { showInfo } = useToast()
const linkedCount = ref(0)
const linkedLoading = ref(false)
const loadLinkedCount = async (modelTypeId: string) => {
linkedLoading.value = true
try {
const params = new URLSearchParams()
params.set('itemsPerPage', '1')
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
const result = await get(`${config.endpoint}?${params.toString()}`)
if (!result.success) {
linkedCount.value = 0
return
}
const fallbackLength = Array.isArray(result.data?.member)
? result.data.member.length
: Array.isArray(result.data?.['hydra:member'])
? result.data['hydra:member'].length
: 0
linkedCount.value = extractTotal(result.data, fallbackLength)
} catch (_error) {
linkedCount.value = 0
} finally {
linkedLoading.value = false
}
}
const isRestrictedMode = computed(
() => !linkedLoading.value && linkedCount.value > 0,
)
const isSubmitBlocked = computed(
() => linkedLoading.value,
)
const restrictedModeMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
if (linkedCount.value <= 0) {
return ''
}
if (linkedCount.value === 1) {
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
}
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
})
const submitBlockMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
return ''
})
const guardSubmitOrNotify = () => {
if (!isSubmitBlocked.value) {
return false
}
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
return true
}
return {
linkedCount,
linkedLoading,
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
}
}

View File

@@ -7,6 +7,7 @@ import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
@@ -53,7 +54,7 @@ const historyFieldLabels: Record<string, string> = {
export function useComponentEdit(componentId: string) {
const { canEdit } = usePermissions()
const router = useRouter()
const { get } = useApi()
const { get, patch } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
@@ -269,6 +270,99 @@ export function useComponentEdit(componentId: string) {
}
})
// --- Slot selection entries (for selectors) ---
const pieceSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => ({
slotId: slot.slotId,
typePieceId: slot.typePieceId,
selectedPieceId: slot.selectedPieceId ?? null,
quantity: slot.quantity ?? 1,
position: slot.position ?? i,
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
}))
})
const productSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.products) return []
return (structure.products as any[]).map((slot: any, i: number) => ({
slotId: slot.slotId,
typeProductId: slot.typeProductId,
selectedProductId: slot.selectedProductId ?? null,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
}))
})
const subcomponentSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.subcomponents) return []
return (structure.subcomponents as any[]).map((slot: any, i: number) => ({
slotId: slot.slotId,
typeComposantId: slot.typeComposantId,
selectedComponentId: slot.selectedComponentId ?? null,
alias: slot.alias,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: slot.alias || `Sous-composant #${i + 1}`,
}))
})
const savePieceSlotSelection = async (slotId: string, selectedPieceId: string | null) => {
const result = await patch(`/composant-piece-slots/${slotId}`, { selectedPieceId })
if (result.success) {
const structure = component.value?.structure
if (structure?.pieces) {
const slot = (structure.pieces as any[]).find((s: any) => s.slotId === slotId)
if (slot) slot.selectedPieceId = selectedPieceId
}
toast.showSuccess('Pièce mise à jour')
}
}
const saveProductSlotSelection = async (slotId: string, selectedProductId: string | null) => {
const result = await patch(`/composant-product-slots/${slotId}`, { selectedProductId })
if (result.success) {
const structure = component.value?.structure
if (structure?.products) {
const slot = (structure.products as any[]).find((s: any) => s.slotId === slotId)
if (slot) slot.selectedProductId = selectedProductId
}
toast.showSuccess('Produit mis à jour')
}
}
const saveSubcomponentSlotSelection = async (slotId: string, selectedComposantId: string | null) => {
const result = await patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId })
if (result.success) {
const structure = component.value?.structure
if (structure?.subcomponents) {
const slot = (structure.subcomponents as any[]).find((s: any) => s.slotId === slotId)
if (slot) slot.selectedComponentId = selectedComposantId
}
toast.showSuccess('Sous-composant mis à jour')
}
}
const saveSlotQuantity = async (entry: SelectionEntry) => {
const slotId = entry.slotId
const quantity = typeof entry._definition?.quantity === 'number'
? Math.max(1, entry._definition.quantity)
: null
if (!slotId || quantity === null) return
try {
await patch(`/composant-piece-slots/${slotId}`, { quantity })
toast.showSuccess('Quantité mise à jour')
}
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
}
}
const submitEdition = async () => {
if (!component.value) {
return
@@ -407,14 +501,12 @@ export function useComponentEdit(componentId: string) {
])
loading.value = false
// Defer bulk catalog loads — only needed when component has structure selections
if (component.value?.structure) {
Promise.allSettled([
loadPieces({ itemsPerPage: 200 }),
loadProducts({ itemsPerPage: 200 }),
loadComposants({ itemsPerPage: 200 }),
]).catch(() => {})
}
// Load catalogs for slot selectors (force: true to bypass cache from list pages that load fewer items)
Promise.allSettled([
loadPieces({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200, force: true }),
loadComposants({ itemsPerPage: 200, force: true }),
]).catch(() => {})
})
return {
@@ -440,6 +532,9 @@ export function useComponentEdit(componentId: string) {
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
// History
history,
@@ -453,6 +548,10 @@ export function useComponentEdit(componentId: string) {
handleFilesAdded,
refreshDocuments,
submitEdition,
saveSlotQuantity,
savePieceSlotSelection,
saveProductSlotSelection,
saveSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,

View File

@@ -0,0 +1,26 @@
const isDark = ref(false)
export function useDarkMode() {
const toggle = () => {
isDark.value = !isDark.value
applyTheme()
}
const applyTheme = () => {
const theme = isDark.value ? 'mytheme-dark' : 'mytheme'
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}
const init = () => {
const saved = localStorage.getItem('theme')
if (saved === 'mytheme-dark') {
isDark.value = true
} else if (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDark.value = true
}
applyTheme()
}
return { isDark, toggle, init }
}

View File

@@ -56,7 +56,7 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations
const refreshDocuments = async () => {
const e = entity()
if (!e?.id) return
if (!e?.id || e._structurePiece) return
loadingDocuments.value = true
try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false })

View File

@@ -15,6 +15,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useSites } from '~/composables/useSites'
import { useMachinePrint } from '~/composables/useMachinePrint'
import {
resolveConstructeurs,
@@ -41,9 +42,10 @@ export function useMachineDetailData(machineId: string) {
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { upsertCustomFieldValue } = useCustomFields()
const { get } = useApi()
const { get, patch: apiPatch } = useApi()
const toast = useToast()
const { constructeurs, loadConstructeurs } = useConstructeurs()
const { sites, loadSites } = useSites()
const {
printModalOpen,
@@ -64,6 +66,7 @@ export function useMachineDetailData(machineId: string) {
// Machine fields
const machineName = ref('')
const machineReference = ref('')
const machineSiteId = ref('')
const machineConstructeurIds = ref<string[]>([])
const machineConstructeurId = computed({
@@ -229,6 +232,7 @@ export function useMachineDetailData(machineId: string) {
machine.value.constructeurs,
machine.value.constructeur,
)
machineSiteId.value = (machine.value.siteId as string) || (machine.value.site as AnyRecord)?.id as string || ''
}
}
@@ -255,6 +259,7 @@ export function useMachineDetailData(machineId: string) {
machine,
machineName,
machineReference,
machineSiteId,
machineConstructeurIds,
machineDocumentsLoaded,
machineComponentLinks,
@@ -269,6 +274,7 @@ export function useMachineDetailData(machineId: string) {
updateMachineApi,
updateComposantApi: updateComposantApi,
updatePieceApi,
apiPatch,
toast,
})
@@ -401,6 +407,7 @@ export function useMachineDetailData(machineId: string) {
loadConstructeurs(),
loadComponentTypes(),
loadPieceTypes(),
loadSites(),
])
}
@@ -419,8 +426,9 @@ export function useMachineDetailData(machineId: string) {
machineComponentLinks, machinePieceLinks, machineProductLinks,
// Machine fields
machineName, machineReference, machineConstructeurIds, machineConstructeurId,
machineName, machineReference, machineSiteId, machineConstructeurIds, machineConstructeurId,
machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur,
sites,
// UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,

View File

@@ -13,6 +13,7 @@ export interface UseMachineDetailUpdatesDeps {
machine: Ref<AnyRecord | null>
machineName: Ref<string>
machineReference: Ref<string>
machineSiteId: Ref<string>
machineConstructeurIds: Ref<string[]>
machineDocumentsLoaded: Ref<boolean>
machineComponentLinks: Ref<AnyRecord[]>
@@ -32,6 +33,7 @@ export interface UseMachineDetailUpdatesDeps {
updateMachineApi: (id: string, data: any) => Promise<unknown>
updateComposantApi: (id: string, data: any) => Promise<unknown>
updatePieceApi: (id: string, data: any) => Promise<unknown>
apiPatch: (endpoint: string, data?: unknown) => Promise<any>
toast: { showInfo: (msg: string) => void }
}
@@ -40,6 +42,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
machine,
machineName,
machineReference,
machineSiteId,
machineConstructeurIds,
machineComponentLinks,
machinePieceLinks,
@@ -51,6 +54,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updateMachineApi,
updateComposantApi,
updatePieceApi,
apiPatch,
toast,
} = deps
@@ -63,6 +67,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value,
reference: machineReference.value,
siteId: machineSiteId.value || undefined,
constructeurIds: cIds,
} as any)
if (result.success) {
@@ -105,18 +110,18 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const productId = updatedComponent.productId
? String(updatedComponent.productId)
: null
const prix =
const prixStr =
updatedComponent.prix !== null &&
updatedComponent.prix !== undefined &&
String(updatedComponent.prix).trim() !== ''
? Number(updatedComponent.prix)
? String(updatedComponent.prix)
: null
const result: any = await updateComposantApi(updatedComponent.id as string, {
name: updatedComponent.name,
reference: updatedComponent.reference,
constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix,
prix: prixStr,
productId,
} as any)
if (result.success) {
@@ -135,18 +140,18 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updatedPiece.constructeur,
)
const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
const prix =
const prixStr =
updatedPiece.prix !== null &&
updatedPiece.prix !== undefined &&
String(updatedPiece.prix).trim() !== ''
? Number(updatedPiece.prix)
? String(updatedPiece.prix)
: null
const result: any = await updatePieceApi(updatedPiece.id as string, {
name: updatedPiece.name,
reference: updatedPiece.reference,
reference: updatedPiece.reference || null,
constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix,
prix: prixStr,
productId,
} as any)
if (result.success) {
@@ -176,6 +181,13 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
)
}
}
// Update slot quantity if this is a composant structure piece
const slotId = updatedPiece.slotId as string | null
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
if (slotId && quantity !== null) {
await apiPatch(`/composant-piece-slots/${slotId}`, { quantity })
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}
@@ -184,6 +196,13 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const updatePieceInfo = async (updatedPiece: AnyRecord) => {
try {
await _buildAndUpdatePiece(updatedPiece)
// Update link quantity if this is a direct machine piece
const linkId = updatedPiece.linkId || updatedPiece.machinePieceLinkId
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
if (linkId && quantity !== null) {
await apiPatch(`/machine_piece_links/${linkId}`, { quantity })
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}

View File

@@ -181,6 +181,7 @@ export const buildMachineHierarchyFromLinks = (
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [],
}
@@ -214,10 +215,39 @@ export const buildMachineHierarchyFromLinks = (
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
const pieces = Array.isArray(link.pieceLinks)
const linkedPieces = Array.isArray(link.pieceLinks)
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
: []
// If no linked pieces exist, build read-only entries from the composant's structure
const structurePieceDefs = (!linkedPieces.length && appliedComponent.structure && typeof appliedComponent.structure === 'object')
? (Array.isArray((appliedComponent.structure as AnyRecord).pieces) ? (appliedComponent.structure as AnyRecord).pieces as AnyRecord[] : [])
: []
const structurePieces = structurePieceDefs.map((def, index) => {
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
return {
...(resolved || {}),
id: resolved?.id || `structure-piece-${composantId}-${index}`,
pieceId: resolved?.id || null,
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
reference: resolved?.reference || definition.reference || def.reference || null,
prix: resolved?.prix ?? null,
constructeurs: resolved?.constructeurs || [],
documents: [],
quantity,
slotId: def.slotId || definition.slotId || null,
typePieceId: resolved?.typePieceId || definition.typePieceId || def.typePieceId || null,
typePiece: resolved?.typePiece || null,
parentComponentLinkId: machineComponentLinkId,
parentComponentName: componentName,
_structurePiece: true,
}
}) as AnyRecord[]
const pieces = linkedPieces.length ? linkedPieces : structurePieces
const subComponents = Array.isArray(link.childLinks)
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
: []

View File

@@ -20,7 +20,6 @@ export type EditorProduct = {
interface Deps {
props: {
modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}
emit: (event: 'update:modelValue', value: PieceModelStructure) => void
}
@@ -202,8 +201,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
// --- Product types ---
@@ -250,18 +247,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
}
}
// --- Locked state ---
const isFieldLocked = (field: EditorField): boolean => {
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
}
const isProductLocked = (product: EditorProduct): boolean => {
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
}
const restrictedMode = computed(() => props.restrictedMode === true)
// --- CRUD ---
const createEmptyProduct = (): EditorProduct => ({
@@ -407,8 +392,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
products.value = hydrateProducts(value)
products.value.forEach(product => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
initialProductUids.value = new Set(products.value.map(p => p.uid))
},
{ deep: true },
)
@@ -426,9 +409,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
fields,
products,
productTypeOptions,
restrictedMode,
isFieldLocked,
isProductLocked,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,

View File

@@ -10,6 +10,7 @@ import { canPreviewDocument } from '~/utils/documentPreview'
type SiteForm = {
name: string
color: string
contactName: string
contactPhone: string
contactAddress: string
@@ -31,6 +32,7 @@ type SiteDocument = {
type SiteWithDocuments = {
id: string
name?: string
color?: string
contactName?: string
contactPhone?: string
contactAddress?: string
@@ -54,6 +56,7 @@ export function useSiteManagement() {
const newSite = reactive<SiteForm>({
name: '',
color: '',
contactName: '',
contactPhone: '',
contactAddress: '',
@@ -63,6 +66,7 @@ export function useSiteManagement() {
const editSiteForm = reactive<SiteForm>({
name: '',
color: '',
contactName: '',
contactPhone: '',
contactAddress: '',
@@ -81,6 +85,7 @@ export function useSiteManagement() {
const resetNewSite = () => {
newSite.name = ''
newSite.color = ''
newSite.contactName = ''
newSite.contactPhone = ''
newSite.contactAddress = ''
@@ -101,6 +106,7 @@ export function useSiteManagement() {
const handleCreateSite = async () => {
const result = await createSite({
name: newSite.name,
color: newSite.color,
contactName: newSite.contactName,
contactPhone: newSite.contactPhone,
contactAddress: newSite.contactAddress,
@@ -116,6 +122,7 @@ export function useSiteManagement() {
const editSite = (site: SiteWithDocuments) => {
siteBeingEdited.value = site
editSiteForm.name = site.name || ''
editSiteForm.color = site.color || ''
editSiteForm.contactName = site.contactName || ''
editSiteForm.contactPhone = site.contactPhone || ''
editSiteForm.contactAddress = site.contactAddress || ''
@@ -148,6 +155,7 @@ export function useSiteManagement() {
const baseUpdate = {
name: editSiteForm.name,
color: editSiteForm.color,
contactName: editSiteForm.contactName,
contactPhone: editSiteForm.contactPhone,
contactAddress: editSiteForm.contactAddress,

View File

@@ -6,6 +6,7 @@ import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Site {
id: string
name?: string
color?: string
contactName?: string
contactPhone?: string
contactAddress?: string

View File

@@ -1,51 +1,11 @@
import { ref } from 'vue'
import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
export interface StructureNodeCrudDeps {
node: EditableStructureNode
restrictedMode: boolean
canManageSubcomponents: () => boolean
}
export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
// --- Lock state ---
const initialCustomFieldIndices = ref<Set<number>>(new Set())
const initialPieceIndices = ref<Set<number>>(new Set())
const initialProductIndices = ref<Set<number>>(new Set())
const initialSubcomponentIndices = ref<Set<number>>(new Set())
const initializeLockedIndices = () => {
if (props.restrictedMode) {
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
}
}
initializeLockedIndices()
const isCustomFieldLocked = (index: number): boolean => {
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
}
const isPieceLocked = (index: number): boolean => {
return props.restrictedMode === true && initialPieceIndices.value.has(index)
}
const isProductLocked = (index: number): boolean => {
return props.restrictedMode === true && initialProductIndices.value.has(index)
}
const isSubcomponentLocked = (index: number): boolean => {
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
}
// --- Helpers ---
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) {
@@ -115,6 +75,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
reference: '',
familyCode: '',
role: '',
quantity: 1,
})
}
@@ -158,11 +119,6 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
}
return {
// Lock checks
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
// Helpers exposed for watchers
reindexCustomFields,
// CRUD

View File

@@ -25,14 +25,12 @@ export interface StructureNodeLogicDeps {
lockedTypeLabel: string
allowSubcomponents: boolean
maxSubcomponentDepth: number
restrictedMode: boolean
isLocked: boolean
}
export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
// --- Computed props ---
const isLocked = computed(() => props.isLocked === true)
const restrictedMode = computed(() => props.restrictedMode === true)
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
@@ -310,7 +308,6 @@ export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
// --- CRUD & Lock (delegated to useStructureNodeCrud) ---
const crud = useStructureNodeCrud({
node: props.node,
restrictedMode: props.restrictedMode,
canManageSubcomponents: () => canManageSubcomponents.value,
})
@@ -395,14 +392,8 @@ export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
)
return {
// Lock checks
isCustomFieldLocked: crud.isCustomFieldLocked,
isPieceLocked: crud.isPieceLocked,
isProductLocked: crud.isProductLocked,
isSubcomponentLocked: crud.isSubcomponentLocked,
// Computed state
isLocked,
restrictedMode,
componentTypes,
pieceTypes,
productTypes,

View File

@@ -69,6 +69,21 @@ const badgeClass = (type: ChangeType) => {
}
const releases: Release[] = [
{
version: 'v1.9.1',
date: '2026-03-16',
changes: [
{ type: 'feat', text: 'Normalisation JSON tables relationnelles : les structures des composants (pièces, produits, sous-composants) et les squelettes des catégories sont désormais stockés dans des tables dédiées au lieu de colonnes JSON, améliorant la fiabilité et les performances des requêtes' },
{ type: 'feat', text: 'Synchronisation des catégories (ModelType Sync) : la modification d\'une catégorie (ajout/suppression de slots ou champs personnalisés) peut être propagée automatiquement à tous les éléments existants de cette catégorie, avec prévisualisation des changements avant application' },
{ type: 'feat', text: 'Sélection interactive des items dans les slots : sur la page d\'édition d\'un composant, il est maintenant possible de choisir directement la pièce, le produit ou le sous-composant assigné à chaque emplacement du squelette via des sélecteurs avec recherche' },
{ type: 'feat', text: 'Endpoints PATCH pour les slots composant : modification de la quantité et de l\'item sélectionné sur les slots pièce, produit et sous-composant' },
{ type: 'feat', text: 'Table de relation pièce ↔ produit (PieceProductSlot) avec versioning pour le suivi des modifications de structure' },
{ type: 'feat', text: 'Gestion des champs personnalisés sur les catégories : synchronisation automatique des définitions de champs (ajout, modification, suppression) lors de la sauvegarde d\'une catégorie' },
{ type: 'feat', text: 'Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités' },
{ type: 'fix', text: 'Correction de l\'affichage des sélections pré-existantes dans les slots : les pièces, produits et sous-composants déjà assignés sont maintenant correctement affichés à l\'ouverture de la page d\'édition (correction du cache catalogue)' },
{ type: 'fix', text: 'Fallback position/orderIndex sur index de tableau dans les stratégies de sync pour éviter les erreurs quand le champ est absent' },
],
},
{
version: 'v1.9.0',
date: '2026-03-09',

View File

@@ -95,7 +95,7 @@
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<div class="flex items-center justify-end gap-2">
<NuxtLink
:to="`/component/${row.component.id}/edit`"
class="btn btn-ghost btn-xs"
@@ -105,7 +105,7 @@
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>

View File

@@ -27,10 +27,6 @@
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -45,6 +41,14 @@
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadComponentTypes } = useComponentTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
})
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name
@@ -126,7 +116,6 @@ const loadCategory = async () => {
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
@@ -141,9 +130,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id)
saving.value = true
try {
@@ -151,10 +137,29 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
}
} catch (error) {
showError(normalizeError(error))
} finally {
@@ -162,6 +167,39 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})

View File

@@ -152,45 +152,76 @@
/>
<div
v-if="structureSelections.hasAny"
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Sélections actuelles</h2>
<h2 class="font-semibold text-base-content">Sélections du squelette</h2>
<p class="text-xs text-base-content/70">
Voici les pièces, produits et sous-composants réellement choisis pour ce composant.
Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-if="structureSelections.pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
<div v-if="structureSelections.products.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => saveProductSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
<div v-if="structureSelections.components.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => saveSubcomponentSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
</div>
@@ -300,6 +331,9 @@ const {
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history,
historyLoading,
historyError,
@@ -308,6 +342,10 @@ const {
removeDocument,
handleFilesAdded,
submitEdition,
saveSlotQuantity,
savePieceSlotSelection,
saveProductSlotSelection,
saveSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,

View File

@@ -49,11 +49,11 @@
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)">
<button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
Supprimer
</button>
</div>

View File

@@ -26,8 +26,8 @@
<!-- Filters -->
<div class="card bg-base-100 shadow-sm mb-8">
<div class="card-body py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<div class="flex flex-col md:flex-row md:items-end gap-4">
<div class="form-control flex-1">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Rechercher</span>
</label>
@@ -41,11 +41,11 @@
>
</div>
</div>
<div class="form-control">
<div class="form-control md:w-64">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
</label>
<select v-model="selectedSiteFilter" class="select select-bordered">
<select v-model="selectedSiteFilter" class="select select-bordered w-full">
<option value="">
Tous les sites
</option>
@@ -102,19 +102,26 @@
<div
v-for="site in filteredSites"
:key="site.id"
class="card bg-base-100 shadow-sm overflow-hidden"
class="card site-card shadow-md overflow-hidden"
:style="{
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
>
<div class="card-body">
<!-- Site Header -->
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<div
class="w-11 h-11 rounded-xl bg-primary/10 grid place-items-center shrink-0"
class="w-11 h-11 rounded-xl grid place-items-center shrink-0"
:style="{ backgroundColor: site.color ? site.color + '40' : 'oklch(var(--p) / 0.1)', border: site.color ? `2px solid ${site.color}60` : 'none' }"
>
<IconLucideMapPin class="w-5 h-5 text-primary" aria-hidden="true" />
<IconLucideMapPin class="w-5 h-5" :style="{ color: site.color || 'oklch(var(--p))' }" aria-hidden="true" />
</div>
<div class="min-w-0">
<h3 class="text-lg font-bold text-base-content tracking-tight">
<h3
class="text-lg font-bold tracking-tight text-base-content"
>
{{ site.name }}
</h3>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1.5 text-sm text-base-content/50">
@@ -134,7 +141,11 @@
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="badge badge-primary badge-sm font-semibold">
<span
class="badge font-bold"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ site.machines?.length || 0 }}
</span>
<button
@@ -163,7 +174,7 @@
<div
v-for="machine in site.machines"
:key="machine.id"
class="group rounded-xl border border-base-300/40 bg-base-200/30 hover:bg-base-200/60 hover:border-primary/20 transition-all cursor-pointer p-4"
class="group flex flex-col rounded-xl border border-base-300/40 bg-base-100 shadow-sm hover:shadow-md hover:border-primary/20 transition-all cursor-pointer p-4"
@click="viewMachineDetails(machine)"
>
<div class="flex items-center justify-between mb-2">
@@ -172,29 +183,29 @@
</h4>
</div>
<div v-if="machine.reference" class="flex items-center gap-1.5 text-xs text-base-content/40 mb-3">
<div v-if="machine.reference" class="flex items-center gap-1.5 text-xs text-base-content/40">
<IconLucideTag class="w-3 h-3" aria-hidden="true" />
<span>{{ machine.reference }}</span>
</div>
<div class="flex items-center gap-1.5 justify-end">
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button
v-if="canEdit"
class="btn btn-xs btn-ghost text-base-content/40 hover:text-base-content opacity-0 group-hover:opacity-100 transition-opacity"
class="btn btn-ghost btn-sm"
@click.stop="editMachine(machine)"
>
Modifier
</button>
<button
v-if="canEdit"
class="btn btn-xs btn-ghost text-error/50 hover:text-error opacity-0 group-hover:opacity-100 transition-opacity"
class="btn btn-ghost btn-sm text-error"
@click.stop="confirmDeleteMachine(machine)"
>
Supprimer
</button>
<NuxtLink
:to="`/machine/${machine.id}`"
class="btn btn-xs btn-primary btn-outline opacity-0 group-hover:opacity-100 transition-opacity"
class="btn btn-primary btn-sm"
@click.stop
>
Détails

View File

@@ -39,7 +39,11 @@
rounded
>
<div class="flex justify-center gap-4">
<div v-if="d.machine.value.site?.name" class="badge badge-outline">
<div
v-if="d.machine.value.site?.name"
class="badge badge-outline font-semibold"
:style="d.machine.value.site?.color ? { borderColor: d.machine.value.site.color + '60', backgroundColor: d.machine.value.site.color + '25', color: d.machine.value.site.color } : {}"
>
{{ d.machine.value.site?.name }}
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
@@ -53,6 +57,9 @@
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-site-id="d.machineSiteId.value"
:machine-site-name="d.machine.value?.site?.name ?? ''"
:sites="d.sites.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
@@ -62,6 +69,7 @@
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"

View File

@@ -65,10 +65,14 @@
<div
v-for="machine in filteredMachines"
:key="machine.id"
class="card bg-base-100 shadow-sm hover:shadow-xl transition-shadow cursor-pointer"
class="card site-card shadow-md hover:shadow-xl transition-shadow cursor-pointer overflow-hidden"
:style="{
borderTop: machine.site?.color ? `4px solid ${machine.site.color}` : '4px solid transparent',
background: machine.site?.color ? `linear-gradient(160deg, ${machine.site.color}30 0%, ${machine.site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
@click="viewMachineDetails(machine)"
>
<div class="card-body">
<div class="card-body flex flex-col">
<div class="flex items-center justify-between mb-2">
<h3 class="card-title text-lg">
{{ machine.name }}
@@ -77,8 +81,11 @@
<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>
<IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
<span
class="font-bold text-sm px-2.5 py-1 rounded-lg text-base-content"
:style="machine.site?.color ? { backgroundColor: machine.site.color + '30', border: `1px solid ${machine.site.color}40` } : {}"
>{{ machine.site?.name || 'Site inconnu' }}</span>
</div>
<div v-if="machine.reference" class="flex items-center gap-2">
@@ -87,15 +94,15 @@
</div>
</div>
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
Modifier
</button>
<button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
<button v-if="canEdit" class="btn btn-ghost btn-sm text-error" @click.stop="confirmDeleteMachine(machine)">
Supprimer
</button>
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
Voir détails
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
Détails
</NuxtLink>
</div>
</div>

View File

@@ -27,10 +27,6 @@
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -45,6 +41,14 @@
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadPieceTypes } = usePieceTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: {
singular: 'pièce',
plural: 'pièces',
verifying: 'Vérification des pièces liées en cours…',
},
})
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
@@ -124,7 +114,6 @@ const loadCategory = async () => {
structure: (response.structure as PieceModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
@@ -139,9 +128,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id)
saving.value = true
try {
@@ -149,10 +135,29 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
}
} catch (error) {
showError(normalizeError(error))
} finally {
@@ -160,6 +165,39 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})

View File

@@ -118,7 +118,7 @@
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<div class="flex items-center justify-end gap-2">
<NuxtLink
:to="`/pieces/${row.piece.id}/edit`"
class="btn btn-ghost btn-xs"
@@ -128,7 +128,7 @@
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>

View File

@@ -115,7 +115,7 @@
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<div class="flex items-center justify-end gap-2">
<NuxtLink
:to="`/product/${row.product.id}/edit`"
class="btn btn-ghost btn-xs"

View File

@@ -27,10 +27,6 @@
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -45,6 +41,14 @@
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadProductTypes } = useProductTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/products',
filterKey: 'typeProduct',
labels: {
singular: 'produit',
plural: 'produits',
verifying: 'Vérification des produits liés en cours…',
},
})
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
@@ -124,7 +114,6 @@ const loadCategory = async () => {
structure: (response.structure as ProductModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
@@ -139,9 +128,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id)
saving.value = true
try {
@@ -149,10 +135,29 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
}
} catch (error) {
showError(normalizeError(error))
} finally {
@@ -160,6 +165,39 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})

View File

@@ -390,7 +390,7 @@ const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
@@ -406,7 +406,7 @@ const loadProductType = async () => {
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null

View File

@@ -46,9 +46,6 @@ export interface ModelType extends BaseModelTypePayload {
updatedAt: string;
category: ModelCategory;
structure: ModelTypeStructure;
componentSkeleton?: ComponentModelStructure | null;
pieceSkeleton?: PieceModelStructure | null;
productSkeleton?: ProductModelStructure | null;
}
export interface ModelTypeListParams {
@@ -86,42 +83,9 @@ const normalizeModelType = (item: any): ModelType => {
if (!item || typeof item !== 'object') {
return item as ModelType;
}
if (!item.structure) {
if (item.category === 'COMPONENT' && item.componentSkeleton) {
item.structure = item.componentSkeleton;
} else if (item.category === 'PIECE' && item.pieceSkeleton) {
item.structure = item.pieceSkeleton;
} else if (item.category === 'PRODUCT' && item.productSkeleton) {
item.structure = item.productSkeleton;
}
}
return item as ModelType;
};
const mapStructureToSkeleton = <T extends Record<string, any>>(payload: T): T => {
if (!payload || typeof payload !== 'object') {
return payload;
}
if (!('structure' in payload)) {
return payload;
}
const structure = (payload as any).structure;
if (!structure) {
return payload;
}
const category = (payload as any).category;
const next = { ...payload } as Record<string, any>;
if (category === 'COMPONENT') {
next.componentSkeleton = structure;
} else if (category === 'PIECE') {
next.pieceSkeleton = structure;
} else if (category === 'PRODUCT') {
next.productSkeleton = structure;
}
delete next.structure;
return next as T;
};
export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
const query: Record<string, string | number> = {};
@@ -178,28 +142,26 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(ENDPOINT, createOptions({
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
Accept: 'application/ld+json',
},
body: mappedPayload,
body: payload,
signal: opts.signal,
})).then(normalizeModelType);
}
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
Accept: 'application/ld+json',
},
body: mappedPayload,
body: payload,
signal: opts.signal,
})).then(normalizeModelType);
}
@@ -249,3 +211,45 @@ export function convertCategory(id: string, opts: { signal?: AbortSignal } = {})
signal: opts.signal,
}));
}
export interface SyncPreviewResult {
modelTypeId: string;
category: string;
itemCount: number;
additions: Record<string, number>;
deletions: Record<string, number>;
modifications: Record<string, number>;
}
export interface SyncExecuteResult {
itemsUpdated: number;
additions: Record<string, number>;
deletions: Record<string, number>;
modifications: Record<string, number>;
}
export function syncPreview(id: string, structure: unknown, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<SyncPreviewResult>(`${ENDPOINT}/${id}/sync-preview`, createOptions({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: { structure },
signal: opts.signal,
}));
}
export function syncExecute(id: string, confirmation: { confirmDeletions: boolean; confirmTypeChanges: boolean }, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<SyncExecuteResult>(`${ENDPOINT}/${id}/sync`, createOptions({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: confirmation,
signal: opts.signal,
}));
}

View File

@@ -175,6 +175,9 @@ export const normalizeStructureForSave = (input: any): any => {
if (piece.reference) {
payload.reference = piece.reference
}
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
payload.quantity = (piece as any).quantity
}
return payload
}) as any

View File

@@ -103,6 +103,7 @@ export const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
reference: piece?.reference ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
}))
}
@@ -175,6 +176,7 @@ export const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
}))
}

View File

@@ -162,6 +162,8 @@ export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
return null
}
@@ -182,6 +184,9 @@ export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (typePieceLabel) {
result.typePieceLabel = typePieceLabel
}
if (quantity !== undefined) {
result.quantity = quantity
}
return result
})
.filter((piece): piece is ComponentModelPiece => !!piece)

View File

@@ -4,7 +4,7 @@ export interface DefinitionOverridePayload {
name?: string
reference?: string
constructeurIds?: string[]
prix?: number
prix?: string
}
export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverridePayload | null => {
@@ -41,7 +41,7 @@ export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverride
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
const parsed = Number(definition.prix)
if (!Number.isNaN(parsed)) {
payload.prix = parsed
payload.prix = String(parsed)
}
}

View File

@@ -21,6 +21,7 @@ export interface ComponentModelPiece {
reference?: string
familyCode?: string
role?: string
quantity?: number
}
export interface ComponentModelProduct {
@@ -156,6 +157,7 @@ const validatePiece = (
const reference = ensureString(value.reference)
const familyCode = ensureString(value.familyCode)
const role = ensureString(value.role)
const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
issues.push(`${path}: au moins un identifiant, une famille ou une référence de pièce est requis`)
@@ -168,6 +170,7 @@ const validatePiece = (
...(reference ? { reference } : {}),
...(familyCode ? { familyCode } : {}),
...(role ? { role } : {}),
...(quantity ? { quantity } : {}),
}
}

View File

@@ -225,7 +225,10 @@ export const buildCustomFieldInputs = (
if (fieldName) mapByName.set(fieldName, entry)
})
return definitions
const matchedIds = new Set<string>()
const matchedNames = new Set<string>()
const result = definitions
.map((definition) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
@@ -239,6 +242,11 @@ export const buildCustomFieldInputs = (
}
}
const matchedFieldId = matched.customField?.id || matched.customFieldId || null
if (matchedFieldId) matchedIds.add(matchedFieldId)
const matchedFieldName = matched.customField?.name || matched.name || null
if (matchedFieldName) matchedNames.add(matchedFieldName)
const resolvedValue = extractStoredCustomFieldValue(matched)
return {
...definition,
@@ -253,7 +261,36 @@ export const buildCustomFieldInputs = (
),
}
})
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
// Include values with embedded definitions that didn't match any structure definition
valueList.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return
const cf = entry.customField
if (!cf || typeof cf !== 'object') return
const fieldId = cf.id || entry.customFieldId || null
const fieldName = cf.name || entry.name || null
if (fieldId && matchedIds.has(fieldId)) return
if (fieldName && matchedNames.has(fieldName)) return
const name = resolveFieldName(cf)
if (!name) return
const type = resolveFieldType(cf)
const resolvedValue = extractStoredCustomFieldValue(entry)
result.push({
id: fieldId,
name,
type,
required: resolveRequiredFlag(cf),
options: resolveOptions(cf),
value: formatDefaultValue(type, resolvedValue),
customFieldId: fieldId,
customFieldValueId: entry.id ?? null,
orderIndex: typeof cf.orderIndex === 'number' ? cf.orderIndex : definitions.length + index,
})
})
return result.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
}
// ---------------------------------------------------------------------------

View File

@@ -196,34 +196,19 @@ export const getProductDisplay = (
const structuralCandidates = [
source.products,
source.productSkeleton,
(source.definition as AnyRecord)?.products,
(source.definition as AnyRecord)?.productSkeleton,
((source.definition as AnyRecord)?.structure as AnyRecord)?.products,
((source.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.structure as AnyRecord)?.products,
(source.structure as AnyRecord)?.productSkeleton,
(source.requirement as AnyRecord)?.products,
(source.requirement as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.productSkeleton,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComposant as AnyRecord)?.products,
(source.originalComposant as AnyRecord)?.productSkeleton,
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.products,
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.productSkeleton,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComponent as AnyRecord)?.products,
(source.originalComponent as AnyRecord)?.productSkeleton,
((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.products,
((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.productSkeleton,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
]
const structuralProducts = structuralCandidates

View File

@@ -176,6 +176,7 @@ export function sanitizePieceDefinition(definition: ComponentModelPiece) {
typePieceLabel: definition.typePieceLabel ?? null,
reference: definition.reference ?? null,
familyCode: (definition as any).familyCode ?? null,
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
})
}

View File

@@ -3,6 +3,9 @@ export type SelectionEntry = {
path: string
requirementLabel: string
resolvedName: string
quantity?: number
slotId?: string
_definition?: Record<string, any>
}
export type StructureSelectionResult = {
@@ -59,6 +62,9 @@ export function collectStructureSelections(
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
requirementLabel: resolvers.resolvePieceLabel(definition),
resolvedName: catalogPiece?.name || selectedId,
quantity: typeof definition?.quantity === 'number' ? definition.quantity : undefined,
slotId: isNonEmptyString(entry?.slotId) ? entry.slotId : undefined,
_definition: definition,
})
})

View File

@@ -225,56 +225,6 @@ describe('category lock', () => {
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('shows restricted mode message', () => {
const wrapper = mountForm({
restrictedMode: true,
restrictedModeMessage: 'Mode restreint actif',
})
expect(wrapper.text()).toContain('Mode restreint actif')
expect(wrapper.find('.alert-info').exists()).toBe(true)
})
it('does not show restricted mode message when not restricted', () => {
const wrapper = mountForm({
restrictedMode: false,
})
expect(wrapper.find('.alert-info').exists()).toBe(false)
})
it('disables name input in restricted mode', () => {
const wrapper = mountForm({ restrictedMode: true })
expect((getNameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Submit disabled
// ---------------------------------------------------------------------------
describe('submit disabled', () => {
it('disables submit button when disableSubmit is true', () => {
const wrapper = mountForm({ disableSubmit: true })
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('shows warning alert when disableSubmit is true', () => {
const wrapper = mountForm({
disableSubmit: true,
disableSubmitMessage: 'Cannot save now',
})
expect(wrapper.find('.alert-warning').exists()).toBe(true)
expect(wrapper.text()).toContain('Cannot save now')
})
it('does not show warning when disableSubmit is false', () => {
const wrapper = mountForm({ disableSubmit: false })
expect(wrapper.find('.alert-warning').exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Saving state
// ---------------------------------------------------------------------------

View File

@@ -227,98 +227,6 @@ describe('required checkbox', () => {
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('allows editing name of pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked Field', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const nameInput = wrapper.find('input[type="text"]')
expect((nameInput.element as HTMLInputElement).disabled).toBe(false)
})
it('disables type select for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(true)
})
it('disables required checkbox for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
})
it('hides delete button for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
// btn-error should not exist for locked fields
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(false)
})
it('allows full editing of newly added field', async () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [],
products: [],
},
})
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
// New field should have an editable type select (not disabled)
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(false)
// Delete button should exist for new field
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(true)
})
it('hides product add button in restricted mode', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: { customFields: [], products: [] },
})
const addButtons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
// Only the "add field" button should be visible, not the product one
expect(addButtons.length).toBe(1)
})
})
// ---------------------------------------------------------------------------
// Add product
// ---------------------------------------------------------------------------

View File

@@ -1,269 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockShowInfo = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
apiCall: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showInfo: mockShowInfo,
showSuccess: vi.fn(),
showError: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const GUARD_CONFIG = {
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe('initial state', () => {
it('has linkedCount 0 and restrictedMode false', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.linkedLoading.value).toBe(false)
})
it('has empty messages when no linked items', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.restrictedModeMessage.value).toBe('')
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// loadLinkedCount
// ---------------------------------------------------------------------------
describe('loadLinkedCount', () => {
it('sets linkedCount from API totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(5)
expect(guard.isRestrictedMode.value).toBe(true)
expect(mockGet).toHaveBeenCalledWith(
expect.stringContaining('/composants?'),
)
})
it('sets linkedCount 0 when API returns 0 items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('extracts totalItems from hydra:totalItems format', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:totalItems': 3, 'hydra:member': [{}, {}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(3)
})
it('falls back to member.length when no totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { member: [{}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(2)
})
it('falls back to hydra:member.length', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:member': [{}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(1)
})
it('sets linkedCount 0 on API failure', async () => {
mockGet.mockResolvedValue({ success: false })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('sets linkedCount 0 on exception', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
})
it('sends correct filter parameters', async () => {
mockGet.mockResolvedValue({ success: true, data: { totalItems: 0 } })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('abc-123')
const callUrl = mockGet.mock.calls[0][0] as string
expect(callUrl).toContain('itemsPerPage=1')
expect(callUrl).toContain('typeComposant=%2Fapi%2Fmodel_types%2Fabc-123')
})
})
// ---------------------------------------------------------------------------
// restrictedModeMessage
// ---------------------------------------------------------------------------
describe('restrictedModeMessage', () => {
it('shows singular message for 1 linked item', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 1 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('1 composant')
expect(guard.restrictedModeMessage.value).toContain('Mode restreint')
})
it('shows plural message for multiple linked items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('5 composants')
expect(guard.restrictedModeMessage.value).toContain('renommer les existants')
})
it('uses custom labels from config', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 3 },
})
const guard = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: { singular: 'pièce', plural: 'pièces', verifying: 'Vérification...' },
})
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('3 pièces')
})
})
// ---------------------------------------------------------------------------
// isSubmitBlocked & submitBlockMessage
// ---------------------------------------------------------------------------
describe('submit blocking', () => {
it('blocks submit during loading', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading state by starting a load without awaiting
mockGet.mockReturnValue(new Promise(() => {})) // Never resolves
guard.loadLinkedCount('mt-1')
expect(guard.linkedLoading.value).toBe(true)
expect(guard.isSubmitBlocked.value).toBe(true)
expect(guard.submitBlockMessage.value).toBe(GUARD_CONFIG.labels.verifying)
})
it('unblocks submit after loading completes', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// guardSubmitOrNotify
// ---------------------------------------------------------------------------
describe('guardSubmitOrNotify', () => {
it('returns false when not blocked', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(false)
expect(mockShowInfo).not.toHaveBeenCalled()
})
it('returns true and shows info when blocked', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading
mockGet.mockReturnValue(new Promise(() => {}))
guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(true)
expect(mockShowInfo).toHaveBeenCalled()
})
})

View File

@@ -50,60 +50,55 @@ beforeEach(() => {
// normalizeModelType (tested via getModelType which calls .then(normalizeModelType))
// ---------------------------------------------------------------------------
describe('normalizeModelType (via getModelType)', () => {
it('maps componentSkeleton to structure for COMPONENT', async () => {
const skeleton = { customFields: [{ name: 'Weight' }] }
it('returns structure as-is for COMPONENT', async () => {
const structure = { customFields: [{ name: 'Weight' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: null,
componentSkeleton: skeleton as any,
structure: structure as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
expect(result.structure).toEqual(structure)
})
it('maps pieceSkeleton to structure for PIECE', async () => {
const skeleton = { customFields: [{ name: 'Size' }] }
it('returns structure as-is for PIECE', async () => {
const structure = { customFields: [{ name: 'Size' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PIECE',
structure: null,
pieceSkeleton: skeleton as any,
structure: structure as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
expect(result.structure).toEqual(structure)
})
it('maps productSkeleton to structure for PRODUCT', async () => {
const skeleton = { customFields: [{ name: 'Brand' }] }
it('returns structure as-is for PRODUCT', async () => {
const structure = { customFields: [{ name: 'Brand' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PRODUCT',
structure: null,
productSkeleton: skeleton as any,
structure: structure as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
expect(result.structure).toEqual(structure)
})
it('does not override existing structure', async () => {
const existing = { customFields: [{ name: 'Existing' }] }
it('preserves null structure', async () => {
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: existing as any,
componentSkeleton: { customFields: [{ name: 'Skeleton' }] } as any,
structure: null,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(existing)
expect(result.structure).toBeNull()
})
})
// ---------------------------------------------------------------------------
// createModelType — maps structure to skeleton
// createModelType — sends structure directly
// ---------------------------------------------------------------------------
describe('createModelType', () => {
it('sends POST with componentSkeleton for COMPONENT', async () => {
it('sends POST with structure for COMPONENT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType())
@@ -120,11 +115,10 @@ describe('createModelType', () => {
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types')
expect(options.method).toBe('POST')
expect(options.body.componentSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
expect(options.body.structure).toEqual(structure)
})
it('sends POST with pieceSkeleton for PIECE', async () => {
it('sends POST with structure for PIECE', async () => {
const structure = { customFields: [], products: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' }))
@@ -136,11 +130,10 @@ describe('createModelType', () => {
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.pieceSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
expect(options.body.structure).toEqual(structure)
})
it('sends POST with productSkeleton for PRODUCT', async () => {
it('sends POST with structure for PRODUCT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' }))
@@ -152,15 +145,15 @@ describe('createModelType', () => {
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.productSkeleton).toEqual(structure)
expect(options.body.structure).toEqual(structure)
})
})
// ---------------------------------------------------------------------------
// updateModelType — maps structure to skeleton
// updateModelType — sends structure directly
// ---------------------------------------------------------------------------
describe('updateModelType', () => {
it('sends PATCH with correct endpoint and skeleton', async () => {
it('sends PATCH with correct endpoint and structure', async () => {
const structure = { customFields: [{ name: 'Updated' }] }
mockFetch.mockResolvedValue(fakeModelType())
@@ -176,10 +169,10 @@ describe('updateModelType', () => {
expect(endpoint).toBe('/model_types/mt-1')
expect(options.method).toBe('PATCH')
expect(options.headers['Content-Type']).toBe('application/merge-patch+json')
expect(options.body.componentSkeleton).toEqual(structure)
expect(options.body.structure).toEqual(structure)
})
it('sends payload without skeleton when no structure', async () => {
it('sends payload without structure when not provided', async () => {
mockFetch.mockResolvedValue(fakeModelType())
await updateModelType('mt-1', {
@@ -189,7 +182,7 @@ describe('updateModelType', () => {
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.componentSkeleton).toBeUndefined()
expect(options.body.structure).toBeUndefined()
expect(options.body.name).toBe('Just Name')
})
})