21 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
3ad326348b docs(changelog) : add v1.9.0 release notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:04:25 +01:00
5b9c4ca09d refactor(ui) : improve styling, layout and responsive across all components
Rework CSS theme (app.css), navbar layout, dashboard page, machine detail,
catalog pages, and various form/display components for better consistency
and mobile responsiveness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:14:32 +01:00
6b5eb7bcd6 fix(tests) : fix stale unit tests for useToast and useEntityTypes
useToast.clearAll() now clears the dedup map to prevent test pollution,
and useEntityTypes error test expectation matches actual French message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:39:10 +01:00
81 changed files with 2297 additions and 1802 deletions

View File

@@ -1,11 +1,16 @@
<template> <template>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100"> <div class="min-h-screen flex flex-col bg-base-200/40">
<!-- Subtle dot pattern background -->
<div class="fixed inset-0 -z-10 bg-[radial-gradient(oklch(85%_0.02_260)_1px,transparent_1px)] bg-[size:24px_24px] opacity-40" />
<AppNavbar <AppNavbar
@open-settings="displaySettingsOpen = true" @open-settings="displaySettingsOpen = true"
@logout="handleLogout" @logout="handleLogout"
/> />
<NuxtPage /> <main class="flex-1">
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
</main>
<ToastContainer /> <ToastContainer />
@@ -17,11 +22,17 @@
@update-settings="handleSettingsUpdate" @update-settings="handleSettingsUpdate"
/> />
<footer class="footer p-4 bg-neutral text-neutral-content"> <footer class="border-t border-base-300/50 bg-base-100/60 backdrop-blur-sm">
<div class="items-center grid-flow-col"> <div class="container mx-auto flex items-center justify-between px-6 py-3">
<p> <p class="text-xs text-base-content/40 font-medium tracking-wide">
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink> &copy; Malio {{ new Date().getFullYear() }}
</p> </p>
<NuxtLink
to="/changelog"
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
>
v{{ appVersion }}
</NuxtLink>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -1,55 +1,136 @@
/* ─── Fonts ─── */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui"; @plugin "daisyui";
/* ─── Theme ─── */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "mytheme"; name: "mytheme";
default: true; /* set as default */ default: true;
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */ prefersdark: false;
color-scheme: light; /* color of browser-provided UI */ color-scheme: light;
/* #FBFAFA — gris clair */ /* Surfaces — warm gray with a hint of blue */
--color-base-100: oklch(98% 0.003 0); --color-base-100: oklch(98.5% 0.004 260);
--color-base-200: oklch(94% 0.01 262); --color-base-200: oklch(95% 0.008 260);
--color-base-300: oklch(90% 0.02 262); --color-base-300: oklch(91% 0.015 260);
--color-base-content: oklch(20% 0.03 262); --color-base-content: oklch(22% 0.025 260);
/* #304998 — bleu Malio */
--color-primary: oklch(37% 0.15 262); /* Primary — Malio blue, slightly richer */
--color-primary: oklch(40% 0.16 262);
--color-primary-content: oklch(98% 0.005 262); --color-primary-content: oklch(98% 0.005 262);
/* #A5ACD0 — lavande */
--color-secondary: oklch(75% 0.055 270); /* Secondary — refined lavender */
--color-secondary-content: oklch(20% 0.03 270); --color-secondary: oklch(72% 0.06 275);
/* #ED8521 — orange */ --color-secondary-content: oklch(22% 0.03 275);
--color-accent: oklch(71% 0.17 58);
--color-accent-content: oklch(98% 0.005 58); /* Accent — warm amber-orange */
/* neutral dérivé du bleu Malio */ --color-accent: oklch(72% 0.17 55);
--color-neutral: oklch(37% 0.08 262); --color-accent-content: oklch(20% 0.04 55);
--color-neutral-content: oklch(98% 0.005 262);
--color-info: oklch(55% 0.12 262); /* Neutral — deep slate */
--color-info-content: oklch(98% 0.005 262); --color-neutral: oklch(28% 0.04 260);
--color-success: oklch(65% 0.2 145); --color-neutral-content: oklch(95% 0.005 260);
--color-success-content: oklch(98% 0.005 145);
/* Semantic */
--color-info: oklch(58% 0.14 255);
--color-info-content: oklch(98% 0.005 255);
--color-success: oklch(62% 0.19 150);
--color-success-content: oklch(98% 0.005 150);
--color-warning: oklch(78% 0.15 70); --color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(20% 0.05 70); --color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(60% 0.25 25); --color-error: oklch(58% 0.22 25);
--color-error-content: oklch(98% 0.005 25); --color-error-content: oklch(98% 0.005 25);
/* border radius */ /* Geometry */
--radius-selector: 1rem; --radius-selector: 0.75rem;
--radius-field: 0.25rem; --radius-field: 0.375rem;
--radius-box: 0.5rem; --radius-box: 0.625rem;
/* base sizes */
--size-selector: 0.25rem; --size-selector: 0.25rem;
--size-field: 0.25rem; --size-field: 0.25rem;
/* border size */
--border: 1px; --border: 1px;
/* effects */
--depth: 1; --depth: 1;
--noise: 0; --noise: 0;
} }
/* Styles pour l'accessibilité et les paramètres d'affichage */ @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;
--font-body: 'DM Sans', system-ui, sans-serif;
}
body {
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
h1, h2, h3, h4, h5, h6,
.card-title,
.stat-value,
.text-2xl,
.text-3xl,
.text-4xl {
font-family: var(--font-heading);
letter-spacing: -0.025em;
}
/* ─── Density variables ─── */
:root { :root {
--spacing-xs: 0.5rem; --spacing-xs: 0.5rem;
--spacing-sm: 0.75rem; --spacing-sm: 0.75rem;
@@ -58,7 +139,6 @@
--spacing-xl: 2rem; --spacing-xl: 2rem;
} }
/* Densité compacte */
.density-compact { .density-compact {
--spacing-xs: 0.25rem; --spacing-xs: 0.25rem;
--spacing-sm: 0.5rem; --spacing-sm: 0.5rem;
@@ -67,7 +147,6 @@
--spacing-xl: 1.25rem; --spacing-xl: 1.25rem;
} }
/* Densité confortable (défaut) */
.density-comfortable { .density-comfortable {
--spacing-xs: 0.5rem; --spacing-xs: 0.5rem;
--spacing-sm: 0.75rem; --spacing-sm: 0.75rem;
@@ -76,7 +155,6 @@
--spacing-xl: 2rem; --spacing-xl: 2rem;
} }
/* Densité espacée */
.density-spacious { .density-spacious {
--spacing-xs: 0.75rem; --spacing-xs: 0.75rem;
--spacing-sm: 1rem; --spacing-sm: 1rem;
@@ -85,251 +163,200 @@
--spacing-xl: 3rem; --spacing-xl: 3rem;
} }
/* Contraste élevé avec DaisyUI */ /* ─── High contrast mode ─── */
.contrast-high .btn { .contrast-high .btn { @apply border-2; }
@apply border-2; .contrast-high .input { @apply border-2; }
} .contrast-high .select { @apply border-2; }
.contrast-high .textarea { @apply border-2; }
.contrast-high .modal-box { @apply border-2 border-base-content; }
.contrast-high .input { /* ─── Accessibility ─── */
@apply border-2;
}
.contrast-high .select {
@apply border-2;
}
.contrast-high .textarea {
@apply border-2;
}
.contrast-high .modal-box {
@apply border-2 border-base-content;
}
/* Amélioration de l'accessibilité */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *, *::before, *::after {
*::before,
*::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* Focus visible pour l'accessibilité */
*:focus-visible { *:focus-visible {
outline: 2px solid #304998; outline: 2px solid oklch(40% 0.16 262);
outline-offset: 2px; outline-offset: 2px;
} }
/* Styles pour les boutons de paramètres */ /* ─── Cards ─── */
.card {
border: 1px solid oklch(91% 0.015 260 / 0.6);
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),
0 2px 4px -2px oklch(22% 0.025 260 / 0.04);
}
/* ─── Navbar glass effect ─── */
.navbar-glass {
background: oklch(98.5% 0.004 260 / 0.82);
backdrop-filter: blur(12px) saturate(1.5);
-webkit-backdrop-filter: blur(12px) saturate(1.5);
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);
font-weight: 500;
letter-spacing: -0.01em;
transition: all 0.15s ease;
}
.btn-circle { .btn-circle {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
.btn-circle:hover { .btn-circle:hover {
transform: scale(1.05); transform: scale(1.05);
} }
.btn-circle:active { .btn-circle:active {
transform: scale(0.95); transform: scale(0.95);
} }
/* Animation pour le modal */ /* ─── Inputs ─── */
.input, .select, .textarea {
font-family: var(--font-body);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input:focus, .select:focus, .textarea:focus {
box-shadow: 0 0 0 3px oklch(40% 0.16 262 / 0.1);
}
/* ─── Tables ─── */
.table thead th {
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
font-size: 0.7rem;
color: oklch(45% 0.03 260);
}
.table tbody tr {
transition: background-color 0.1s ease;
}
/* ─── Badges ─── */
.badge {
font-family: var(--font-heading);
font-weight: 500;
letter-spacing: 0;
}
/* ─── Stats ─── */
.stat-title {
font-family: var(--font-body);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.6;
}
.stat-value {
font-weight: 700;
}
/* ─── Modals ─── */
.modal { .modal {
transition: opacity 0.3s ease-in-out; transition: opacity 0.25s ease;
font-size: 100% !important; /* Force la taille normale pour le modal */
transform: none !important; /* Empêche les transformations */
scale: 1 !important; /* Force l'échelle à 1 */
} }
.modal.modal-open {
animation: modalFadeIn 0.3s ease-in-out;
}
/* S'assurer que le contenu du modal garde une taille normale */
.modal-box { .modal-box {
font-size: 100% !important; font-family: var(--font-body);
transform: none !important; border-radius: 0.75rem;
scale: 1 !important; border: 1px solid oklch(91% 0.015 260 / 0.5);
width: auto !important;
max-width: 500px !important;
} }
.modal .form-control { @keyframes modalSlideUp {
font-size: 100% !important; from { opacity: 0; transform: translateY(0.5rem); }
transform: none !important; to { opacity: 1; transform: translateY(0); }
} }
.modal .btn { .modal.modal-open .modal-box {
font-size: 100% !important; animation: modalSlideUp 0.25s ease-out;
transform: none !important;
padding: 0.5rem 1rem !important;
height: auto !important;
min-height: 2.5rem !important;
} }
.modal .input { /* ─── Page transitions ─── */
font-size: 100% !important; .page-enter-active {
transform: none !important; transition: opacity 0.2s ease, transform 0.2s ease;
height: auto !important; }
min-height: 2.5rem !important; .page-leave-active {
transition: opacity 0.15s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.page-leave-to {
opacity: 0;
} }
.modal .select { /* ─── Scrollbar styling ─── */
font-size: 100% !important; ::-webkit-scrollbar {
transform: none !important; width: 6px;
height: auto !important; height: 6px;
min-height: 2.5rem !important; }
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(75% 0.02 260);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(60% 0.03 260);
} }
.modal .textarea { /* ─── Readability ─── */
font-size: 100% !important; .text-sm { line-height: 1.5; }
transform: none !important; .text-xs { line-height: 1.4; }
min-height: 4rem !important;
}
.modal .range { /* ─── Adaptive spacing ─── */
font-size: 100% !important; .p-1 { padding: var(--spacing-xs); }
transform: none !important; .p-2 { padding: var(--spacing-sm); }
height: auto !important; .p-3 { padding: var(--spacing-md); }
min-height: 1.5rem !important; .p-4 { padding: var(--spacing-lg); }
} .p-5 { padding: var(--spacing-xl); }
.modal .label { .m-1 { margin: var(--spacing-xs); }
font-size: 100% !important; .m-2 { margin: var(--spacing-sm); }
transform: none !important; .m-3 { margin: var(--spacing-md); }
} .m-4 { margin: var(--spacing-lg); }
.m-5 { margin: var(--spacing-xl); }
.modal .label-text { .gap-1 { gap: var(--spacing-xs); }
font-size: 100% !important; .gap-2 { gap: var(--spacing-sm); }
transform: none !important; .gap-3 { gap: var(--spacing-md); }
} .gap-4 { gap: var(--spacing-lg); }
.gap-5 { gap: var(--spacing-xl); }
.modal .label-text-alt {
font-size: 100% !important;
transform: none !important;
}
.modal .checkbox {
font-size: 100% !important;
transform: none !important;
width: 1rem !important;
height: 1rem !important;
}
.modal .text-xs {
font-size: 0.75rem !important;
transform: none !important;
}
.modal .text-sm {
font-size: 0.875rem !important;
transform: none !important;
}
.modal .text-lg {
font-size: 1.125rem !important;
transform: none !important;
}
.modal .font-bold {
font-weight: 700 !important;
transform: none !important;
}
.modal .font-medium {
font-weight: 500 !important;
transform: none !important;
}
/* Empêcher les héritages de taille */
.modal * {
font-size: inherit !important;
transform: none !important;
scale: 1 !important;
}
@keyframes modalFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Styles pour les contrôles de zoom */
.range {
transition: all 0.2s ease-in-out;
}
.range::-webkit-slider-thumb {
transition: all 0.2s ease-in-out;
}
.range::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
/* Amélioration de la lisibilité */
.text-sm {
line-height: 1.5;
}
.text-xs {
line-height: 1.4;
}
/* Espacement adaptatif */
.p-1 {
padding: var(--spacing-xs);
}
.p-2 {
padding: var(--spacing-sm);
}
.p-3 {
padding: var(--spacing-md);
}
.p-4 {
padding: var(--spacing-lg);
}
.p-5 {
padding: var(--spacing-xl);
}
.m-1 {
margin: var(--spacing-xs);
}
.m-2 {
margin: var(--spacing-sm);
}
.m-3 {
margin: var(--spacing-md);
}
.m-4 {
margin: var(--spacing-lg);
}
.m-5 {
margin: var(--spacing-xl);
}
.gap-1 {
gap: var(--spacing-xs);
}
.gap-2 {
gap: var(--spacing-sm);
}
.gap-3 {
gap: var(--spacing-md);
}
.gap-4 {
gap: var(--spacing-lg);
}
.gap-5 {
gap: var(--spacing-xl);
}
@layer components { @layer components {
.form-control .label { .form-control .label {
@@ -337,7 +364,6 @@
padding-bottom: 0; padding-bottom: 0;
margin-right: 15px; margin-right: 15px;
} }
.form-control .label + * { .form-control .label + * {
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
} }

View File

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

View File

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

View File

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

@@ -1,6 +1,6 @@
<template> <template>
<div v-if="customFields && customFields.length > 0" class="space-y-4"> <div v-if="customFields && customFields.length > 0" class="space-y-4">
<h4 class="font-semibold text-gray-700 mb-3"> <h4 class="font-semibold text-base-content/80 mb-3">
Champs personnalisés Champs personnalisés
</h4> </h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -10,11 +10,11 @@
<div class="min-w-0"> <div class="min-w-0">
<h3 class="font-bold text-xl truncate"> <h3 class="font-bold text-xl truncate">
Prévisualisation Prévisualisation
<span v-if="navTotal > 1" class="text-base font-normal text-gray-500"> <span v-if="navTotal > 1" class="text-base font-normal text-base-content/50">
{{ activeIndex + 1 }} / {{ navTotal }} {{ activeIndex + 1 }} / {{ navTotal }}
</span> </span>
</h3> </h3>
<p class="text-sm text-gray-500 truncate"> <p class="text-sm text-base-content/50 truncate">
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span> {{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span>
</p> </p>
</div> </div>
@@ -68,7 +68,7 @@
<template v-else-if="previewType === 'text'"> <template v-else-if="previewType === 'text'">
<div class="w-full h-full overflow-auto"> <div class="w-full h-full overflow-auto">
<div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-gray-500"> <div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-base-content/50">
<span class="loading loading-spinner loading-md mr-2" /> <span class="loading loading-spinner loading-md mr-2" />
Chargement du document... Chargement du document...
</div> </div>
@@ -82,7 +82,7 @@
</template> </template>
<template v-else> <template v-else>
<div class="text-sm text-gray-500 text-center px-6"> <div class="text-sm text-base-content/50 text-center px-6">
Prévisualisation non disponible pour ce type de document. Prévisualisation non disponible pour ce type de document.
</div> </div>
</template> </template>

View File

@@ -13,7 +13,7 @@
<h3 class="font-semibold"> <h3 class="font-semibold">
{{ title }} {{ title }}
</h3> </h3>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50">
{{ subtitle }} {{ subtitle }}
</p> </p>
</div> </div>
@@ -22,7 +22,7 @@
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog"> <button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
Sélectionner des fichiers Sélectionner des fichiers
</button> </button>
<span class="text-xs text-gray-500">ou glisser-déposer ici</span> <span class="text-xs text-base-content/50">ou glisser-déposer ici</span>
</div> </div>
<input <input
@@ -54,7 +54,7 @@
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-medium">{{ file.name }}</span> <span class="font-medium">{{ file.name }}</span>
<span class="text-xs text-gray-500">{{ formatSize(file.size) }} {{ file.type || 'Type inconnu' }}</span> <span class="text-xs text-base-content/50">{{ formatSize(file.size) }} {{ file.type || 'Type inconnu' }}</span>
</div> </div>
</div> </div>
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)"> <button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-600"> <div class="flex flex-wrap items-center gap-2 text-sm text-base-content/60">
<span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span> <span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span>
<span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span> <span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span>
<span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span> <span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span>
<span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-gray-500"> <span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-base-content/50">
Structure vide Structure vide
</span> </span>
</div> </div>

View File

@@ -1,11 +1,11 @@
<template> <template>
<section :class="sectionClasses"> <section :class="sectionClasses">
<div :class="contentClasses"> <div :class="contentClasses">
<div :class="['space-y-4', maxWidthClass]"> <div :class="['space-y-3', maxWidthClass]">
<component :is="headingTag" v-if="title" class="text-4xl font-bold"> <component :is="headingTag" v-if="title" class="text-4xl font-bold tracking-tight">
{{ title }} {{ title }}
</component> </component>
<p v-if="subtitle" class="text-sm opacity-90"> <p v-if="subtitle" class="text-sm opacity-80 leading-relaxed">
{{ subtitle }} {{ subtitle }}
</p> </p>
<slot /> <slot />
@@ -58,9 +58,9 @@ const props = defineProps({
}) })
const sectionClasses = computed(() => { const sectionClasses = computed(() => {
const classes = ['hero', 'bg-gradient-to-r', props.gradientFrom, props.gradientTo, props.minHeight] const classes = ['hero', 'bg-gradient-to-br', props.gradientFrom, props.gradientTo, props.minHeight]
if (props.rounded) { if (props.rounded) {
classes.push('rounded-lg') classes.push('rounded-xl', 'overflow-hidden')
} }
return classes return classes
}) })

View File

@@ -9,7 +9,7 @@
<!-- Piece Header (collapsible, same pattern as ComponentItem) --> <!-- 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 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 <button
type="button" type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform" 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" /> <IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span> <span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button> </button>
<div class="flex-1"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold"> <h3 class="text-lg font-semibold">
{{ pieceData.name }} {{ pieceData.name }}
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3> </h3>
<div class="flex flex-wrap gap-2 mt-2"> <div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm"> <span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
@@ -49,11 +55,37 @@
</div> </div>
</div> </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>
<div v-show="!isCollapsed" class="space-y-4"> <div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg"> <div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm"> <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> <div>
<span class="font-medium">Référence:</span> <span class="font-medium">Référence:</span>
<input <input
@@ -82,7 +114,7 @@
</span> </span>
<span <span
v-if="formatConstructeurContact(constructeur)" v-if="formatConstructeurContact(constructeur)"
class="text-xs text-gray-500" class="text-xs text-base-content/50"
> >
{{ formatConstructeurContact(constructeur) }} {{ formatConstructeurContact(constructeur) }}
</span> </span>
@@ -186,9 +218,9 @@
@field-blur="handleCustomFieldBlur" @field-blur="handleCustomFieldBlur"
/> />
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3"> <div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-gray-700">Documents</h5> <h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span <span
v-if="isEditMode && selectedFiles.length" v-if="isEditMode && selectedFiles.length"
class="badge badge-outline" class="badge badge-outline"
@@ -200,7 +232,7 @@
</span> </span>
</div> </div>
<p v-if="loadingDocuments" class="text-xs text-gray-500"> <p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement des documents... Chargement des documents...
</p> </p>
@@ -250,11 +282,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({ const props = defineProps({
piece: { type: Object, required: true }, piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false }, isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true }, collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 }, 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 --- // --- Local reactive data for editing ---
const pieceData = reactive({ const pieceData = reactive({
@@ -262,6 +295,11 @@ const pieceData = reactive({
reference: props.piece.reference || '', reference: props.piece.reference || '',
prix: props.piece.prix || '', prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null, productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
}) })
// --- Products --- // --- Products ---
@@ -422,13 +460,14 @@ const updatePiece = () => {
let parsedPrice = null let parsedPrice = null
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) { if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
const numeric = Number(prixValue) const numeric = Number(prixValue)
if (!Number.isNaN(numeric)) parsedPrice = numeric if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
} }
const product = selectedProduct.value ? { ...selectedProduct.value } : null const product = selectedProduct.value ? { ...selectedProduct.value } : null
emit('update', { emit('update', {
...props.piece, ...props.piece,
...pieceData, ...pieceData,
prix: parsedPrice, prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null, productId: pieceData.productId || null,
product, product,
constructeurIds: pieceConstructeurIds.value, constructeurIds: pieceConstructeurIds.value,
@@ -468,11 +507,12 @@ watch(
) )
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.name = props.piece.name || ''
pieceData.reference = props.piece.reference || '' pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || '' pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
}, },
) )
@@ -480,6 +520,7 @@ onMounted(() => {
pieceData.name = props.piece.name || '' pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || '' pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || '' pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {}) loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId) if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments() if (!props.piece.documents?.length) refreshDocuments()

View File

@@ -10,7 +10,7 @@
</p> </p>
</header> </header>
<p v-if="!products.length" class="text-xs text-gray-500"> <p v-if="!products.length" class="text-xs text-base-content/50">
Aucun produit défini. Aucun produit défini.
</p> </p>
@@ -29,7 +29,6 @@
<select <select
v-model="product.typeProductId" v-model="product.typeProductId"
class="select select-bordered select-xs" class="select select-bordered select-xs"
:disabled="isProductLocked(product)"
@change="handleProductTypeSelect(product)" @change="handleProductTypeSelect(product)"
> >
<option value=""> <option value="">
@@ -46,26 +45,16 @@
</div> </div>
</div> </div>
<button <button
v-if="!isProductLocked(product)"
type="button" type="button"
class="btn btn-error btn-xs btn-square" class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)" @click="removeProduct(index)"
> >
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </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>
</li> </li>
</ul> </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" /> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter Ajouter
</button> </button>
@@ -76,7 +65,7 @@
Champs personnalisés Champs personnalisés
</h3> </h3>
<p v-if="!fields.length" class="text-xs text-gray-500"> <p v-if="!fields.length" class="text-xs text-base-content/50">
Aucun champ personnalisé n'a encore été défini. Aucun champ personnalisé n'a encore été défini.
</p> </p>
@@ -111,7 +100,7 @@
class="input input-bordered input-xs" class="input input-bordered input-xs"
placeholder="Nom du champ" 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"> <option value="text">
Texte Texte
</option> </option>
@@ -131,7 +120,7 @@
</div> </div>
<div class="flex items-center gap-2 text-xs"> <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 Obligatoire
</div> </div>
@@ -140,27 +129,16 @@
v-model="field.optionsText" v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20" class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2" placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/> />
</div> </div>
<button <button
v-if="!isFieldLocked(field)"
type="button" type="button"
class="btn btn-error btn-xs btn-square" class="btn btn-error btn-xs btn-square"
@click="removeField(index)" @click="removeField(index)"
> >
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </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>
</li> </li>
</ul> </ul>
@@ -183,7 +161,6 @@ defineOptions({ name: 'PieceModelStructureEditor' })
const props = defineProps<{ const props = defineProps<{
modelValue?: PieceModelStructure | null modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -194,9 +171,6 @@ const {
fields, fields,
products, products,
productTypeOptions, productTypeOptions,
restrictedMode,
isFieldLocked,
isProductLocked,
formatProductTypeOption, formatProductTypeOption,
handleProductTypeSelect, handleProductTypeSelect,
addProduct, 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( watch(
() => props.modelValue, () => props.modelValue,
(value) => { (value) => {
if (typeof value === 'string') { if (typeof value === 'string' && value) {
const exists = productOptions.value.some((product) => product.id === value) const exists = productOptions.value.some((product) => product.id === value)
if (!exists && productOptions.value.length === 0 && !loading.value) { if (!exists && !loading.value) {
loadProducts().catch((error) => { loadProducts({ force: true }).catch((error) => {
console.error('Erreur lors du chargement des produits:', error) console.error('Erreur lors du chargement des produits:', error)
}) })
} }

View File

@@ -9,7 +9,7 @@
</span> </span>
</label> </label>
<template v-if="isRoot"> <template v-if="isRoot">
<p class="text-[11px] text-gray-500"> <p class="text-[11px] text-base-content/50">
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants. Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
</p> </p>
</template> </template>
@@ -31,7 +31,7 @@
{{ formatComponentTypeOption(type) }} {{ formatComponentTypeOption(type) }}
</option> </option>
</select> </select>
<p class="text-[11px] text-gray-500"> <p class="text-[11px] text-base-content/50">
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }} {{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p> </p>
<div v-if="!isRoot" class="form-control mt-2"> <div v-if="!isRoot" class="form-control mt-2">
@@ -73,7 +73,7 @@
<h4 :class="headingClass"> <h4 :class="headingClass">
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }} {{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
</h4> </h4>
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500"> <p v-if="!(node.customFields?.length)" class="text-xs text-base-content/50">
Aucun champ n'a encore été défini. Aucun champ n'a encore été défini.
</p> </p>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
@@ -109,7 +109,7 @@
class="input input-bordered input-xs" class="input input-bordered input-xs"
placeholder="Nom du champ" 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="text">Texte</option>
<option value="number">Nombre</option> <option value="number">Nombre</option>
<option value="select">Liste</option> <option value="select">Liste</option>
@@ -118,7 +118,7 @@
</select> </select>
</div> </div>
<div class="flex items-center gap-2 text-xs"> <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 Obligatoire
</div> </div>
<textarea <textarea
@@ -126,26 +126,15 @@
v-model="field.optionsText" v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20" class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2" placeholder="Option 1&#10;Option 2"
:disabled="isCustomFieldLocked(index)"
></textarea> ></textarea>
</div> </div>
<button <button
v-if="!isCustomFieldLocked(index)"
type="button" type="button"
class="btn btn-error btn-xs btn-square" class="btn btn-error btn-xs btn-square"
@click="removeCustomField(index)" @click="removeCustomField(index)"
> >
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </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> </div>
</div> </div>
@@ -159,7 +148,7 @@
<h4 :class="headingClass"> <h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }} {{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4> </h4>
<p v-if="!(node.products?.length)" class="text-xs text-gray-500"> <p v-if="!(node.products?.length)" class="text-xs text-base-content/50">
Aucun produit défini. Aucun produit défini.
</p> </p>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
@@ -189,7 +178,6 @@
<select <select
v-model="product.typeProductId" v-model="product.typeProductId"
class="select select-bordered select-xs" class="select select-bordered select-xs"
:disabled="isProductLocked(index)"
@change="handleProductTypeSelect(product)" @change="handleProductTypeSelect(product)"
> >
<option value=""> <option value="">
@@ -205,22 +193,13 @@
</select> </select>
</div> </div>
</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" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </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> </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" /> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter Ajouter
</button> </button>
@@ -230,7 +209,7 @@
<h4 :class="headingClass"> <h4 :class="headingClass">
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }} {{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
</h4> </h4>
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500"> <p v-if="!(node.pieces?.length)" class="text-xs text-base-content/50">
Aucune pièce définie. Aucune pièce définie.
</p> </p>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
@@ -261,7 +240,6 @@
<select <select
v-model="piece.typePieceId" v-model="piece.typePieceId"
class="select select-bordered select-xs" class="select select-bordered select-xs"
:disabled="isPieceLocked(index)"
@change="handlePieceTypeSelect(piece)" @change="handlePieceTypeSelect(piece)"
> >
<option value=""> <option value="">
@@ -276,23 +254,30 @@
</option> </option>
</select> </select>
</div> </div>
<p class="mt-1 text-[11px] text-gray-500"> <p class="mt-1 text-[11px] text-base-content/50">
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }} {{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p> </p>
</div> </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> </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" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </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> </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" /> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter Ajouter
</button> </button>
@@ -300,10 +285,10 @@
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3"> <section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
<h4 :class="headingClass">Sous-composants</h4> <h4 :class="headingClass">Sous-composants</h4>
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-gray-500"> <p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-base-content/50">
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle. Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
</p> </p>
<p v-if="!hasSubcomponents" class="text-xs text-gray-500"> <p v-if="!hasSubcomponents" class="text-xs text-base-content/50">
Aucun sous-composant défini. Aucun sous-composant défini.
</p> </p>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
@@ -334,14 +319,12 @@
:product-types="productTypes" :product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents" :allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth" :max-subcomponent-depth="maxSubcomponentDepth"
:restricted-mode="restrictedMode"
:is-locked="isSubcomponentLocked(index)"
@remove="removeSubComponent(index)" @remove="removeSubComponent(index)"
/> />
</div> </div>
</div> </div>
<button <button
v-if="canManageSubcomponents && !restrictedMode" v-if="canManageSubcomponents"
type="button" type="button"
class="btn btn-outline btn-xs" class="btn btn-outline btn-xs"
@click="addSubComponent" @click="addSubComponent"
@@ -374,7 +357,6 @@ const props = withDefaults(defineProps<{
lockedTypeLabel?: string lockedTypeLabel?: string
allowSubcomponents?: boolean allowSubcomponents?: boolean
maxSubcomponentDepth?: number maxSubcomponentDepth?: number
restrictedMode?: boolean
isLocked?: boolean isLocked?: boolean
}>(), { }>(), {
depth: 0, depth: 0,
@@ -386,19 +368,13 @@ const props = withDefaults(defineProps<{
lockedTypeLabel: '', lockedTypeLabel: '',
allowSubcomponents: true, allowSubcomponents: true,
maxSubcomponentDepth: Infinity, maxSubcomponentDepth: Infinity,
restrictedMode: false,
isLocked: false, isLocked: false,
}) })
const emit = defineEmits(['remove']) const emit = defineEmits(['remove'])
const { const {
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
isLocked, isLocked,
restrictedMode,
componentTypes, componentTypes,
pieceTypes, pieceTypes,
productTypes, 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

@@ -1,9 +1,9 @@
<template> <template>
<div <div
v-if="fields.length" v-if="fields.length"
class="mt-4 pt-4 border-t border-gray-200" class="mt-4 pt-4 border-t border-base-200"
> >
<h5 class="text-sm font-medium text-gray-700 mb-3"> <h5 class="text-sm font-medium text-base-content/80 mb-3">
Champs personnalisés Champs personnalisés
</h5> </h5>
<div :class="layoutClass"> <div :class="layoutClass">

View File

@@ -63,13 +63,13 @@
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) --> <!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
<template v-else> <template v-else>
<div class="overflow-x-auto overflow-y-clip relative"> <div class="overflow-x-auto overflow-y-clip relative rounded-lg border border-base-300/40">
<!-- Loading overlay (keeps table & filter inputs visible) --> <!-- Loading overlay (keeps table & filter inputs visible) -->
<div <div
v-if="loading && hasFilterableColumns" v-if="loading && hasFilterableColumns"
class="absolute inset-0 bg-base-100/50 z-10 flex items-center justify-center" class="absolute inset-0 bg-base-100/60 backdrop-blur-[1px] z-10 flex items-center justify-center"
> >
<span class="loading loading-spinner" aria-hidden="true" /> <span class="loading loading-spinner text-primary" aria-hidden="true" />
</div> </div>
<table :class="['table table-sm md:table-md', tableClass]"> <table :class="['table table-sm md:table-md', tableClass]">
<thead> <thead>
@@ -175,7 +175,7 @@
</tr> </tr>
<!-- Expanded row --> <!-- Expanded row -->
<tr v-if="expandable && isExpanded(row)"> <tr v-if="expandable && isExpanded(row)">
<td :colspan="columns.length + 1" class="bg-base-200/50 p-4"> <td :colspan="columns.length + 1" class="bg-base-200/30 p-4 border-t border-base-200/80">
<slot name="row-expanded" :row="row" :index="idx" /> <slot name="row-expanded" :row="row" :index="idx" />
</td> </td>
</tr> </tr>

View File

@@ -9,11 +9,11 @@
</button> </button>
</div> </div>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50">
{{ labels.description }} {{ labels.description }}
</p> </p>
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4"> <div v-if="requirements.length === 0" class="text-sm text-base-content/50 bg-base-200/60 rounded-md p-4">
{{ labels.emptyState }} {{ labels.emptyState }}
</div> </div>

View File

@@ -41,11 +41,11 @@
v-if="openDropdown" v-if="openDropdown"
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg" class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
> >
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-gray-500"> <div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-base-content/50">
<span class="loading loading-spinner loading-xs" /> <span class="loading loading-spinner loading-xs" />
Recherche en cours Recherche en cours
</div> </div>
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-gray-500"> <div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
{{ emptyText }} {{ emptyText }}
</div> </div>
<ul v-else class="flex flex-col"> <ul v-else class="flex flex-col">
@@ -69,7 +69,7 @@
{{ resolveLabel(option) }} {{ resolveLabel(option) }}
</slot> </slot>
</span> </span>
<span v-if="resolveDescription(option)" class="text-xs text-gray-500"> <span v-if="resolveDescription(option)" class="text-xs text-base-content/50">
<slot name="option-description" :option="option"> <slot name="option-description" :option="option">
{{ resolveDescription(option) }} {{ resolveDescription(option) }}
</slot> </slot>

View File

@@ -93,7 +93,7 @@
<!-- Empty state: component variant --> <!-- Empty state: component variant -->
<p <p
v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length" v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length"
class="text-xs text-gray-500" class="text-xs text-base-content/50"
> >
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut. Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
</p> </p>

View File

@@ -23,7 +23,7 @@
@blur="onBlur" @blur="onBlur"
@focus="(event) => emit('focus', event)" @focus="(event) => emit('focus', event)"
/> />
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500"> <p v-if="help" :id="helpId" class="mt-2 text-xs text-base-content/50">
{{ help }} {{ help }}
</p> </p>
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error"> <p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">

View File

@@ -1,18 +1,28 @@
<template> <template>
<div class="navbar bg-base-100 shadow-lg"> <div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
<div class="navbar-start"> <div class="navbar-start">
<!-- Mobile hamburger menu --> <!-- Mobile hamburger menu -->
<div class="dropdown"> <div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
<IconLucideMenu class="w-5 h-5" aria-hidden="true" /> <IconLucideMenu class="w-5 h-5" aria-hidden="true" />
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52" 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"> <li class="pt-1 pb-2 lg:hidden">
<button <button
class="w-full flex items-center gap-2 rounded-md px-2 py-1 transition-colors text-base-content hover:bg-primary/10 hover:text-primary" 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"
@click="$emit('open-settings')" @click="$emit('open-settings')"
> >
<IconLucideSettings class="w-4 h-4" aria-hidden="true" /> <IconLucideSettings class="w-4 h-4" aria-hidden="true" />
@@ -24,7 +34,7 @@
<li v-for="link in simpleLinks" :key="link.to"> <li v-for="link in simpleLinks" :key="link.to">
<NuxtLink <NuxtLink
:to="link.to" :to="link.to"
class="rounded-md px-2 py-1 transition-colors flex items-center gap-2" class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
:class="linkClass(link)" :class="linkClass(link)"
> >
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" /> <component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
@@ -40,7 +50,7 @@
> >
<button <button
type="button" type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
:class="groupClass(group)" :class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-mobile'" :aria-expanded="openDropdown === group.id + '-mobile'"
@click="toggleDropdown(group.id + '-mobile')" @click="toggleDropdown(group.id + '-mobile')"
@@ -52,7 +62,7 @@
{{ group.label }} {{ group.label }}
</span> </span>
<IconLucideChevronRight <IconLucideChevronRight
class="h-4 w-4 transition-transform" class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''" :class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
aria-hidden="true" aria-hidden="true"
/> />
@@ -60,12 +70,12 @@
<Transition name="nav-dropdown-mobile"> <Transition name="nav-dropdown-mobile">
<ul <ul
v-if="openDropdown === group.id + '-mobile'" v-if="openDropdown === group.id + '-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden" class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
> >
<li v-for="child in group.children" :key="child.to"> <li v-for="child in group.children" :key="child.to">
<NuxtLink <NuxtLink
:to="child.to" :to="child.to"
class="rounded-md px-2 py-1 transition-colors block" class="rounded-md px-3 py-1.5 transition-colors block text-sm"
:class="childLinkClass(child)" :class="childLinkClass(child)"
> >
{{ child.label }} {{ child.label }}
@@ -81,30 +91,28 @@
</div> </div>
<!-- Logo --> <!-- Logo -->
<div class="flex items-center space-x-3"> <NuxtLink to="/" class="flex items-center gap-2.5 group">
<div class="avatar"> <div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
<div class="w-14"> <img
<img :src="logoSrc"
:src="logoSrc" alt="Logo Malio"
alt="Logo Malio" class="h-full w-full object-contain"
class="h-full w-full object-contain" />
/>
</div>
</div> </div>
<NuxtLink to="/" class="btn btn-ghost text-xl"> <span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
Inventory Inventory
</NuxtLink> </span>
</div> </NuxtLink>
</div> </div>
<!-- Desktop navbar --> <!-- Desktop navbar -->
<div class="navbar-center hidden lg:flex"> <div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1"> <ul class="menu menu-horizontal gap-0.5 px-1">
<!-- Desktop: simple links --> <!-- Desktop: simple links -->
<li v-for="link in simpleLinks" :key="link.to"> <li v-for="link in simpleLinks" :key="link.to">
<NuxtLink <NuxtLink
:to="link.to" :to="link.to"
class="transition-colors px-3 py-2 rounded-md flex items-center gap-1.5" class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
:class="linkClass(link)" :class="linkClass(link)"
> >
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" /> <component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
@@ -124,7 +132,7 @@
> >
<button <button
type="button" type="button"
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 transition-colors" class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 transition-all text-sm font-medium"
:class="groupClass(group)" :class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'" :aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')" @click="toggleDropdown(group.id + '-desktop')"
@@ -133,21 +141,21 @@
> >
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" /> <component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }} {{ group.label }}
<IconLucideChevronRight <IconLucideChevronDown
class="h-4 w-4 transition-transform" class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-desktop' ? 'rotate-90' : ''" :class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
<Transition name="nav-dropdown-desktop"> <Transition name="nav-dropdown-desktop">
<ul <ul
v-if="openDropdown === group.id + '-desktop'" v-if="openDropdown === group.id + '-desktop'"
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50" class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
> >
<li v-for="child in group.children" :key="child.to"> <li v-for="child in group.children" :key="child.to">
<NuxtLink <NuxtLink
:to="child.to" :to="child.to"
class="block rounded-md px-2 py-1 transition-colors" class="block rounded-lg px-3 py-2 transition-all text-sm"
:class="childLinkClass(child)" :class="childLinkClass(child)"
> >
{{ child.label }} {{ child.label }}
@@ -164,13 +172,21 @@
<!-- Navbar end --> <!-- Navbar end -->
<div class="navbar-end"> <div class="navbar-end">
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
<button <button
class="btn btn-ghost btn-circle hidden lg:inline-flex" 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" title="Paramètres d'affichage"
@click="$emit('open-settings')" @click="$emit('open-settings')"
> >
<IconLucideSettings class="w-5 h-5" aria-hidden="true" /> <IconLucideSettings class="w-4 h-4" aria-hidden="true" />
</button> </button>
<ClientOnly> <ClientOnly>
@@ -178,7 +194,7 @@
<div <div
tabindex="0" tabindex="0"
role="button" role="button"
class="btn btn-ghost btn-circle avatar placeholder indicator" class="indicator cursor-pointer"
> >
<span <span
v-if="unresolvedCount > 0" v-if="unresolvedCount > 0"
@@ -187,47 +203,49 @@
{{ unresolvedCount }} {{ unresolvedCount }}
</span> </span>
<div <div
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center" class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
> >
<span <span class="text-xs font-semibold">
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
>
{{ activeProfileInitials }} {{ activeProfileInitials }}
</span> </span>
</div> </div>
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64" class="menu dropdown-content mt-3 p-2 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
> >
<li class="px-2 py-1 text-sm text-base-content/70"> <li class="px-3 py-2">
Connecté en tant que<br /> <div class="flex flex-col gap-1 pointer-events-none">
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span> <span class="text-xs text-base-content/50">Connecté en tant que</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span> <span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
</div>
</li> </li>
<div class="divider my-0.5 px-2" />
<li v-if="isAdmin"> <li v-if="isAdmin">
<NuxtLink to="/admin" class="justify-between"> <NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
Administration Administration
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" /> <IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink> </NuxtLink>
</li> </li>
<li> <li>
<NuxtLink to="/comments" class="justify-between"> <NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
Commentaires Commentaires
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs"> <span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
{{ unresolvedCount }} {{ unresolvedCount }}
</span> </span>
<IconLucideChevronRight v-else class="w-4 h-4" aria-hidden="true" /> <IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink> </NuxtLink>
</li> </li>
<div class="divider my-0.5 px-2" />
<li> <li>
<button <button
type="button" type="button"
class="text-error justify-between" class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
@click="$emit('logout')" @click="$emit('logout')"
> >
Déconnexion Déconnexion
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" /> <IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</li> </li>
</ul> </ul>
@@ -248,6 +266,7 @@ import { useComments } from '~/composables/useComments'
import IconLucideMenu from '~icons/lucide/menu' import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings' import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideLogOut from '~icons/lucide/log-out' import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard' import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory' import IconLucideFactory from '~icons/lucide/factory'
@@ -256,6 +275,8 @@ import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle' import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package' import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link' 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' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{ defineEmits<{
@@ -333,6 +354,7 @@ const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = use
const { activeProfile } = useProfileSession() const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions() const { isAdmin, canEdit } = usePermissions()
const { fetchUnresolvedCount } = useComments() const { fetchUnresolvedCount } = useComments()
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
const unresolvedCount = ref(0) const unresolvedCount = ref(0)
let pollInterval: ReturnType<typeof setInterval> | null = null let pollInterval: ReturnType<typeof setInterval> | null = null
@@ -343,6 +365,7 @@ const refreshUnresolvedCount = async () => {
} }
onMounted(() => { onMounted(() => {
initDarkMode()
refreshUnresolvedCount() refreshUnresolvedCount()
pollInterval = setInterval(refreshUnresolvedCount, 60_000) pollInterval = setInterval(refreshUnresolvedCount, 60_000)
}) })
@@ -365,19 +388,19 @@ const isGroupActive = (group: NavGroup) => {
const linkClass = (link: NavLink) => { const linkClass = (link: NavLink) => {
return isActive(link.to) return isActive(link.to)
? 'bg-primary text-primary-content font-semibold shadow-sm' ? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
} }
const groupClass = (group: NavGroup) => { const groupClass = (group: NavGroup) => {
return isGroupActive(group) return isGroupActive(group)
? 'bg-primary text-primary-content font-semibold shadow-sm' ? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
} }
const childLinkClass = (child: NavLink) => { const childLinkClass = (child: NavLink) => {
return isActive(child.to) return isActive(child.to)
? 'bg-primary/10 text-primary font-semibold' ? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
} }
const roleLabel = computed(() => { const roleLabel = computed(() => {
@@ -418,12 +441,12 @@ const activeProfileInitials = computed(() => {
.nav-dropdown-desktop-enter-from, .nav-dropdown-desktop-enter-from,
.nav-dropdown-desktop-leave-to { .nav-dropdown-desktop-leave-to {
opacity: 0; opacity: 0;
transform: translateY(0.25rem); transform: translateY(4px) scale(0.98);
} }
.nav-dropdown-desktop-enter-to, .nav-dropdown-desktop-enter-to,
.nav-dropdown-desktop-leave-from { .nav-dropdown-desktop-leave-from {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0) scale(1);
} }
.nav-dropdown-mobile-enter-active, .nav-dropdown-mobile-enter-active,

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2> <h2 class="card-title">Composants</h2>
@@ -25,22 +25,15 @@
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div v-for="component in components" :key="component.id" class="relative"> <div v-for="component in components" :key="component.id">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer ce composant"
@click="$emit('remove-component', component.linkId || component.id)"
>
Supprimer
</button>
<ComponentHierarchy <ComponentHierarchy
:components="[component]" :components="[component]"
:is-edit-mode="false" :is-edit-mode="false"
:show-delete="isEditMode"
:collapse-all="collapsed" :collapse-all="collapsed"
:toggle-token="collapseToggleToken" :toggle-token="collapseToggleToken"
@edit-piece="$emit('edit-piece', $event)" @edit-piece="$emit('edit-piece', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
/> />
</div> </div>
</div> </div>

View File

@@ -84,7 +84,7 @@
<button <button
type="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)" @click="$emit('remove-field', index)"
> >
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <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

@@ -1,5 +1,5 @@
<template> <template>
<div class="card bg-base-100 shadow-lg mt-6"> <div class="card bg-base-100 shadow-sm mt-6">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -74,7 +74,7 @@
<button <button
v-if="isEditMode" v-if="isEditMode"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-ghost btn-xs text-error"
:disabled="uploading" :disabled="uploading"
@click="$emit('remove', doc.id)" @click="$emit('remove', doc.id)"
> >

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">Informations de la machine</h2> <h2 class="card-title tracking-tight">Informations de la machine</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -20,6 +20,29 @@
{{ machineName }} {{ machineName }}
</div> </div>
</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"> <div v-if="isEditMode || machineReference" class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Référence</span> <span class="label-text">Référence</span>
@@ -49,30 +72,30 @@
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
@update:modelValue="$emit('update:constructeur-ids', $event)" @update:modelValue="$emit('update:constructeur-ids', $event)"
/> />
<div v-else class="input input-bordered bg-base-200"> <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="space-y-1"> <div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2">
<div <span
v-for="constructeur in machineConstructeursDisplay" v-for="constructeur in machineConstructeursDisplay"
:key="constructeur.id" :key="constructeur.id"
class="flex flex-col" class="badge badge-ghost gap-1"
> >
<span class="font-medium">{{ constructeur.name }}</span> {{ constructeur.name }}
<span <span
v-if="formatConstructeurContactSummary(constructeur)" v-if="formatConstructeurContactSummary(constructeur)"
class="text-xs text-gray-500" class="text-xs opacity-60"
> >
{{ formatConstructeurContactSummary(constructeur) }} · {{ formatConstructeurContactSummary(constructeur) }}
</span> </span>
</div> </span>
</div> </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> </div>
</div> </div>
<!-- Champs personnalisés --> <!-- Champs personnalisés -->
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-gray-200"> <div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-base-200">
<h4 class="font-semibold text-gray-700 mb-3">Champs personnalisés de la machine</h4> <h4 class="font-semibold text-base-content/80 mb-3">Champs personnalisés de la machine</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div <div
v-for="field in visibleCustomFields" v-for="field in visibleCustomFields"
@@ -184,6 +207,9 @@ const props = defineProps<{
isEditMode: boolean isEditMode: boolean
machineName: string machineName: string
machineReference: string machineReference: string
machineSiteId: string
machineSiteName: string
sites: any[]
machineConstructeurIds: string[] machineConstructeurIds: string[]
machineConstructeursDisplay: any[] machineConstructeursDisplay: any[]
hasMachineConstructeur: boolean hasMachineConstructeur: boolean
@@ -196,6 +222,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:machine-name': [value: string] 'update:machine-name': [value: string]
'update:machine-reference': [value: string] 'update:machine-reference': [value: string]
'update:machine-site-id': [value: string]
'update:constructeur-ids': [ids: unknown] 'update:constructeur-ids': [ids: unknown]
'blur-field': [] 'blur-field': []
'set-custom-field-value': [field: any, value: unknown] 'set-custom-field-value': [field: any, value: unknown]

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2> <h2 class="card-title">Pièces de la machine</h2>
@@ -25,22 +25,16 @@
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div v-for="piece in pieces" :key="piece.id" class="relative"> <div v-for="piece in pieces" :key="piece.id">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer cette pièce"
@click="$emit('remove-piece', piece.linkId || piece.id)"
>
Supprimer
</button>
<PieceItem <PieceItem
:piece="piece" :piece="piece"
:is-edit-mode="false" :is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed" :collapse-all="collapsed"
:toggle-token="collapseToggleToken" :toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)" @edit="$emit('edit-piece', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
/> />
</div> </div>
</div> </div>
@@ -69,6 +63,7 @@ defineProps<{
defineEmits<{ defineEmits<{
'toggle-collapse': [] 'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any] 'edit-piece': [piece: any]
'add-piece': [] 'add-piece': []
'remove-piece': [linkId: string] 'remove-piece': [linkId: string]

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
@@ -24,24 +24,26 @@
<div <div
v-for="product in products" v-for="product in products"
:key="product.id || product.name" :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"> <div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold text-base-content"> <p class="font-semibold text-base-content">
{{ product.name }} {{ product.name }}
</p> </p>
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm"> <div class="flex items-center gap-2">
{{ product.groupLabel }} <span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
</span> {{ 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> </div>
<p v-if="product.reference" class="text-xs text-base-content/70"> <p v-if="product.reference" class="text-xs text-base-content/70">
<span class="font-medium">Référence :</span> <span class="font-medium">Référence :</span>

View File

@@ -15,8 +15,7 @@
minlength="2" minlength="2"
maxlength="120" maxlength="120"
required required
:disabled="restrictedMode" />
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p> <p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div> </div>
<div> <div>
@@ -48,7 +47,6 @@
rows="4" rows="4"
name="notes" name="notes"
maxlength="2000" maxlength="2000"
:disabled="restrictedMode"
></textarea> ></textarea>
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p> <p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
</div> </div>
@@ -83,7 +81,6 @@
v-model="componentStructure" v-model="componentStructure"
:allow-subcomponents="allowComponentSubcomponents" :allow-subcomponents="allowComponentSubcomponents"
:max-subcomponent-depth="componentSubcomponentMaxDepth" :max-subcomponent-depth="componentSubcomponentMaxDepth"
:restricted-mode="restrictedMode"
/> />
</div> </div>
@@ -95,7 +92,7 @@
Aperçu : Aperçu :
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span> <span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
</p> </p>
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" /> <PieceModelStructureEditor v-model="pieceStructure" />
</div> </div>
<div <div
@@ -106,30 +103,11 @@
Aperçu : Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span> <span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p> </p>
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" /> <PieceModelStructureEditor v-model="productStructure" />
</div> </div>
</template> </template>
</section> </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"> <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')"> <button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler Annuler
@@ -172,10 +150,6 @@ const props = withDefaults(defineProps<{
structureLoading?: boolean structureLoading?: boolean
allowComponentSubcomponents?: boolean allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number componentSubcomponentMaxDepth?: number
disableSubmit?: boolean
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
readonly?: boolean readonly?: boolean
}>(), { }>(), {
initialData: null, initialData: null,
@@ -184,10 +158,6 @@ const props = withDefaults(defineProps<{
structureLoading: false, structureLoading: false,
allowComponentSubcomponents: true, allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1, componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
readonly: false, readonly: false,
}) })
@@ -205,19 +175,7 @@ const componentSubcomponentMaxDepth = computed(() =>
? props.componentSubcomponentMaxDepth ? props.componentSubcomponentMaxDepth
: 1, : 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 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>({ const form = reactive<ModelTypePayload>({
name: '', name: '',
@@ -294,7 +252,7 @@ const resetForm = () => {
} }
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer')) 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 = () => { const validate = () => {
errors.name = undefined errors.name = undefined

View File

@@ -1,27 +1,37 @@
<template> <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="card-body">
<div class="flex items-center justify-between mb-4"> <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 }} {{ site.name }}
</h3> </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 {{ machineCount }} machines
</div> </div>
</div> </div>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<div class="flex items-center gap-2 text-gray-700"> <div class="flex items-center gap-2 text-base-content/80">
<IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" /> <IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" />
<span class="font-medium">{{ site.contactName }}</span> <span class="font-medium">{{ site.contactName }}</span>
</div> </div>
<div class="flex items-center gap-2 text-gray-600"> <div class="flex items-center gap-2 text-base-content/60">
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" /> <IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
<span>{{ formattedContactPhone }}</span> <span>{{ formattedContactPhone }}</span>
</div> </div>
<div class="flex items-start gap-2 text-gray-600"> <div class="flex items-start gap-2 text-base-content/60">
<IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" /> <IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" />
<span> <span>
{{ site.contactAddress }}<br> {{ site.contactAddress }}<br>
@@ -29,7 +39,7 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-2 text-gray-600"> <div class="flex items-center gap-2 text-base-content/60">
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" /> <IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span>{{ machineCount }} machine(s)</span> <span>{{ machineCount }} machine(s)</span>
</div> </div>

View File

@@ -17,6 +17,46 @@
/> />
</div> </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" /> <SiteContactFormFields :form="siteRef" :disabled="disabled" />
<div class="modal-action"> <div class="modal-action">
@@ -39,6 +79,7 @@ import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
type SiteForm = { type SiteForm = {
name: string name: string
color: string
contactName: string contactName: string
contactPhone: string contactPhone: string
contactAddress: string contactAddress: string

View File

@@ -3,7 +3,7 @@
<div class="modal-box max-w-md"> <div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4"> <h3 class="font-bold text-lg mb-4">
{{ disabled ? 'Détails du site' : 'Modifier le site' }} {{ disabled ? 'Détails du site' : 'Modifier le site' }}
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span> <span v-if="siteName" class="block text-sm font-normal text-base-content/50">{{ siteName }}</span>
</h3> </h3>
<form class="space-y-4" @submit.prevent="emit('submit')"> <form class="space-y-4" @submit.prevent="emit('submit')">
<div class="form-control"> <div class="form-control">
@@ -20,6 +20,46 @@
> >
</div> </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" /> <SiteContactFormFields :form="props.form" :disabled="disabled" />
<div class="border-t border-base-200 pt-4 space-y-4"> <div class="border-t border-base-200 pt-4 space-y-4">
@@ -28,7 +68,7 @@
<h4 class="font-semibold text-sm"> <h4 class="font-semibold text-sm">
Documents liés Documents liés
</h4> </h4>
<p class="text-xs text-gray-500"> <p class="text-xs text-base-content/50">
Ajoutez des documents (PDF, images...) relatifs à ce site. Ajoutez des documents (PDF, images...) relatifs à ce site.
</p> </p>
</div> </div>
@@ -74,7 +114,7 @@
<div class="font-medium"> <div class="font-medium">
{{ document.name }} {{ document.name }}
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-base-content/50">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }} {{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div> </div>
</div> </div>

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 { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
@@ -53,7 +54,7 @@ const historyFieldLabels: Record<string, string> = {
export function useComponentEdit(componentId: string) { export function useComponentEdit(componentId: string) {
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get, patch } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes() 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 () => { const submitEdition = async () => {
if (!component.value) { if (!component.value) {
return return
@@ -407,14 +501,12 @@ export function useComponentEdit(componentId: string) {
]) ])
loading.value = false loading.value = false
// Defer bulk catalog loads — only needed when component has structure selections // Load catalogs for slot selectors (force: true to bypass cache from list pages that load fewer items)
if (component.value?.structure) { Promise.allSettled([
Promise.allSettled([ loadPieces({ itemsPerPage: 200, force: true }),
loadPieces({ itemsPerPage: 200 }), loadProducts({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200 }), loadComposants({ itemsPerPage: 200, force: true }),
loadComposants({ itemsPerPage: 200 }), ]).catch(() => {})
]).catch(() => {})
}
}) })
return { return {
@@ -440,6 +532,9 @@ export function useComponentEdit(componentId: string) {
selectedType, selectedType,
selectedTypeStructure, selectedTypeStructure,
structureSelections, structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
// History // History
history, history,
@@ -453,6 +548,10 @@ export function useComponentEdit(componentId: string) {
handleFilesAdded, handleFilesAdded,
refreshDocuments, refreshDocuments,
submitEdition, submitEdition,
saveSlotQuantity,
savePieceSlotSelection,
saveProductSlotSelection,
saveSubcomponentSlotSelection,
resolvePieceLabel, resolvePieceLabel,
resolveProductLabel, resolveProductLabel,
resolveSubcomponentLabel, 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 // CRUD operations
const refreshDocuments = async () => { const refreshDocuments = async () => {
const e = entity() const e = entity()
if (!e?.id) return if (!e?.id || e._structurePiece) return
loadingDocuments.value = true loadingDocuments.value = true
try { try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false }) 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 { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useSites } from '~/composables/useSites'
import { useMachinePrint } from '~/composables/useMachinePrint' import { useMachinePrint } from '~/composables/useMachinePrint'
import { import {
resolveConstructeurs, resolveConstructeurs,
@@ -41,9 +42,10 @@ export function useMachineDetailData(machineId: string) {
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { upsertCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue } = useCustomFields()
const { get } = useApi() const { get, patch: apiPatch } = useApi()
const toast = useToast() const toast = useToast()
const { constructeurs, loadConstructeurs } = useConstructeurs() const { constructeurs, loadConstructeurs } = useConstructeurs()
const { sites, loadSites } = useSites()
const { const {
printModalOpen, printModalOpen,
@@ -64,6 +66,7 @@ export function useMachineDetailData(machineId: string) {
// Machine fields // Machine fields
const machineName = ref('') const machineName = ref('')
const machineReference = ref('') const machineReference = ref('')
const machineSiteId = ref('')
const machineConstructeurIds = ref<string[]>([]) const machineConstructeurIds = ref<string[]>([])
const machineConstructeurId = computed({ const machineConstructeurId = computed({
@@ -229,6 +232,7 @@ export function useMachineDetailData(machineId: string) {
machine.value.constructeurs, machine.value.constructeurs,
machine.value.constructeur, 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, machine,
machineName, machineName,
machineReference, machineReference,
machineSiteId,
machineConstructeurIds, machineConstructeurIds,
machineDocumentsLoaded, machineDocumentsLoaded,
machineComponentLinks, machineComponentLinks,
@@ -269,6 +274,7 @@ export function useMachineDetailData(machineId: string) {
updateMachineApi, updateMachineApi,
updateComposantApi: updateComposantApi, updateComposantApi: updateComposantApi,
updatePieceApi, updatePieceApi,
apiPatch,
toast, toast,
}) })
@@ -401,6 +407,7 @@ export function useMachineDetailData(machineId: string) {
loadConstructeurs(), loadConstructeurs(),
loadComponentTypes(), loadComponentTypes(),
loadPieceTypes(), loadPieceTypes(),
loadSites(),
]) ])
} }
@@ -419,8 +426,9 @@ export function useMachineDetailData(machineId: string) {
machineComponentLinks, machinePieceLinks, machineProductLinks, machineComponentLinks, machinePieceLinks, machineProductLinks,
// Machine fields // Machine fields
machineName, machineReference, machineConstructeurIds, machineConstructeurId, machineName, machineReference, machineSiteId, machineConstructeurIds, machineConstructeurId,
machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur, machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur,
sites,
// UI state // UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded, machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,

View File

@@ -13,6 +13,7 @@ export interface UseMachineDetailUpdatesDeps {
machine: Ref<AnyRecord | null> machine: Ref<AnyRecord | null>
machineName: Ref<string> machineName: Ref<string>
machineReference: Ref<string> machineReference: Ref<string>
machineSiteId: Ref<string>
machineConstructeurIds: Ref<string[]> machineConstructeurIds: Ref<string[]>
machineDocumentsLoaded: Ref<boolean> machineDocumentsLoaded: Ref<boolean>
machineComponentLinks: Ref<AnyRecord[]> machineComponentLinks: Ref<AnyRecord[]>
@@ -32,6 +33,7 @@ export interface UseMachineDetailUpdatesDeps {
updateMachineApi: (id: string, data: any) => Promise<unknown> updateMachineApi: (id: string, data: any) => Promise<unknown>
updateComposantApi: (id: string, data: any) => Promise<unknown> updateComposantApi: (id: string, data: any) => Promise<unknown>
updatePieceApi: (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 } toast: { showInfo: (msg: string) => void }
} }
@@ -40,6 +42,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
machine, machine,
machineName, machineName,
machineReference, machineReference,
machineSiteId,
machineConstructeurIds, machineConstructeurIds,
machineComponentLinks, machineComponentLinks,
machinePieceLinks, machinePieceLinks,
@@ -51,6 +54,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updateMachineApi, updateMachineApi,
updateComposantApi, updateComposantApi,
updatePieceApi, updatePieceApi,
apiPatch,
toast, toast,
} = deps } = deps
@@ -63,6 +67,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const result: any = await updateMachineApi(machine.value.id as string, { const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value, name: machineName.value,
reference: machineReference.value, reference: machineReference.value,
siteId: machineSiteId.value || undefined,
constructeurIds: cIds, constructeurIds: cIds,
} as any) } as any)
if (result.success) { if (result.success) {
@@ -105,18 +110,18 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const productId = updatedComponent.productId const productId = updatedComponent.productId
? String(updatedComponent.productId) ? String(updatedComponent.productId)
: null : null
const prix = const prixStr =
updatedComponent.prix !== null && updatedComponent.prix !== null &&
updatedComponent.prix !== undefined && updatedComponent.prix !== undefined &&
String(updatedComponent.prix).trim() !== '' String(updatedComponent.prix).trim() !== ''
? Number(updatedComponent.prix) ? String(updatedComponent.prix)
: null : null
const result: any = await updateComposantApi(updatedComponent.id as string, { const result: any = await updateComposantApi(updatedComponent.id as string, {
name: updatedComponent.name, name: updatedComponent.name,
reference: updatedComponent.reference, reference: updatedComponent.reference,
constructeurIds: cIds, constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix, prix: prixStr,
productId, productId,
} as any) } as any)
if (result.success) { if (result.success) {
@@ -135,18 +140,18 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updatedPiece.constructeur, updatedPiece.constructeur,
) )
const productId = updatedPiece.productId ? String(updatedPiece.productId) : null const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
const prix = const prixStr =
updatedPiece.prix !== null && updatedPiece.prix !== null &&
updatedPiece.prix !== undefined && updatedPiece.prix !== undefined &&
String(updatedPiece.prix).trim() !== '' String(updatedPiece.prix).trim() !== ''
? Number(updatedPiece.prix) ? String(updatedPiece.prix)
: null : null
const result: any = await updatePieceApi(updatedPiece.id as string, { const result: any = await updatePieceApi(updatedPiece.id as string, {
name: updatedPiece.name, name: updatedPiece.name,
reference: updatedPiece.reference, reference: updatedPiece.reference || null,
constructeurIds: cIds, constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix, prix: prixStr,
productId, productId,
} as any) } as any)
if (result.success) { 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) { } catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error) console.error('Erreur lors de la mise à jour de la pièce:', error)
} }
@@ -184,6 +196,13 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const updatePieceInfo = async (updatedPiece: AnyRecord) => { const updatePieceInfo = async (updatedPiece: AnyRecord) => {
try { try {
await _buildAndUpdatePiece(updatedPiece) 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) { } catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error) console.error('Erreur lors de la mise à jour de la pièce:', error)
} }

View File

@@ -181,6 +181,7 @@ export const buildMachineHierarchyFromLinks = (
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId), parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId), parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId), parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {}, definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [], 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 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[] ? (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) const subComponents = Array.isArray(link.childLinks)
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[] ? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
: [] : []

View File

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

View File

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

View File

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

View File

@@ -1,51 +1,11 @@
import { ref } from 'vue'
import type { EditableStructureNode } from '~/composables/useStructureNodeLogic' import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
export interface StructureNodeCrudDeps { export interface StructureNodeCrudDeps {
node: EditableStructureNode node: EditableStructureNode
restrictedMode: boolean
canManageSubcomponents: () => boolean canManageSubcomponents: () => boolean
} }
export function useStructureNodeCrud(props: StructureNodeCrudDeps) { 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 --- // --- Helpers ---
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => { const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) { if (!Array.isArray((props.node as any)[key])) {
@@ -115,6 +75,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
reference: '', reference: '',
familyCode: '', familyCode: '',
role: '', role: '',
quantity: 1,
}) })
} }
@@ -158,11 +119,6 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
} }
return { return {
// Lock checks
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
// Helpers exposed for watchers // Helpers exposed for watchers
reindexCustomFields, reindexCustomFields,
// CRUD // CRUD

View File

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

View File

@@ -76,6 +76,7 @@ export function useToast() {
const clearAll = (): void => { const clearAll = (): void => {
toasts.value = [] toasts.value = []
recentMessages.clear()
} }
return { return {

View File

@@ -69,6 +69,37 @@ const badgeClass = (type: ChangeType) => {
} }
const releases: Release[] = [ 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',
changes: [
{ type: 'feat', text: 'Gestion des champs personnalisés sur les machines : ajout, modification et suppression de définitions de champs directement depuis la fiche machine' },
{ type: 'feat', text: 'Refonte UI globale : amélioration du styling, des layouts et du responsive sur l\'ensemble des composants et pages' },
{ type: 'feat', text: 'Suite de tests API complète : 167 tests couvrant toutes les entités, la sécurité et les validations' },
{ type: 'feat', text: 'Endpoint /api/health pour le monitoring applicatif' },
{ type: 'fix', text: 'Sécurité renforcée : désactivation de la migration de session sur le firewall API, durcissement des accès documents et sessions' },
{ type: 'fix', text: 'Confirmation de suppression avec impact sur le catalogue produits (documents, liaisons machines en cascade)' },
{ type: 'fix', text: 'Correction du débordement des dropdowns dans les DataTable' },
{ type: 'perf', text: 'Refactoring massif du frontend : extraction de 15+ composables et composants partagés, réduction de la taille des fichiers' },
{ type: 'chore', text: 'Extraction de CuidEntityTrait et abstraction du subscriber d\'audit côté backend' },
{ type: 'chore', text: 'Ajout de DAMA DoctrineTestBundle pour l\'isolation des tests par transaction' },
],
},
{ {
version: 'v1.8.1', version: 'v1.8.1',
date: '2026-03-05', date: '2026-03-05',

View File

@@ -4,7 +4,7 @@
<h1 class="text-3xl font-semibold text-base-content"> <h1 class="text-3xl font-semibold text-base-content">
Commentaires Commentaires
</h1> </h1>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50">
Liste de tous les commentaires et tickets ouverts sur les fiches. Liste de tous les commentaires et tickets ouverts sur les fiches.
</p> </p>
</header> </header>

View File

@@ -1,9 +1,9 @@
<template> <template>
<main class="container mx-auto px-6 py-10 space-y-8"> <main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div> <div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des composants</h1> <h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50 mt-1">
Consultez et gérez tous les composants existants. Consultez et gérez tous les composants existants.
</p> </p>
</div> </div>
@@ -11,17 +11,17 @@
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md"> <NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un composant Ajouter un composant
</NuxtLink> </NuxtLink>
<NuxtLink to="/component-category" class="btn btn-outline btn-sm md:btn-md"> <NuxtLink to="/component-category" class="btn btn-ghost btn-sm md:btn-md">
Gérer les catégories Gérer les catégories
</NuxtLink> </NuxtLink>
</div> </div>
</header> </header>
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-1">
<h2 class="text-xl font-semibold text-base-content">Composants créés</h2> <h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/50">
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie. Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
</p> </p>
</header> </header>
@@ -95,7 +95,7 @@
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-2"> <div class="flex items-center justify-end gap-2">
<NuxtLink <NuxtLink
:to="`/component/${row.component.id}/edit`" :to="`/component/${row.component.id}/edit`"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@@ -105,7 +105,7 @@
<button <button
v-if="canEdit" v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants" :disabled="loadingComposants"
@click="handleDeleteComponent(row.component)" @click="handleDeleteComponent(row.component)"
> >

View File

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

View File

@@ -1,11 +1,12 @@
<template> <template>
<DocumentPreviewModal <div>
:document="previewDocument" <DocumentPreviewModal
:visible="previewVisible" :document="previewDocument"
:documents="componentDocuments" :visible="previewVisible"
@close="closePreview" :documents="componentDocuments"
/> @close="closePreview"
<main class="container mx-auto px-6 py-10"> />
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center"> <div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" /> <span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du composant</p> <p class="text-sm text-base-content/70">Chargement du composant</p>
@@ -151,45 +152,76 @@
/> />
<div <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" class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
> >
<header class="space-y-1"> <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"> <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> </p>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div v-if="pieceSlotEntries.length" class="space-y-2">
<div v-if="structureSelections.pieces.length" class="space-y-2"> <h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3> <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<ul class="list-disc list-inside space-y-1 text-sm"> <div
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`"> v-for="slot in pieceSlotEntries"
<span class="font-medium">{{ entry.resolvedName }}</span> :key="`piece-slot-${slot.slotId}`"
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span> class="form-control"
</li> >
</ul> <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>
<div v-if="structureSelections.products.length" class="space-y-2"> <div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3> <h3 class="font-semibold text-sm text-base-content">Produits</h3>
<ul class="list-disc list-inside space-y-1 text-sm"> <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`"> <div
<span class="font-medium">{{ entry.resolvedName }}</span> v-for="slot in productSlotEntries"
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span> :key="`product-slot-${slot.slotId}`"
</li> class="form-control"
</ul> >
<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>
<div v-if="structureSelections.components.length" class="space-y-2"> <div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3> <h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1 text-sm"> <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`"> <div
<span class="font-medium">{{ entry.resolvedName }}</span> v-for="slot in subcomponentSlotEntries"
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span> :key="`sub-slot-${slot.slotId}`"
</li> class="form-control"
</ul> >
<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> </div>
</div> </div>
@@ -269,7 +301,8 @@
</div> </div>
</div> </div>
</section> </section>
</main> </main>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -298,6 +331,9 @@ const {
selectedType, selectedType,
selectedTypeStructure, selectedTypeStructure,
structureSelections, structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history, history,
historyLoading, historyLoading,
historyError, historyError,
@@ -306,6 +342,10 @@ const {
removeDocument, removeDocument,
handleFilesAdded, handleFilesAdded,
submitEdition, submitEdition,
saveSlotQuantity,
savePieceSlotSelection,
saveProductSlotSelection,
saveSubcomponentSlotSelection,
resolvePieceLabel, resolvePieceLabel,
resolveProductLabel, resolveProductLabel,
resolveSubcomponentLabel, resolveSubcomponentLabel,

View File

@@ -15,7 +15,7 @@
</button> </button>
</div> </div>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DataTable <DataTable
:columns="columns" :columns="columns"
@@ -49,11 +49,11 @@
</template> </template>
<template #cell-actions="{ row }"> <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)"> <button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
{{ canEdit ? 'Modifier' : 'Consulter' }} {{ canEdit ? 'Modifier' : 'Consulter' }}
</button> </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 Supprimer
</button> </button>
</div> </div>

View File

@@ -7,7 +7,7 @@
@close="closePreview" @close="closePreview"
/> />
<section class="card bg-base-100 shadow-lg"> <section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<DataTable <DataTable
:columns="columns" :columns="columns"
@@ -68,7 +68,7 @@
</span> </span>
<div> <div>
<div class="font-semibold">{{ row.name }}</div> <div class="font-semibold">{{ row.name }}</div>
<div class="text-xs text-gray-500">{{ row.filename }}</div> <div class="text-xs text-base-content/50">{{ row.filename }}</div>
</div> </div>
</div> </div>
</template> </template>
@@ -88,7 +88,7 @@
<span v-else-if="row.composant">Composant &middot; {{ row.composant.name }}</span> <span v-else-if="row.composant">Composant &middot; {{ row.composant.name }}</span>
<span v-else-if="row.piece">Pi&egrave;ce &middot; {{ row.piece.name }}</span> <span v-else-if="row.piece">Pi&egrave;ce &middot; {{ row.piece.name }}</span>
<span v-else-if="row.product">Produit &middot; {{ row.product.name }}</span> <span v-else-if="row.product">Produit &middot; {{ row.product.name }}</span>
<span v-else class="text-gray-400">Non d&eacute;fini</span> <span v-else class="text-base-content/30">Non d&eacute;fini</span>
</div> </div>
</template> </template>

View File

@@ -1,57 +1,51 @@
<template> <template>
<main class="container mx-auto px-6 py-8"> <main class="container mx-auto px-6 py-8">
<!-- Hierarchical View -->
<div class="my-8"> <div class="my-8">
<!-- Header with Stats --> <!-- Header with Stats -->
<div class="flex justify-between items-center mb-6"> <div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between mb-8">
<div> <div>
<h2 class="text-2xl font-bold text-gray-800"> <h2 class="text-3xl font-bold text-base-content tracking-tight">
Vue d'ensemble Vue d'ensemble
</h2> </h2>
<p class="text-gray-600"> <p class="text-base-content/50 mt-1">
Machines organisées par site Machines organisées par site
</p> </p>
</div> </div>
<div class="stats shadow"> <div class="flex gap-3">
<div class="stat"> <div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<div class="stat-title"> <p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Sites</p>
Sites <p class="text-2xl font-bold text-primary tracking-tight">{{ sites.length }}</p>
</div>
<div class="stat-value text-primary">
{{ sites.length }}
</div>
</div> </div>
<div class="stat"> <div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<div class="stat-title"> <p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Machines</p>
Machines <p class="text-2xl font-bold text-secondary tracking-tight">{{ totalMachines }}</p>
</div>
<div class="stat-value text-secondary">
{{ totalMachines }}
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="card bg-base-100 shadow-lg mb-6"> <div class="card bg-base-100 shadow-sm mb-8">
<div class="card-body"> <div class="card-body py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="flex flex-col md:flex-row md:items-end gap-4">
<div class="form-control"> <div class="form-control flex-1">
<label class="label"> <label class="label">
<span class="label-text">Rechercher</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Rechercher</span>
</label> </label>
<input <div class="relative">
v-model="searchTerm" <IconLucideSearch class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/30" aria-hidden="true" />
type="text" <input
placeholder="Nom de machine ou site..." v-model="searchTerm"
class="input input-bordered" type="text"
> placeholder="Nom de machine ou site..."
class="input input-bordered pl-10 w-full"
>
</div>
</div> </div>
<div class="form-control"> <div class="form-control md:w-64">
<label class="label"> <label class="label">
<span class="label-text">Site</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
</label> </label>
<select v-model="selectedSiteFilter" class="select select-bordered"> <select v-model="selectedSiteFilter" class="select select-bordered w-full">
<option value=""> <option value="">
Tous les sites Tous les sites
</option> </option>
@@ -69,30 +63,32 @@
</div> </div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-12"> <div v-if="loading" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg" /> <span class="loading loading-spinner loading-lg text-primary" />
</div> </div>
<!-- Hierarchical Machines View --> <!-- Empty State -->
<div v-else-if="filteredSites.length === 0" class="text-center py-12"> <div v-else-if="filteredSites.length === 0" class="text-center py-16">
<div class="max-w-md mx-auto"> <div class="max-w-sm mx-auto">
<IconLucideFactory <div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
class="w-16 h-16 mx-auto text-gray-400 mb-4" <IconLucideFactory
aria-hidden="true" class="w-8 h-8 text-base-content/30"
/> aria-hidden="true"
<h3 class="text-lg font-medium text-gray-900 mb-2"> />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">
Aucune machine trouvée Aucune machine trouvée
</h3> </h3>
<p class="text-gray-500 mb-4"> <p class="text-sm text-base-content/50 mb-6">
Commencez par ajouter des sites et des machines. Commencez par ajouter des sites et des machines.
</p> </p>
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true"> <button v-if="canEdit" class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site Ajouter un site
</button> </button>
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-secondary" class="btn btn-ghost btn-sm"
@click="showAddMachineModal = true" @click="showAddMachineModal = true"
> >
Ajouter une machine Ajouter une machine
@@ -101,130 +97,119 @@
</div> </div>
</div> </div>
<div v-else class="space-y-6"> <!-- Sites List -->
<div v-else class="space-y-5">
<div <div
v-for="site in filteredSites" v-for="site in filteredSites"
:key="site.id" :key="site.id"
class="card bg-base-100 shadow-lg" 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,
}"
> >
<!-- Site Header -->
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between mb-4"> <!-- Site Header -->
<div class="flex items-center gap-3"> <div class="flex items-start justify-between gap-4">
<div class="avatar placeholder"> <div class="flex items-start gap-4">
<div <div
class="bg-primary text-primary-content rounded-lg w-12 grid place-items-center" 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-6 h-6" aria-hidden="true" /> >
</div> <IconLucideMapPin class="w-5 h-5" :style="{ color: site.color || 'oklch(var(--p))' }" aria-hidden="true" />
</div> </div>
<div> <div class="min-w-0">
<h3 class="text-xl font-bold"> <h3
class="text-lg font-bold tracking-tight text-base-content"
>
{{ site.name }} {{ site.name }}
</h3> </h3>
<div class="text-sm text-gray-600 space-y-1"> <div class="flex flex-wrap gap-x-4 gap-y-1 mt-1.5 text-sm text-base-content/50">
<div class="flex items-center gap-2"> <span v-if="site.contactName" class="flex items-center gap-1.5">
<IconLucideUser <IconLucideUser class="w-3.5 h-3.5" aria-hidden="true" />
class="w-4 h-4 text-primary" {{ site.contactName }}
aria-hidden="true" </span>
/> <span v-if="site.contactPhone" class="flex items-center gap-1.5">
<span class="font-medium">{{ site.contactName }}</span> <IconLucidePhone class="w-3.5 h-3.5" aria-hidden="true" />
</div> {{ formatPhoneDisplay(site.contactPhone) }}
<div class="flex items-center gap-2"> </span>
<IconLucidePhone <span v-if="site.contactCity" class="flex items-center gap-1.5">
class="w-4 h-4 text-secondary" <IconLucideMapPinned class="w-3.5 h-3.5" aria-hidden="true" />
aria-hidden="true" {{ site.contactPostalCode }} {{ site.contactCity }}
/> </span>
<span>{{ formatPhoneDisplay(site.contactPhone) }}</span>
</div>
<div class="flex items-start gap-2">
<IconLucideMapPinned
class="w-4 h-4 text-accent mt-1"
aria-hidden="true"
/>
<span>
{{ site.contactAddress }}<br>
{{ site.contactPostalCode }} {{ site.contactCity }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 shrink-0">
<div class="badge badge-primary badge-lg"> <span
{{ site.machines?.length || 0 }} machines class="badge font-bold"
</div> :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 <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-xs btn-circle"
@click="toggleSiteCollapse(site.id)" @click="toggleSiteCollapse(site.id)"
> >
<IconLucideChevronDown <IconLucideChevronDown
class="w-5 h-5 transition-transform" class="w-4 h-4 transition-transform duration-200"
:class=" :class="collapsedSites.includes(site.id) ? 'rotate-180' : ''"
collapsedSites.includes(site.id) ? 'rotate-180' : ''
"
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
</div> </div>
</div> </div>
<!-- Machines List --> <!-- Machines Grid -->
<div <div
v-if=" v-if="
!collapsedSites.includes(site.id) && !collapsedSites.includes(site.id) &&
site.machines && site.machines &&
site.machines.length > 0 site.machines.length > 0
" "
class="space-y-3" class="mt-4 pt-4 border-t border-base-200/80"
> >
<div class="divider" /> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div <div
v-for="machine in site.machines" v-for="machine in site.machines"
:key="machine.id" :key="machine.id"
class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer" 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)" @click="viewMachineDetails(machine)"
> >
<div class="card-body p-4"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center justify-between mb-2"> <h4 class="font-semibold text-sm text-base-content group-hover:text-primary transition-colors truncate">
<h4 class="font-semibold text-sm"> {{ machine.name }}
{{ machine.name }} </h4>
</h4> </div>
</div>
<div class="space-y-1 text-xs text-gray-600"> <div v-if="machine.reference" class="flex items-center gap-1.5 text-xs text-base-content/40">
<div <IconLucideTag class="w-3 h-3" aria-hidden="true" />
v-if="machine.reference" <span>{{ machine.reference }}</span>
class="flex items-center gap-1" </div>
>
<IconLucideTag class="w-3 h-3" aria-hidden="true" />
<span>{{ machine.reference }}</span>
</div>
</div>
<div class="card-actions justify-end mt-3"> <div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-xs btn-outline" class="btn btn-ghost btn-sm"
@click.stop="editMachine(machine)" @click.stop="editMachine(machine)"
> >
Modifier Modifier
</button> </button>
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-xs btn-error" class="btn btn-ghost btn-sm text-error"
@click.stop="confirmDeleteMachine(machine)" @click.stop="confirmDeleteMachine(machine)"
> >
Supprimer Supprimer
</button> </button>
<NuxtLink <NuxtLink
:to="`/machine/${machine.id}`" :to="`/machine/${machine.id}`"
class="btn btn-xs btn-primary" class="btn btn-primary btn-sm"
> @click.stop
Détails >
</NuxtLink> Détails
</div> </NuxtLink>
</div> </div>
</div> </div>
</div> </div>
@@ -236,17 +221,17 @@
!collapsedSites.includes(site.id) && !collapsedSites.includes(site.id) &&
(!site.machines || site.machines.length === 0) (!site.machines || site.machines.length === 0)
" "
class="text-center py-6" class="text-center py-8 mt-4 border-t border-base-200/80"
> >
<div class="text-gray-400 mb-2"> <div class="w-10 h-10 rounded-xl bg-base-200 grid place-items-center mx-auto mb-3">
<IconLucideFactory class="w-8 h-8 mx-auto" aria-hidden="true" /> <IconLucideFactory class="w-5 h-5 text-base-content/25" aria-hidden="true" />
</div> </div>
<p class="text-sm text-gray-500 mb-3"> <p class="text-sm text-base-content/40 mb-3">
Aucune machine dans ce site Aucune machine dans ce site
</p> </p>
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary btn-outline"
@click="addMachineToSite(site)" @click="addMachineToSite(site)"
> >
Ajouter une machine Ajouter une machine
@@ -290,6 +275,7 @@ import IconLucidePhone from '~icons/lucide/phone'
import IconLucideMapPinned from '~icons/lucide/map-pinned' import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down' import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideTag from '~icons/lucide/tag' import IconLucideTag from '~icons/lucide/tag'
import IconLucideSearch from '~icons/lucide/search'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
@@ -410,12 +396,10 @@ const toggleSiteCollapse = (siteId) => {
} }
const viewMachineDetails = (machine) => { const viewMachineDetails = (machine) => {
// Navigation vers la page de détails de la machine
navigateTo(`/machine/${machine.id}`) navigateTo(`/machine/${machine.id}`)
} }
const editMachine = (machine) => { const editMachine = (machine) => {
// Rediriger vers la page d'édition de la machine
navigateTo(`/machine/${machine.id}?edit=true`) navigateTo(`/machine/${machine.id}?edit=true`)
} }

View File

@@ -1,8 +1,9 @@
<template> <template>
<main class="container mx-auto px-6 py-8"> <div>
<main class="container mx-auto px-6 py-8">
<!-- Loading State --> <!-- Loading State -->
<div v-if="d.loading.value" class="flex justify-center items-center py-12"> <div v-if="d.loading.value" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
<!-- Machine Details --> <!-- Machine Details -->
@@ -38,7 +39,11 @@
rounded rounded
> >
<div class="flex justify-center gap-4"> <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 }} {{ d.machine.value.site?.name }}
</div> </div>
<div v-if="d.machine.value.reference" class="badge badge-outline"> <div v-if="d.machine.value.reference" class="badge badge-outline">
@@ -52,6 +57,9 @@
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value" :machine-name="d.machineName.value"
:machine-reference="d.machineReference.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-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value" :machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value" :has-machine-constructeur="d.hasMachineConstructeur.value"
@@ -61,6 +69,7 @@
:machine-custom-field-defs="d.machine.value?.customFields ?? []" :machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event" @update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event" @update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange" @update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo" @blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue" @set-custom-field-value="d.setMachineCustomFieldValue"
@@ -139,26 +148,29 @@
<!-- Error State --> <!-- Error State -->
<div v-else class="text-center py-12"> <div v-else class="text-center py-12">
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<IconLucideAlertTriangle class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" /> <div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<h3 class="text-lg font-medium text-gray-900 mb-2">Machine non trouvée</h3> <IconLucideAlertTriangle class="w-8 h-8 text-base-content/30" aria-hidden="true" />
<p class="text-gray-500 mb-4">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p> </div>
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
<NuxtLink to="/machines" class="btn btn-primary"> <NuxtLink to="/machines" class="btn btn-primary">
Retour aux machines Retour aux machines
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
</main> </main>
<MachinePrintSelectionModal <MachinePrintSelectionModal
:open="d.printModalOpen.value" :open="d.printModalOpen.value"
:selection="d.printSelection" :selection="d.printSelection"
:components="d.components.value" :components="d.components.value"
:pieces="d.machinePieces.value" :pieces="d.machinePieces.value"
@close="d.closePrintModal" @close="d.closePrintModal"
@confirm="d.handlePrintConfirm" @confirm="d.handlePrintConfirm"
@select-all="d.setAllPrintSelection(true)" @select-all="d.setAllPrintSelection(true)"
@deselect-all="d.setAllPrintSelection(false)" @deselect-all="d.setAllPrintSelection(false)"
/> />
</div>
</template> </template>
<script setup> <script setup>

View File

@@ -11,7 +11,7 @@
</NuxtLink> </NuxtLink>
</div> </div>
<div class="card bg-base-100 shadow-lg mb-6"> <div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body"> <div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
@@ -65,10 +65,14 @@
<div <div
v-for="machine in filteredMachines" v-for="machine in filteredMachines"
:key="machine.id" :key="machine.id"
class="card bg-base-100 shadow-lg 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)" @click="viewMachineDetails(machine)"
> >
<div class="card-body"> <div class="card-body flex flex-col">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="card-title text-lg"> <h3 class="card-title text-lg">
{{ machine.name }} {{ machine.name }}
@@ -77,8 +81,11 @@
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<IconLucideMapPin class="w-4 h-4 text-blue-500" aria-hidden="true" /> <IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
<span class="text-gray-600">{{ machine.site?.name || 'Site inconnu' }}</span> <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>
<div v-if="machine.reference" class="flex items-center gap-2"> <div v-if="machine.reference" class="flex items-center gap-2">
@@ -87,15 +94,15 @@
</div> </div>
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)"> <button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
Modifier Modifier
</button> </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 Supprimer
</button> </button>
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary"> <NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
Voir détails Détails
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>

View File

@@ -20,7 +20,7 @@
</div> </div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation"> <form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<!-- Basic fields --> <!-- Basic fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -27,10 +27,6 @@
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit" :readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -45,6 +41,14 @@
show-resolved show-resolved
/> />
</div> </div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main> </main>
</template> </template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports' import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' 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 type { PieceModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadPieceTypes } = usePieceTypes()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null) const initialData = ref<Partial<ModelTypePayload> | null>(null)
const showSyncModal = ref(false)
const { const syncLoading = ref(false)
isRestrictedMode, const syncPreviewData = ref<SyncPreviewResult | null>(null)
isSubmitBlocked, const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
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 title = computed(() => const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce', 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, structure: (response.structure as PieceModelStructure | null) ?? undefined,
} }
await loadLinkedCount(id)
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
await navigateBackToList() await navigateBackToList()
@@ -139,9 +128,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id) const id = String(route.params.id)
saving.value = true saving.value = true
try { try {
@@ -149,10 +135,29 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload, ...payload,
description: payload?.notes ?? null, description: payload?.notes ?? null,
} }
await updateModelType(id, enrichedPayload)
await loadPieceTypes({ force: true }) // Get sync preview BEFORE saving
showSuccess('Catégorie de pièce mise à jour avec succès.') const preview = await syncPreview(id, enrichedPayload.structure || {})
await navigateBackToList()
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) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } 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(() => { onMounted(() => {
loadCategory() loadCategory()
}) })

View File

@@ -72,7 +72,7 @@
<template #cell-description="{ row }"> <template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative"> <div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span> <span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible"> <div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p> <p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
</div> </div>
</div> </div>
@@ -118,7 +118,7 @@
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-2"> <div class="flex items-center justify-end gap-2">
<NuxtLink <NuxtLink
:to="`/pieces/${row.piece.id}/edit`" :to="`/pieces/${row.piece.id}/edit`"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@@ -128,7 +128,7 @@
<button <button
v-if="canEdit" v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-ghost btn-xs text-error"
:disabled="loadingPieces" :disabled="loadingPieces"
@click="handleDeletePiece(row.piece)" @click="handleDeletePiece(row.piece)"
> >

View File

@@ -1,11 +1,12 @@
<template> <template>
<DocumentPreviewModal <div>
:document="previewDocument" <DocumentPreviewModal
:visible="previewVisible" :document="previewDocument"
:documents="pieceDocuments" :visible="previewVisible"
@close="closePreview" :documents="pieceDocuments"
/> @close="closePreview"
<main class="container mx-auto px-6 py-10"> />
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center"> <div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" /> <span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement de la pièce</p> <p class="text-sm text-base-content/70">Chargement de la pièce</p>
@@ -265,7 +266,8 @@
</div> </div>
</div> </div>
</section> </section>
</main> </main>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
<template> <template>
<DocumentPreviewModal <div>
:document="previewDocument" <DocumentPreviewModal
:visible="previewVisible" :document="previewDocument"
:documents="productDocuments" :visible="previewVisible"
@close="closePreview" :documents="productDocuments"
/> @close="closePreview"
<main class="container mx-auto px-6 py-10"> />
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center"> <div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" /> <span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du produit</p> <p class="text-sm text-base-content/70">Chargement du produit</p>
@@ -204,7 +205,8 @@
</div> </div>
</div> </div>
</section> </section>
</main> </main>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -388,7 +390,7 @@ const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first // Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) { if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) { if (embeddedStructure) {
productType.value = embedded productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure) structure.value = normalizeProductStructureForSave(embeddedStructure)
@@ -404,7 +406,7 @@ const loadProductType = async () => {
try { try {
const type = await getModelType(product.value.typeProductId) const type = await getModelType(product.value.typeProductId)
productType.value = type productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null) structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement du type de produit:', error) console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null productType.value = embedded ?? null

View File

@@ -24,11 +24,11 @@
<div v-else-if="sites.length === 0" class="text-center py-12"> <div v-else-if="sites.length === 0" class="text-center py-12">
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<IconLucideMapPin class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" /> <IconLucideMapPin class="w-16 h-16 mx-auto text-base-content/30 mb-4" aria-hidden="true" />
<h3 class="text-lg font-medium text-gray-900 mb-2"> <h3 class="text-lg font-medium text-base-content mb-2">
Aucun site trouvé Aucun site trouvé
</h3> </h3>
<p class="text-gray-500 mb-4"> <p class="text-base-content/50 mb-4">
Commencez par ajouter votre premier site. Commencez par ajouter votre premier site.
</p> </p>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal"> <button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">

View File

@@ -46,9 +46,6 @@ export interface ModelType extends BaseModelTypePayload {
updatedAt: string; updatedAt: string;
category: ModelCategory; category: ModelCategory;
structure: ModelTypeStructure; structure: ModelTypeStructure;
componentSkeleton?: ComponentModelStructure | null;
pieceSkeleton?: PieceModelStructure | null;
productSkeleton?: ProductModelStructure | null;
} }
export interface ModelTypeListParams { export interface ModelTypeListParams {
@@ -86,42 +83,9 @@ const normalizeModelType = (item: any): ModelType => {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return item as ModelType; 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; 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 } = {}) { export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const query: Record<string, string | number> = {}; 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 } = {}) { export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(ENDPOINT, createOptions({ return requestFetch<ModelType>(ENDPOINT, createOptions({
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/ld+json', 'Content-Type': 'application/ld+json',
Accept: 'application/ld+json', Accept: 'application/ld+json',
}, },
body: mappedPayload, body: payload,
signal: opts.signal, signal: opts.signal,
})).then(normalizeModelType); })).then(normalizeModelType);
} }
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) { export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch(); const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({ return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/merge-patch+json', 'Content-Type': 'application/merge-patch+json',
Accept: 'application/ld+json', Accept: 'application/ld+json',
}, },
body: mappedPayload, body: payload,
signal: opts.signal, signal: opts.signal,
})).then(normalizeModelType); })).then(normalizeModelType);
} }
@@ -249,3 +211,45 @@ export function convertCategory(id: string, opts: { signal?: AbortSignal } = {})
signal: opts.signal, 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) { if (piece.reference) {
payload.reference = 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 return payload
}) as any }) as any

View File

@@ -103,6 +103,7 @@ export const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
reference: piece?.reference ?? '', reference: piece?.reference ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '', familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '', 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 ?? '', typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '', familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '', 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 rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined const role = rawRole.length > 0 ? rawRole : undefined
const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) { if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
return null return null
} }
@@ -182,6 +184,9 @@ export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (typePieceLabel) { if (typePieceLabel) {
result.typePieceLabel = typePieceLabel result.typePieceLabel = typePieceLabel
} }
if (quantity !== undefined) {
result.quantity = quantity
}
return result return result
}) })
.filter((piece): piece is ComponentModelPiece => !!piece) .filter((piece): piece is ComponentModelPiece => !!piece)

View File

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

View File

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

View File

@@ -225,7 +225,10 @@ export const buildCustomFieldInputs = (
if (fieldName) mapByName.set(fieldName, entry) if (fieldName) mapByName.set(fieldName, entry)
}) })
return definitions const matchedIds = new Set<string>()
const matchedNames = new Set<string>()
const result = definitions
.map((definition) => { .map((definition) => {
const definitionId = definition.customFieldId || definition.id || null const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name) 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) const resolvedValue = extractStoredCustomFieldValue(matched)
return { return {
...definition, ...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 = [ const structuralCandidates = [
source.products, source.products,
source.productSkeleton,
(source.definition as AnyRecord)?.products, (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)?.products,
((source.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.structure as AnyRecord)?.products, (source.structure as AnyRecord)?.products,
(source.structure as AnyRecord)?.productSkeleton,
(source.requirement as AnyRecord)?.products, (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)?.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)?.products,
(source.typeComposant as AnyRecord)?.productSkeleton,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products, ((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)?.products,
(source.originalComposant as AnyRecord)?.productSkeleton,
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.products, ((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)?.products,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComponent as AnyRecord)?.products, (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)?.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)?.products,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
] ]
const structuralProducts = structuralCandidates const structuralProducts = structuralCandidates

View File

@@ -176,6 +176,7 @@ export function sanitizePieceDefinition(definition: ComponentModelPiece) {
typePieceLabel: definition.typePieceLabel ?? null, typePieceLabel: definition.typePieceLabel ?? null,
reference: definition.reference ?? null, reference: definition.reference ?? null,
familyCode: (definition as any).familyCode ?? 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 path: string
requirementLabel: string requirementLabel: string
resolvedName: string resolvedName: string
quantity?: number
slotId?: string
_definition?: Record<string, any>
} }
export type StructureSelectionResult = { export type StructureSelectionResult = {
@@ -59,6 +62,9 @@ export function collectStructureSelections(
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`, path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
requirementLabel: resolvers.resolvePieceLabel(definition), requirementLabel: resolvers.resolvePieceLabel(definition),
resolvedName: catalogPiece?.name || selectedId, 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 // 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 // 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

@@ -153,7 +153,7 @@ describe('loadTypes', () => {
expect(result.error).toBe('Network error') expect(result.error).toBe('Network error')
expect(types.value).toEqual([]) expect(types.value).toEqual([])
expect(mockShowError).toHaveBeenCalledWith( expect(mockShowError).toHaveBeenCalledWith(
expect.stringContaining('Network error'), 'Impossible de charger les types de composant.',
) )
}) })

View File

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