Compare commits
50 Commits
v1.7.0
...
4a3bceffa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a3bceffa1 | ||
|
|
50d8dde6d5 | ||
|
|
9b40f9f2c7 | ||
|
|
721963449b | ||
|
|
22ba9a8d05 | ||
|
|
695d56a6d3 | ||
|
|
5c31045e83 | ||
|
|
b0124c11ba | ||
|
|
7e67b124f3 | ||
| 3ad326348b | |||
| 5b9c4ca09d | |||
| 6b5eb7bcd6 | |||
| 98f5d983b3 | |||
| cda872a057 | |||
| 84970a352d | |||
| c1d14124ff | |||
| a83a4428c2 | |||
| a1998d7966 | |||
| 6add558725 | |||
| e18ce984e7 | |||
| d00e5c058b | |||
| 3b24dc128a | |||
| c188bd7e8b | |||
| e911f169ce | |||
| 9f9ad80c61 | |||
| c831f65ef3 | |||
| 81eb181000 | |||
| a3fde7a191 | |||
| b696b5aa1f | |||
| c6db96dc76 | |||
| 165e0a6341 | |||
| de7be1b9d0 | |||
| 7b3eb1c5fc | |||
|
|
592beb0fa7 | ||
|
|
e732585e63 | ||
|
|
f1cc21c31b | ||
|
|
6c2f84dd3a | ||
|
|
032b3b33c9 | ||
|
|
32d03b480d | ||
|
|
6f1bac381d | ||
|
|
89dc2e93b8 | ||
|
|
8f5f25b3e7 | ||
|
|
c06c852493 | ||
|
|
41f5319b67 | ||
|
|
c7fd8328d6 | ||
|
|
55e2a4fafe | ||
|
|
e88ed5b8f2 | ||
|
|
546cc37a09 | ||
|
|
efd0fbe407 | ||
|
|
607f84fc3d |
178
README.md
178
README.md
@@ -1,75 +1,155 @@
|
||||
# Nuxt Minimal Starter
|
||||
# Inventory Frontend
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
Interface web de gestion d'inventaire industriel pour **Malio**. Application SPA complète permettant la gestion du parc machines, des pièces, composants, produits, fournisseurs et documents associés.
|
||||
|
||||
## Setup
|
||||
## Stack technique
|
||||
|
||||
Make sure to install dependencies:
|
||||
| Technologie | Version | Rôle |
|
||||
|-------------|---------|------|
|
||||
| [Nuxt](https://nuxt.com) | 4 | Framework (SPA, SSR désactivé) |
|
||||
| [Vue 3](https://vuejs.org) | 3.5 | Composition API + `<script setup>` |
|
||||
| [TypeScript](https://www.typescriptlang.org) | 5.7 | Typage strict sur l'ensemble du projet |
|
||||
| [TailwindCSS](https://tailwindcss.com) | 4 | Utility-first CSS |
|
||||
| [DaisyUI](https://daisyui.com) | 5 | Composants UI (alertes, modales, badges, etc.) |
|
||||
| [Lucide](https://lucide.dev) | via unplugin-icons | Icônes SVG |
|
||||
| [Vitest](https://vitest.dev) | 4 | Tests unitaires |
|
||||
| [Playwright](https://playwright.dev) | 1.58 | Tests E2E |
|
||||
|
||||
## Prérequis
|
||||
|
||||
- **Node.js** >= 20
|
||||
- **npm**
|
||||
- **Backend Symfony** démarré avec l'API sur `http://localhost:8081/api`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
L'application est accessible sur **http://localhost:3001**.
|
||||
|
||||
Build the application for production:
|
||||
## Commandes disponibles
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `npm run dev` | Serveur de développement avec HMR |
|
||||
| `npm run build` | Build de production |
|
||||
| `npm run lint:fix` | Correction automatique ESLint |
|
||||
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
|
||||
| `npm run test` | Tests unitaires Vitest |
|
||||
| `npm run test:watch` | Tests unitaires en mode watch |
|
||||
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
## Fonctionnalités
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
### Gestion du parc
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
|
||||
- **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
|
||||
- **Sites** : gestion multi-sites avec coordonnées de contact
|
||||
|
||||
### Catalogues
|
||||
|
||||
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
|
||||
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
|
||||
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
|
||||
|
||||
### Documents et traçabilité
|
||||
|
||||
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
|
||||
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
|
||||
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
|
||||
|
||||
### Administration
|
||||
|
||||
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
|
||||
- **Profils** : gestion des utilisateurs et attribution des rôles
|
||||
- **Notifications** : badge compteur de commentaires ouverts avec polling
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
├── pages/ # 36 pages (file-based routing)
|
||||
├── components/ # 57 composants Vue (auto-imported par Nuxt)
|
||||
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
|
||||
│ ├── form/ # Champs de formulaire (email, téléphone)
|
||||
│ ├── layout/ # Navbar principale
|
||||
│ ├── machine/ # Vue détail et création de machines
|
||||
│ │ └── create/ # Wizard de création machine
|
||||
│ ├── model-types/ # Gestion des types/catégories
|
||||
│ └── sites/ # Modales site (création, édition)
|
||||
├── composables/ # 45 composables (logique métier)
|
||||
├── shared/ # Types, utilitaires, validation
|
||||
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
|
||||
│ ├── validation/ # Validation email, téléphone
|
||||
│ └── model/ # Définitions de structures
|
||||
├── services/ # Service layer (wrappers API spécialisés)
|
||||
├── middleware/ # Middleware d'auth global (session cookie)
|
||||
└── utils/ # Formatage dates, montants, événements
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
## Conventions de code
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
### Composables
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
Pattern avec injection de dépendances explicite :
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
```typescript
|
||||
interface Deps {
|
||||
machineId: Ref<string>
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
export function useMachineDetail(deps: Deps) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
### Communication entre composants
|
||||
|
||||
**Props + Events uniquement** — pas de `provide/inject` dans le projet.
|
||||
|
||||
### Appels API
|
||||
|
||||
Le composable `useApi.ts` centralise tous les appels HTTP :
|
||||
- Cookies de session inclus automatiquement (`credentials: 'include'`)
|
||||
- `application/ld+json` pour POST/PUT
|
||||
- `application/merge-patch+json` pour PATCH
|
||||
- Gestion d'erreurs centralisée avec traduction des messages backend en français
|
||||
|
||||
### Styles
|
||||
|
||||
Classes DaisyUI standard :
|
||||
- Input : `input input-bordered input-sm md:input-md`
|
||||
- Select : `select select-bordered select-sm md:select-md`
|
||||
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||
|
||||
## Authentification
|
||||
|
||||
L'application utilise une **authentification par session (cookies)**, pas de JWT.
|
||||
|
||||
Le middleware global `profile.global.ts` vérifie la session à chaque navigation :
|
||||
- Utilisateur non connecté → redirection vers `/profiles`
|
||||
- Route `/admin/*` → accès restreint à `ROLE_ADMIN`
|
||||
|
||||
## Tests
|
||||
|
||||
- **13 tests unitaires** (Vitest + happy-dom) couvrant composables, utils et composants
|
||||
- **3 specs E2E** (Playwright + Chrome) avec setup d'authentification
|
||||
|
||||
## Submodule Git
|
||||
|
||||
Ce repo est un **submodule** du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory).
|
||||
|
||||
Workflow de commit :
|
||||
1. Commiter dans ce repo (frontend) en premier
|
||||
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Pousser les deux repos
|
||||
|
||||
27
app/app.vue
27
app/app.vue
@@ -1,15 +1,20 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<LayoutAppNavbar
|
||||
<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
|
||||
@open-settings="displaySettingsOpen = true"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<NuxtPage />
|
||||
<main class="flex-1">
|
||||
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
|
||||
</main>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<CommonConfirmModal />
|
||||
<ConfirmModal />
|
||||
|
||||
<DisplaySettings
|
||||
:is-open="displaySettingsOpen"
|
||||
@@ -17,11 +22,17 @@
|
||||
@update-settings="handleSettingsUpdate"
|
||||
/>
|
||||
|
||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||
<div class="items-center grid-flow-col">
|
||||
<p>
|
||||
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
|
||||
<footer class="border-t border-base-300/50 bg-base-100/60 backdrop-blur-sm">
|
||||
<div class="container mx-auto flex items-center justify-between px-6 py-3">
|
||||
<p class="text-xs text-base-content/40 font-medium tracking-wide">
|
||||
© Malio {{ new Date().getFullYear() }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/changelog"
|
||||
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
v{{ appVersion }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
@plugin "daisyui";
|
||||
|
||||
/* ─── Theme ─── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "mytheme";
|
||||
default: true; /* set as default */
|
||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
||||
color-scheme: light; /* color of browser-provided UI */
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: light;
|
||||
|
||||
/* #FBFAFA — gris clair */
|
||||
--color-base-100: oklch(98% 0.003 0);
|
||||
--color-base-200: oklch(94% 0.01 262);
|
||||
--color-base-300: oklch(90% 0.02 262);
|
||||
--color-base-content: oklch(20% 0.03 262);
|
||||
/* #304998 — bleu Malio */
|
||||
--color-primary: oklch(37% 0.15 262);
|
||||
/* Surfaces — warm gray with a hint of blue */
|
||||
--color-base-100: oklch(98.5% 0.004 260);
|
||||
--color-base-200: oklch(95% 0.008 260);
|
||||
--color-base-300: oklch(91% 0.015 260);
|
||||
--color-base-content: oklch(22% 0.025 260);
|
||||
|
||||
/* Primary — Malio blue, slightly richer */
|
||||
--color-primary: oklch(40% 0.16 262);
|
||||
--color-primary-content: oklch(98% 0.005 262);
|
||||
/* #A5ACD0 — lavande */
|
||||
--color-secondary: oklch(75% 0.055 270);
|
||||
--color-secondary-content: oklch(20% 0.03 270);
|
||||
/* #ED8521 — orange */
|
||||
--color-accent: oklch(71% 0.17 58);
|
||||
--color-accent-content: oklch(98% 0.005 58);
|
||||
/* neutral dérivé du bleu Malio */
|
||||
--color-neutral: oklch(37% 0.08 262);
|
||||
--color-neutral-content: oklch(98% 0.005 262);
|
||||
--color-info: oklch(55% 0.12 262);
|
||||
--color-info-content: oklch(98% 0.005 262);
|
||||
--color-success: oklch(65% 0.2 145);
|
||||
--color-success-content: oklch(98% 0.005 145);
|
||||
|
||||
/* 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 — deep slate */
|
||||
--color-neutral: oklch(28% 0.04 260);
|
||||
--color-neutral-content: oklch(95% 0.005 260);
|
||||
|
||||
/* 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-content: oklch(20% 0.05 70);
|
||||
--color-error: oklch(60% 0.25 25);
|
||||
--color-warning-content: oklch(22% 0.05 70);
|
||||
--color-error: oklch(58% 0.22 25);
|
||||
--color-error-content: oklch(98% 0.005 25);
|
||||
|
||||
/* border radius */
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
/* Geometry */
|
||||
--radius-selector: 0.75rem;
|
||||
--radius-field: 0.375rem;
|
||||
--radius-box: 0.625rem;
|
||||
|
||||
/* base sizes */
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
|
||||
/* border size */
|
||||
--border: 1px;
|
||||
|
||||
/* effects */
|
||||
--depth: 1;
|
||||
--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 {
|
||||
--spacing-xs: 0.5rem;
|
||||
--spacing-sm: 0.75rem;
|
||||
@@ -58,7 +139,6 @@
|
||||
--spacing-xl: 2rem;
|
||||
}
|
||||
|
||||
/* Densité compacte */
|
||||
.density-compact {
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
@@ -67,7 +147,6 @@
|
||||
--spacing-xl: 1.25rem;
|
||||
}
|
||||
|
||||
/* Densité confortable (défaut) */
|
||||
.density-comfortable {
|
||||
--spacing-xs: 0.5rem;
|
||||
--spacing-sm: 0.75rem;
|
||||
@@ -76,7 +155,6 @@
|
||||
--spacing-xl: 2rem;
|
||||
}
|
||||
|
||||
/* Densité espacée */
|
||||
.density-spacious {
|
||||
--spacing-xs: 0.75rem;
|
||||
--spacing-sm: 1rem;
|
||||
@@ -85,251 +163,200 @@
|
||||
--spacing-xl: 3rem;
|
||||
}
|
||||
|
||||
/* Contraste élevé avec DaisyUI */
|
||||
.contrast-high .btn {
|
||||
@apply border-2;
|
||||
}
|
||||
/* ─── High contrast mode ─── */
|
||||
.contrast-high .btn { @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 {
|
||||
@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é */
|
||||
/* ─── Accessibility ─── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible pour l'accessibilité */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #304998;
|
||||
outline: 2px solid oklch(40% 0.16 262);
|
||||
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 {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-circle:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-circle:active {
|
||||
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 {
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
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 */
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.modal.modal-open {
|
||||
animation: modalFadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* S'assurer que le contenu du modal garde une taille normale */
|
||||
.modal-box {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
scale: 1 !important;
|
||||
width: auto !important;
|
||||
max-width: 500px !important;
|
||||
font-family: var(--font-body);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid oklch(91% 0.015 260 / 0.5);
|
||||
}
|
||||
|
||||
.modal .form-control {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
@keyframes modalSlideUp {
|
||||
from { opacity: 0; transform: translateY(0.5rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.modal .btn {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
height: auto !important;
|
||||
min-height: 2.5rem !important;
|
||||
.modal.modal-open .modal-box {
|
||||
animation: modalSlideUp 0.25s ease-out;
|
||||
}
|
||||
|
||||
.modal .input {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
height: auto !important;
|
||||
min-height: 2.5rem !important;
|
||||
/* ─── Page transitions ─── */
|
||||
.page-enter-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.page-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal .select {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
height: auto !important;
|
||||
min-height: 2.5rem !important;
|
||||
/* ─── Scrollbar styling ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-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 {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
min-height: 4rem !important;
|
||||
}
|
||||
/* ─── Readability ─── */
|
||||
.text-sm { line-height: 1.5; }
|
||||
.text-xs { line-height: 1.4; }
|
||||
|
||||
.modal .range {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
height: auto !important;
|
||||
min-height: 1.5rem !important;
|
||||
}
|
||||
/* ─── Adaptive spacing ─── */
|
||||
.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); }
|
||||
|
||||
.modal .label {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.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); }
|
||||
|
||||
.modal .label-text {
|
||||
font-size: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
.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 {
|
||||
.form-control .label {
|
||||
@@ -337,7 +364,6 @@
|
||||
padding-bottom: 0;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.form-control .label + * {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
<ComponentItem
|
||||
:component="component"
|
||||
:is-edit-mode="isEditMode"
|
||||
:show-delete="showDelete"
|
||||
:collapse-all="collapseAll"
|
||||
:toggle-token="toggleToken"
|
||||
@update="$emit('update', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,6 +29,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
collapseAll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
@@ -37,5 +43,5 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update'])
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
</script>
|
||||
|
||||
@@ -1,315 +1,200 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
:aria-expanded="!isCollapsed"
|
||||
:title="isCollapsed ? 'Déplier les détails du composant' : 'Replier les détails du composant'"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} le composant</span>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-sm font-semibold text-base-content truncate">
|
||||
{{ component.name }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span
|
||||
v-if="component.skeletonOnly"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
Défini dans le catalogue
|
||||
</span>
|
||||
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
||||
<template v-if="componentConstructeursDisplay.length">
|
||||
<span
|
||||
v-for="constructeur in componentConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}€</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="component.typeMachineComponentRequirement"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
||||
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</span>
|
||||
</div>
|
||||
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
|
||||
<span
|
||||
v-for="constructeur in componentConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
</span>
|
||||
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
||||
{{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="showDelete"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||
title="Supprimer ce composant"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed" class="space-y-4">
|
||||
<!-- Component Info Display - Editable or Read-only -->
|
||||
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-medium">Nom</span></label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="component.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
@blur="updateComponent"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm bg-base-200">
|
||||
{{ component.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-medium">Référence</span></label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="component.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
@blur="updateComponent"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm bg-base-200">
|
||||
{{ component.reference || 'Non définie' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-medium">Prix</span></label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="component.prix"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm"
|
||||
@blur="updateComponent"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm bg-base-200">
|
||||
{{ component.prix ? `${component.prix}€` : 'Non défini' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-medium">Fournisseur</span></label>
|
||||
<ConstructeurSelect
|
||||
v-if="isEditMode"
|
||||
class="w-full"
|
||||
:model-value="componentConstructeurIds"
|
||||
:initial-options="componentConstructeursDisplay"
|
||||
@update:model-value="handleConstructeurChange"
|
||||
/>
|
||||
<div v-else class="input input-bordered input-sm bg-base-200">
|
||||
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
|
||||
<div
|
||||
v-for="constructeur in componentConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<span class="font-medium">{{ constructeur.name }}</span>
|
||||
<span
|
||||
v-if="formatConstructeurContact(constructeur)"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
{{ formatConstructeurContact(constructeur) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="font-medium">Non défini</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Produit catalogue</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200 min-h-[2.75rem] flex flex-col justify-center space-y-1">
|
||||
<template v-if="displayProduct">
|
||||
<span class="font-semibold text-base-content">
|
||||
{{ displayProductName || 'Produit catalogue' }}
|
||||
</span>
|
||||
<span
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="text-xs text-base-content/70"
|
||||
>
|
||||
{{ info.label }} : {{ info.value }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="component.product?.id"
|
||||
:to="`/product/${component.product.id}/edit`"
|
||||
class="link link-primary text-xs"
|
||||
>
|
||||
Ouvrir la fiche produit
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<span v-else class="font-medium">Non défini</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="productDocuments.length"
|
||||
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
||||
>
|
||||
<h4 class="font-medium text-base-content">
|
||||
Documents du produit
|
||||
</h4>
|
||||
<div
|
||||
v-for="document in productDocuments"
|
||||
:key="document.id || document.path || document.name"
|
||||
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="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>
|
||||
<!-- Expanded content -->
|
||||
<div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
|
||||
<!-- Info fields -->
|
||||
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
|
||||
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
|
||||
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
|
||||
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
|
||||
<ConstructeurSelect
|
||||
class="w-full"
|
||||
:model-value="componentConstructeurIds"
|
||||
:initial-options="componentConstructeursDisplay"
|
||||
@update:model-value="handleConstructeurChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields Display - Editable or Read-only -->
|
||||
<div v-if="displayedCustomFields.length" class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="font-semibold text-sm text-gray-700 mb-3">
|
||||
Champs personnalisés
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="(field, index) in displayedCustomFields"
|
||||
:key="resolveFieldKey(field, index)"
|
||||
class="form-control"
|
||||
<!-- 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"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
|
||||
{{ formatConstructeurContact(constructeur) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-base-content">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product -->
|
||||
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-base-content/40">Produit catalogue</p>
|
||||
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
|
||||
<p
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="text-xs text-base-content/60"
|
||||
>
|
||||
{{ info.label }} : {{ info.value }}
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="component.product?.id"
|
||||
:to="`/product/${component.product.id}/edit`"
|
||||
class="btn btn-ghost btn-xs shrink-0"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ resolveFieldName(field) }}</span>
|
||||
<span v-if="resolveFieldRequired(field)" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@blur="updateComponentCustomField(field)"
|
||||
>
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@blur="updateComponentCustomField(field)"
|
||||
>
|
||||
<select
|
||||
v-else-if="resolveFieldType(field) === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@change="updateComponentCustomField(field)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner...
|
||||
</option>
|
||||
<option v-for="option in resolveFieldOptions(field)" :key="option" :value="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="resolveFieldType(field) === 'boolean'" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
@change="updateComponentCustomField(field)"
|
||||
Voir le produit
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<!-- Product documents -->
|
||||
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
|
||||
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
|
||||
<div
|
||||
v-for="document in productDocuments"
|
||||
:key="document.id || document.path || document.name"
|
||||
class="flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
||||
<img
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-4 w-4"
|
||||
:class="documentIcon(document).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@blur="updateComponentCustomField(field)"
|
||||
<span class="truncate text-base-content">{{ document.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
@click="openPreview(document)"
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatFieldDisplayValue(field) }}
|
||||
</div>
|
||||
</template>
|
||||
Consulter
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldDisplay
|
||||
:fields="displayedCustomFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
@field-blur="updateComponentCustomField"
|
||||
/>
|
||||
|
||||
<!-- Documents -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-semibold text-sm text-gray-700">
|
||||
Documents
|
||||
</h4>
|
||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
||||
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
|
||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="loadingDocuments" class="text-xs text-gray-500">
|
||||
Chargement des documents...
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
||||
Chargement...
|
||||
</p>
|
||||
|
||||
<DocumentUpload
|
||||
@@ -320,88 +205,27 @@
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
|
||||
<div v-if="componentDocuments.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in componentDocuments"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="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">
|
||||
{{ document.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="uploadingDocuments"
|
||||
@click="removeDocument(document.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
|
||||
Aucun document lié à ce composant.
|
||||
</p>
|
||||
<DocumentListInline
|
||||
:documents="componentDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à ce composant."
|
||||
@preview="openPreview"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Component Pieces -->
|
||||
<div v-if="component.pieces && component.pieces.length > 0" class="space-y-2">
|
||||
<h4 class="font-semibold text-gray-700">
|
||||
<!-- Component Pieces (real MachinePieceLinks) -->
|
||||
<div v-if="linkedPieces.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Pièces du composant
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<PieceItem
|
||||
v-for="piece in component.pieces"
|
||||
v-for="piece in linkedPieces"
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode && !piece.skeletonOnly"
|
||||
|
||||
:is-edit-mode="isEditMode"
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@@ -409,17 +233,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Structure pieces (read-only, from composant definition) -->
|
||||
<div v-if="structurePieces.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Pièces incluses par défaut
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<PieceItem
|
||||
v-for="piece in structurePieces"
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub Components -->
|
||||
<div v-if="childComponents.length > 0" class="space-y-3">
|
||||
<h4 class="font-semibold text-gray-700">
|
||||
<div v-if="childComponents.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Sous-composants
|
||||
</h4>
|
||||
<div class="space-y-3 pl-4 border-l-2 border-gray-200">
|
||||
</p>
|
||||
<div class="space-y-2 pl-4 border-l-2 border-base-200">
|
||||
<ComponentItem
|
||||
v-for="subComponent in childComponents"
|
||||
:key="subComponent.id"
|
||||
:component="subComponent"
|
||||
:is-edit-mode="isEditMode && !subComponent.skeletonOnly"
|
||||
:is-edit-mode="isEditMode"
|
||||
:collapse-all="collapseAll"
|
||||
:toggle-token="toggleToken"
|
||||
@update="$emit('update', $event)"
|
||||
@@ -450,19 +289,9 @@ import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
@@ -470,11 +299,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
const props = defineProps({
|
||||
component: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
showDelete: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update'])
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
@@ -526,6 +356,14 @@ const childComponents = computed(() => {
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
// --- Pieces split: real links vs structure definitions ---
|
||||
const allPieces = computed(() => {
|
||||
const list = props.component.pieces
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
|
||||
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
|
||||
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
|
||||
@@ -134,76 +134,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||
import { useApi } from '~/composables/useApi';
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers';
|
||||
import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
ComponentModelStructureNode,
|
||||
} from '~/shared/types/inventory';
|
||||
ComponentOption,
|
||||
PieceOption,
|
||||
ProductOption,
|
||||
} from '~/composables/useStructureAssignmentFetch';
|
||||
|
||||
interface ComponentOption {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
reference?: string | null;
|
||||
typeComposantId?: string | null;
|
||||
typeComposant?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
code?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PieceOption {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
reference?: string | null;
|
||||
typePieceId?: string | null;
|
||||
typePiece?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
code?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
reference?: string | null;
|
||||
typeProductId?: string | null;
|
||||
typeProduct?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
code?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface StructurePieceAssignment {
|
||||
path: string;
|
||||
definition: ComponentModelPiece;
|
||||
selectedPieceId: string;
|
||||
}
|
||||
|
||||
export interface StructureProductAssignment {
|
||||
path: string;
|
||||
definition: ComponentModelProduct;
|
||||
selectedProductId: string;
|
||||
}
|
||||
|
||||
export interface StructureAssignmentNode {
|
||||
path: string;
|
||||
definition: ComponentModelStructureNode;
|
||||
selectedComponentId: string;
|
||||
pieces: StructurePieceAssignment[];
|
||||
products: StructureProductAssignment[];
|
||||
subcomponents: StructureAssignmentNode[];
|
||||
}
|
||||
export type {
|
||||
StructureAssignmentNode,
|
||||
StructurePieceAssignment,
|
||||
StructureProductAssignment,
|
||||
} from '~/composables/useStructureAssignmentFetch';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
assignment: StructureAssignmentNode;
|
||||
assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
|
||||
pieces: PieceOption[] | null;
|
||||
products: ProductOption[] | null;
|
||||
components: ComponentOption[] | null;
|
||||
@@ -236,331 +184,46 @@ const wrapperClass = computed(() =>
|
||||
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||
);
|
||||
|
||||
const { get } = useApi();
|
||||
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
|
||||
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
|
||||
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
|
||||
const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const productLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const componentLoadingByPath = ref<Record<string, boolean>>({});
|
||||
|
||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
||||
target[key] = value;
|
||||
};
|
||||
|
||||
const componentOptions = computed(() => {
|
||||
if (isRoot.value) {
|
||||
return [];
|
||||
}
|
||||
const cached = componentOptionsByPath.value[props.assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = props.assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || null;
|
||||
const requiredFamilyCode = definition.familyCode || null;
|
||||
|
||||
return (props.components || []).filter((component) => {
|
||||
if (!component || typeof component !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
return component.typeComposantId === requiredTypeId;
|
||||
}
|
||||
if (requiredFamilyCode) {
|
||||
return (
|
||||
component.typeComposant?.code === requiredFamilyCode ||
|
||||
component.typeComposantId === requiredFamilyCode
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const {
|
||||
pieceLoadingByPath,
|
||||
productLoadingByPath,
|
||||
componentLoadingByPath,
|
||||
componentOptions,
|
||||
componentOptionLabel,
|
||||
componentOptionDescription,
|
||||
fetchComponentOptions,
|
||||
getPieceOptions,
|
||||
pieceOptionLabel,
|
||||
pieceOptionDescription,
|
||||
fetchPieceOptions,
|
||||
describePieceRequirement,
|
||||
getProductOptions,
|
||||
productOptionLabel,
|
||||
productOptionDescription,
|
||||
fetchProductOptions,
|
||||
describeProductRequirement,
|
||||
} = useStructureAssignmentFetch({
|
||||
assignment: props.assignment,
|
||||
pieces: props.pieces,
|
||||
products: props.products,
|
||||
components: props.components,
|
||||
isRoot: () => isRoot.value,
|
||||
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
|
||||
productTypeLabelMap: props.productTypeLabelMap ?? {},
|
||||
componentTypeLabelMap: props.componentTypeLabelMap ?? {},
|
||||
});
|
||||
|
||||
const componentOptionLabel = (component?: ComponentOption | null) => {
|
||||
if (!component) {
|
||||
return 'Composant sans nom';
|
||||
}
|
||||
return component.name || 'Composant sans nom';
|
||||
};
|
||||
|
||||
const componentOptionDescription = (component?: ComponentOption | null) => {
|
||||
if (!component) {
|
||||
const normalizeSelectionValue = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const typeLabel =
|
||||
component.typeComposant?.name || component.typeComposant?.code || null;
|
||||
if (typeLabel) {
|
||||
parts.push(typeLabel);
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (component.reference) {
|
||||
parts.push(`Ref. ${component.reference}`);
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const typeIri = (id: string) => `/api/model_types/${id}`;
|
||||
const primedPiecePaths = new Set<string>();
|
||||
const primedProductPaths = new Set<string>();
|
||||
const primedComponentPaths = new Set<string>();
|
||||
|
||||
const fetchComponentOptions = async (term = '') => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const key = props.assignment.path;
|
||||
if (componentLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = props.assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeComposant', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(componentLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/composants?${params.toString()}`);
|
||||
if (result.success) {
|
||||
componentOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(componentLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
|
||||
const key = assignment.path;
|
||||
if (pieceLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typePieceId || definition.typePiece?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typePiece', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(pieceLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`);
|
||||
if (result.success) {
|
||||
pieceOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(pieceLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
|
||||
const key = assignment.path;
|
||||
if (productLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeProductId || definition.typeProduct?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeProduct', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(productLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/products?${params.toString()}`);
|
||||
if (result.success) {
|
||||
productOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(productLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
componentOptions,
|
||||
(options) => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const hasMatch = options.some(
|
||||
(component) => component.id === props.assignment.selectedComponentId,
|
||||
);
|
||||
if (!hasMatch) {
|
||||
props.assignment.selectedComponentId = '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const parts: string[] = [];
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
if (trimmed && !parts.includes(trimmed)) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const options = getPieceOptions(assignment);
|
||||
const fallbackPiece = options[0] || null;
|
||||
const fallbackType = fallbackPiece?.typePiece || null;
|
||||
|
||||
addPart(definition.role);
|
||||
const explicitLabel =
|
||||
definition.typePieceLabel ||
|
||||
definition.typePiece?.name ||
|
||||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
definition.typePiece?.code ||
|
||||
fallbackType?.code ||
|
||||
null;
|
||||
if (family) {
|
||||
addPart(`Famille ${family}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
addPart(fallbackType?.name);
|
||||
if (fallbackType?.code) {
|
||||
addPart(`Famille ${fallbackType.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0 && definition.typePieceId) {
|
||||
addPart(`#${definition.typePieceId}`);
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
||||
};
|
||||
|
||||
const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
const cached = productOptionsByPath.value[assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typeProductId ||
|
||||
definition.typeProduct?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
return (props.products || []).filter((product) => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (!requiredTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (definition.typeProductId || definition.typeProduct?.id) {
|
||||
return (
|
||||
product.typeProductId === requiredTypeId ||
|
||||
product.typeProduct?.id === requiredTypeId
|
||||
);
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return (
|
||||
product.typeProduct?.code === requiredTypeId ||
|
||||
product.typeProductId === requiredTypeId
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const productOptionLabel = (product?: ProductOption | null) => {
|
||||
if (!product) {
|
||||
return 'Produit';
|
||||
}
|
||||
return product.name || product.reference || 'Produit';
|
||||
};
|
||||
|
||||
const productOptionDescription = (product?: ProductOption | null) => {
|
||||
if (!product) {
|
||||
return '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const typeLabel =
|
||||
product.typeProduct?.name || product.typeProduct?.code || null;
|
||||
if (typeLabel) {
|
||||
parts.push(typeLabel);
|
||||
}
|
||||
if (product.reference) {
|
||||
parts.push(`Ref. ${product.reference}`);
|
||||
}
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const parts: string[] = [];
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
if (trimmed && !parts.includes(trimmed)) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const options = getProductOptions(assignment);
|
||||
const fallbackProduct = options[0] || null;
|
||||
const fallbackType = fallbackProduct?.typeProduct || null;
|
||||
|
||||
addPart(definition.role);
|
||||
const explicitLabel =
|
||||
definition.typeProductLabel ||
|
||||
definition.typeProduct?.name ||
|
||||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
definition.typeProduct?.code ||
|
||||
fallbackType?.code ||
|
||||
null;
|
||||
if (family) {
|
||||
addPart(`Famille ${family}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
addPart(fallbackType?.name);
|
||||
if (fallbackType?.code) {
|
||||
addPart(`Famille ${fallbackType.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0 && definition.typeProductId) {
|
||||
addPart(`#${definition.typeProductId}`);
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' • ') : 'Produit du squelette';
|
||||
return '';
|
||||
};
|
||||
|
||||
const requirementLabel = computed(() => {
|
||||
@@ -584,139 +247,13 @@ const requirementLabel = computed(() => {
|
||||
const requirementDescription = computed(() => {
|
||||
const definition = props.assignment.definition || {};
|
||||
const family =
|
||||
definition.typeComposantLabel ||
|
||||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
|
||||
definition.typeComposant?.name ||
|
||||
definition.familyCode;
|
||||
definition.typeComposantLabel
|
||||
|| (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
|
||||
|| definition.typeComposant?.name
|
||||
|| definition.familyCode;
|
||||
if (family) {
|
||||
return `Doit appartenir à la famille "${family}".`;
|
||||
}
|
||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
||||
});
|
||||
|
||||
const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
const cached = pieceOptionsByPath.value[assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typePieceId ||
|
||||
definition.typePiece?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
return (props.pieces || []).filter((piece) => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (!requiredTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (definition.typePieceId || definition.typePiece?.id) {
|
||||
return (
|
||||
piece.typePieceId === requiredTypeId ||
|
||||
piece.typePiece?.id === requiredTypeId
|
||||
);
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return (
|
||||
piece.typePiece?.code === requiredTypeId ||
|
||||
piece.typePieceId === requiredTypeId
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const pieceOptionLabel = (piece?: PieceOption | null) => {
|
||||
if (!piece) {
|
||||
return 'Pièce';
|
||||
}
|
||||
return piece.name || 'Pièce';
|
||||
};
|
||||
|
||||
const pieceOptionDescription = (piece?: PieceOption | null) => {
|
||||
if (!piece) {
|
||||
return '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const typeLabel =
|
||||
piece.typePiece?.name || piece.typePiece?.code || null;
|
||||
if (typeLabel) {
|
||||
parts.push(typeLabel);
|
||||
}
|
||||
if (piece.reference) {
|
||||
parts.push(`Ref. ${piece.reference}`);
|
||||
}
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const normalizeSelectionValue = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.pieces, props.assignment.pieces],
|
||||
() => {
|
||||
for (const pieceAssignment of props.assignment.pieces) {
|
||||
const options = getPieceOptions(pieceAssignment);
|
||||
if (
|
||||
pieceAssignment.selectedPieceId &&
|
||||
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
||||
) {
|
||||
pieceAssignment.selectedPieceId = '';
|
||||
}
|
||||
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
|
||||
primedPiecePaths.add(pieceAssignment.path);
|
||||
fetchPieceOptions(pieceAssignment).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.products, props.assignment.products],
|
||||
() => {
|
||||
for (const productAssignment of props.assignment.products) {
|
||||
const options = getProductOptions(productAssignment);
|
||||
if (
|
||||
productAssignment.selectedProductId &&
|
||||
!options.some((product) => product.id === productAssignment.selectedProductId)
|
||||
) {
|
||||
productAssignment.selectedProductId = '';
|
||||
}
|
||||
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
|
||||
primedProductPaths.add(productAssignment.path);
|
||||
fetchProductOptions(productAssignment).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.assignment.definition,
|
||||
() => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const key = props.assignment.path;
|
||||
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
|
||||
primedComponentPaths.add(key);
|
||||
fetchComponentOptions().catch(() => {});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-bold text-xl truncate">
|
||||
Prévisualisation
|
||||
<span v-if="navTotal > 1" class="text-base font-normal text-base-content/50">
|
||||
{{ activeIndex + 1 }} / {{ navTotal }}
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 truncate">
|
||||
{{ document?.name || document?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
||||
<p class="text-sm text-base-content/50 truncate">
|
||||
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
|
||||
@@ -20,15 +23,35 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden">
|
||||
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
|
||||
<button
|
||||
v-if="hasPrev"
|
||||
type="button"
|
||||
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||
title="Document précédent (←)"
|
||||
@click="goToPrev"
|
||||
>
|
||||
❮
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasNext"
|
||||
type="button"
|
||||
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||
title="Document suivant (→)"
|
||||
@click="goToNext"
|
||||
>
|
||||
❯
|
||||
</button>
|
||||
|
||||
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
|
||||
<template v-if="previewType === 'image'">
|
||||
<img :src="document?.path" alt="preview" class="max-h-full max-w-full object-contain">
|
||||
<img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'pdf'">
|
||||
<iframe
|
||||
:src="document?.path"
|
||||
:src="documentSrc"
|
||||
class="w-full h-full bg-white"
|
||||
frameborder="0"
|
||||
title="Aperçu PDF"
|
||||
@@ -36,16 +59,16 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'audio'">
|
||||
<audio :src="document?.path" controls class="w-full" />
|
||||
<audio :src="documentSrc" controls class="w-full" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'video'">
|
||||
<video :src="document?.path" controls class="w-full h-full bg-black" />
|
||||
<video :src="documentSrc" controls class="w-full h-full bg-black" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'text'">
|
||||
<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" />
|
||||
Chargement du document...
|
||||
</div>
|
||||
@@ -59,7 +82,7 @@
|
||||
</template>
|
||||
|
||||
<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.
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,31 +103,110 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getPreviewType, describeDocument } from '~/utils/documentPreview'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
const props = defineProps({
|
||||
document: {
|
||||
type: Object,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
documents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const previewType = computed(() => getPreviewType(props.document))
|
||||
const documentDescription = computed(() => describeDocument(props.document))
|
||||
// --- Carousel navigation ---
|
||||
|
||||
const previewableDocuments = computed(() => {
|
||||
if (!props.documents?.length) return []
|
||||
return props.documents.filter((doc) => canPreviewDocument(doc))
|
||||
})
|
||||
|
||||
const navTotal = computed(() => previewableDocuments.value.length)
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
|
||||
watch(
|
||||
() => props.document,
|
||||
(doc) => {
|
||||
if (!doc || !previewableDocuments.value.length) {
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
|
||||
activeIndex.value = idx >= 0 ? idx : 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const activeDoc = computed(() => {
|
||||
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
|
||||
return previewableDocuments.value[activeIndex.value]
|
||||
}
|
||||
return props.document
|
||||
})
|
||||
|
||||
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
|
||||
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
|
||||
|
||||
const goToPrev = () => {
|
||||
if (hasPrev.value) activeIndex.value--
|
||||
}
|
||||
const goToNext = () => {
|
||||
if (hasNext.value) activeIndex.value++
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeydown = (e) => {
|
||||
if (!props.visible) return
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
goToPrev()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
goToNext()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// --- Preview logic (uses activeDoc) ---
|
||||
|
||||
const previewType = computed(() => getPreviewType(activeDoc.value))
|
||||
const documentDescription = computed(() => describeDocument(activeDoc.value))
|
||||
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
|
||||
|
||||
const textContent = ref('')
|
||||
const textLoading = ref(false)
|
||||
const textError = ref('')
|
||||
|
||||
watch(
|
||||
() => props.document,
|
||||
activeDoc,
|
||||
async (doc) => {
|
||||
textContent.value = ''
|
||||
textError.value = ''
|
||||
@@ -115,22 +217,17 @@ watch(
|
||||
|
||||
try {
|
||||
textLoading.value = true
|
||||
const path = doc.path || ''
|
||||
if (path.startsWith('data:')) {
|
||||
const base64Part = path.split(',')[1] || ''
|
||||
if (!base64Part) {
|
||||
textError.value = 'Impossible de lire ce document texte.'
|
||||
return
|
||||
}
|
||||
const decoded = atob(base64Part)
|
||||
textContent.value = decodeURIComponent(escape(decoded))
|
||||
} else {
|
||||
const response = await fetch(path)
|
||||
if (!response.ok) {
|
||||
throw new Error('Téléchargement du document impossible')
|
||||
}
|
||||
textContent.value = await response.text()
|
||||
const url = doc.fileUrl || doc.path || ''
|
||||
if (!url) {
|
||||
textError.value = 'Aucune URL de document disponible.'
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(url, { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('Téléchargement du document impossible')
|
||||
}
|
||||
textContent.value = await response.text()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du texte:', error)
|
||||
textError.value = error.message || 'Impossible de lire ce document.'
|
||||
@@ -138,7 +235,7 @@ watch(
|
||||
textLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const close = () => {
|
||||
@@ -146,11 +243,8 @@ const close = () => {
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
if (!props.document?.path) { return }
|
||||
const link = document.createElement('a')
|
||||
link.href = props.document.path
|
||||
link.download = props.document.filename || props.document.name || 'document'
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
|
||||
if (!url) { return }
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,6 +40,8 @@ type GenericDocument = {
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
path?: string | null;
|
||||
fileUrl?: string | null;
|
||||
downloadUrl?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
@@ -52,7 +54,7 @@ const normalizedDocument = computed(() => props.document ?? null);
|
||||
|
||||
const canRenderImage = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
return !!(doc && isImageDocument(doc) && doc.path);
|
||||
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
|
||||
});
|
||||
|
||||
const canRenderPdf = computed(() => {
|
||||
@@ -73,13 +75,14 @@ const appendPdfViewerParams = (src: string) => {
|
||||
|
||||
const previewSrc = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
if (!doc || !doc.path) {
|
||||
const url = doc?.fileUrl || doc?.path;
|
||||
if (!doc || !url) {
|
||||
return '';
|
||||
}
|
||||
if (isPdfDocument(doc)) {
|
||||
return appendPdfViewerParams(doc.path);
|
||||
return appendPdfViewerParams(url);
|
||||
}
|
||||
return doc.path;
|
||||
return url;
|
||||
});
|
||||
|
||||
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<h3 class="font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-base-content/50">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
|
||||
Sélectionner des fichiers
|
||||
</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>
|
||||
|
||||
<input
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<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>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">
|
||||
@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFiles = computed(() => internalFiles.value)
|
||||
const selectedFiles = internalFiles
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<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.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.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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<section :class="sectionClasses">
|
||||
<div :class="contentClasses">
|
||||
<div :class="['space-y-4', maxWidthClass]">
|
||||
<component :is="headingTag" v-if="title" class="text-4xl font-bold">
|
||||
<div :class="['space-y-3', maxWidthClass]">
|
||||
<component :is="headingTag" v-if="title" class="text-4xl font-bold tracking-tight">
|
||||
{{ title }}
|
||||
</component>
|
||||
<p v-if="subtitle" class="text-sm opacity-90">
|
||||
<p v-if="subtitle" class="text-sm opacity-80 leading-relaxed">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<slot />
|
||||
@@ -58,9 +58,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
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) {
|
||||
classes.push('rounded-lg')
|
||||
classes.push('rounded-xl', 'overflow-hidden')
|
||||
}
|
||||
return classes
|
||||
})
|
||||
|
||||
@@ -1,63 +1,87 @@
|
||||
<template>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="space-y-4">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" />
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="`piece-name-${piece.id}`"
|
||||
v-model="pieceData.name"
|
||||
type="text"
|
||||
class="font-semibold text-lg input input-sm input-bordered"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
:aria-expanded="!isCollapsed"
|
||||
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
{{ pieceData.name }}
|
||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ pieceData.name }}
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
>
|
||||
×{{ displayQuantity }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
||||
<template v-if="pieceConstructeursDisplay.length">
|
||||
<span
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
v-if="piece.skeletonOnly"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
Défini dans le catalogue
|
||||
</span>
|
||||
<span
|
||||
v-if="piece.typeMachinePieceRequirement"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
Groupe :
|
||||
{{
|
||||
piece.typeMachinePieceRequirement.label ||
|
||||
piece.typeMachinePieceRequirement.typePiece?.name ||
|
||||
"Non défini"
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="piece.parentComponentName"
|
||||
class="badge badge-ghost badge-sm"
|
||||
>
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
<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 class="space-y-2 text-sm">
|
||||
<div v-show="!isCollapsed" class="space-y-4">
|
||||
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="!piece.parentComponentLinkId && 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>
|
||||
<span class="font-medium">Référence:</span>
|
||||
<input
|
||||
@@ -86,7 +110,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="formatConstructeurContact(constructeur)"
|
||||
class="text-xs text-gray-500"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
{{ formatConstructeurContact(constructeur) }}
|
||||
</span>
|
||||
@@ -169,65 +193,10 @@
|
||||
<span class="font-semibold">{{ info.label }} :</span>
|
||||
<span class="ml-1">{{ info.value }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="productDocuments.length"
|
||||
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
||||
>
|
||||
<h5 class="font-medium text-base-content">Documents du produit</h5>
|
||||
<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">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="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-5 w-5"
|
||||
: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>
|
||||
<ProductDocumentsInline
|
||||
:documents="productDocuments"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="font-medium">
|
||||
Non défini
|
||||
@@ -235,149 +204,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs personnalisés de la pièce -->
|
||||
<div
|
||||
v-if="displayedCustomFields.length"
|
||||
class="mt-4 pt-4 border-t border-gray-200"
|
||||
>
|
||||
<h5 class="text-sm font-medium text-gray-700 mb-3">
|
||||
Champs personnalisés
|
||||
</h5>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(field, index) in displayedCustomFields"
|
||||
:key="resolveFieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{
|
||||
resolveFieldName(field)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="resolveFieldRequired(field)"
|
||||
class="label-text-alt text-error"
|
||||
>*</span
|
||||
>
|
||||
</label>
|
||||
<CustomFieldDisplay
|
||||
:fields="displayedCustomFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
@field-input="handleCustomFieldInput"
|
||||
@field-blur="handleCustomFieldBlur"
|
||||
/>
|
||||
|
||||
<!-- Mode édition -->
|
||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="
|
||||
setCustomFieldValue(
|
||||
resolveFieldId(field),
|
||||
$event.target.value,
|
||||
field
|
||||
)
|
||||
"
|
||||
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
|
||||
/>
|
||||
|
||||
<!-- Champ de type NUMBER -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="
|
||||
setCustomFieldValue(
|
||||
resolveFieldId(field),
|
||||
$event.target.value,
|
||||
field
|
||||
)
|
||||
"
|
||||
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
|
||||
/>
|
||||
|
||||
<!-- Champ de type SELECT -->
|
||||
<select
|
||||
v-else-if="resolveFieldType(field) === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@change="
|
||||
(event) =>
|
||||
setCustomFieldValue(
|
||||
resolveFieldId(field),
|
||||
event.target.value,
|
||||
field
|
||||
)
|
||||
"
|
||||
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in resolveFieldOptions(field)"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<div
|
||||
v-else-if="resolveFieldType(field) === 'boolean'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
:value="field.value ?? ''"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="
|
||||
setCustomFieldValue(
|
||||
resolveFieldId(field),
|
||||
$event.target.checked ? 'true' : 'false',
|
||||
field
|
||||
)
|
||||
"
|
||||
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
|
||||
/>
|
||||
<span class="text-sm">{{
|
||||
String(field.value).toLowerCase() === "true" ? "Oui" : "Non"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="
|
||||
setCustomFieldValue(
|
||||
resolveFieldId(field),
|
||||
$event.target.value,
|
||||
field
|
||||
)
|
||||
"
|
||||
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Mode lecture seule -->
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatFieldDisplayValue(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h5 class="text-sm font-medium text-gray-700">Documents</h5>
|
||||
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
|
||||
<span
|
||||
v-if="isEditMode && selectedFiles.length"
|
||||
class="badge badge-outline"
|
||||
@@ -389,7 +228,7 @@
|
||||
</span>
|
||||
</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...
|
||||
</p>
|
||||
|
||||
@@ -401,95 +240,26 @@
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
|
||||
<div v-if="pieceDocuments.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in pieceDocuments"
|
||||
:key="document.id"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="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">
|
||||
{{ document.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ document.mimeType || "Inconnu" }} •
|
||||
{{ formatSize(document.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="uploadingDocuments"
|
||||
@click="removeDocument(document.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
|
||||
Aucun document lié à cette pièce.
|
||||
</p>
|
||||
<DocumentListInline
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à cette pièce."
|
||||
@preview="openPreview"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted, watch, computed } from 'vue'
|
||||
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import {
|
||||
@@ -498,22 +268,8 @@ import {
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldId,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
@@ -522,9 +278,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
const props = defineProps({
|
||||
piece: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
showDelete: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||
|
||||
// --- Local reactive data for editing ---
|
||||
const pieceData = reactive({
|
||||
@@ -532,6 +291,11 @@ const pieceData = reactive({
|
||||
reference: props.piece.reference || '',
|
||||
prix: props.piece.prix || '',
|
||||
productId: props.piece.product?.id || props.piece.productId || null,
|
||||
quantity: props.piece.quantity ?? 1,
|
||||
})
|
||||
|
||||
const displayQuantity = computed(() => {
|
||||
return pieceData.quantity ?? 1
|
||||
})
|
||||
|
||||
// --- Products ---
|
||||
@@ -575,6 +339,23 @@ const {
|
||||
updateCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) refreshDocuments()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) refreshDocuments()
|
||||
}
|
||||
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
@@ -648,16 +429,16 @@ const handleProductChange = async (value) => {
|
||||
updatePiece()
|
||||
}
|
||||
|
||||
// --- Custom field local helpers ---
|
||||
const setCustomFieldValue = (fieldValueId, value, field) => {
|
||||
// --- Custom field event handlers ---
|
||||
const handleCustomFieldInput = (field, value) => {
|
||||
if (resolveFieldReadOnly(field)) return
|
||||
if (field && typeof field === 'object') field.value = value
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
if (!fieldValueId) return
|
||||
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
||||
if (fieldValue) fieldValue.value = value
|
||||
}
|
||||
|
||||
const updateCustomFieldValue = async (_fieldValueId, field) => {
|
||||
const handleCustomFieldBlur = async (field) => {
|
||||
await updateCustomField(field)
|
||||
const cfId = field?.customFieldId || field?.customField?.id || null
|
||||
if (cfId || field?.customFieldValueId) {
|
||||
@@ -682,6 +463,7 @@ const updatePiece = () => {
|
||||
...props.piece,
|
||||
...pieceData,
|
||||
prix: parsedPrice,
|
||||
quantity: pieceData.quantity ?? 1,
|
||||
productId: pieceData.productId || null,
|
||||
product,
|
||||
constructeurIds: pieceConstructeurIds.value,
|
||||
@@ -721,11 +503,12 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.piece.name, props.piece.reference, props.piece.prix],
|
||||
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
|
||||
() => {
|
||||
pieceData.name = props.piece.name || ''
|
||||
pieceData.reference = props.piece.reference || ''
|
||||
pieceData.prix = props.piece.prix || ''
|
||||
pieceData.quantity = props.piece.quantity ?? 1
|
||||
},
|
||||
)
|
||||
|
||||
@@ -733,6 +516,7 @@ onMounted(() => {
|
||||
pieceData.name = props.piece.name || ''
|
||||
pieceData.reference = props.piece.reference || ''
|
||||
pieceData.prix = props.piece.prix || ''
|
||||
pieceData.quantity = props.piece.quantity ?? 1
|
||||
loadProducts().catch(() => {})
|
||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
||||
if (!props.piece.documents?.length) refreshDocuments()
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="space-y-3">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Produits inclus par défaut
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
<header>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Produits inclus par défaut
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
||||
</p>
|
||||
</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.
|
||||
</p>
|
||||
|
||||
@@ -71,20 +65,18 @@
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<header class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Champs personnalisés
|
||||
</h3>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addField">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</header>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Champs personnalisés
|
||||
</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.
|
||||
</p>
|
||||
|
||||
@@ -101,106 +93,94 @@
|
||||
@drop.prevent="onDrop(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||
title="Réordonner"
|
||||
draggable="false"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
<option value="number">
|
||||
Nombre
|
||||
</option>
|
||||
<option value="select">
|
||||
Liste
|
||||
</option>
|
||||
<option value="boolean">
|
||||
Oui/Non
|
||||
</option>
|
||||
<option value="date">
|
||||
Date
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isFieldLocked(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isFieldLocked(field)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||
title="Réordonner"
|
||||
draggable="false"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
<option value="number">
|
||||
Nombre
|
||||
</option>
|
||||
<option value="select">
|
||||
Liste
|
||||
</option>
|
||||
<option value="boolean">
|
||||
Oui/Non
|
||||
</option>
|
||||
<option value="date">
|
||||
Date
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isFieldLocked(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isFieldLocked(field)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addField">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import type {
|
||||
PieceModelCustomField,
|
||||
PieceModelCustomFieldType,
|
||||
PieceModelProduct,
|
||||
PieceModelStructure,
|
||||
PieceModelStructureEditorField,
|
||||
} from '~/shared/types/inventory'
|
||||
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
|
||||
|
||||
defineOptions({ name: 'PieceModelStructureEditor' })
|
||||
|
||||
type EditorField = PieceModelStructureEditorField & { uid: string }
|
||||
type EditorProduct = {
|
||||
uid: string
|
||||
typeProductId: string
|
||||
typeProductLabel: string
|
||||
familyCode: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: PieceModelStructure | null
|
||||
restrictedMode?: boolean
|
||||
@@ -210,373 +190,23 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: PieceModelStructure): void
|
||||
}>()
|
||||
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
|
||||
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
|
||||
Array.isArray(value) ? value : []
|
||||
|
||||
const normalizeLineEndings = (value: string): string =>
|
||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const safeClone = <T,>(value: T, fallback: T): T => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
||||
} catch {
|
||||
return JSON.parse(JSON.stringify(fallback)) as T
|
||||
}
|
||||
}
|
||||
|
||||
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const entries = Object.entries(structure).filter(
|
||||
([key]) => key !== 'customFields' && key !== 'products',
|
||||
)
|
||||
return safeClone(Object.fromEntries(entries), {})
|
||||
}
|
||||
|
||||
let uidCounter = 0
|
||||
const createUid = (scope: 'field' | 'product'): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
uidCounter += 1
|
||||
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
|
||||
}
|
||||
|
||||
const toEditorField = (
|
||||
input: Partial<PieceModelStructureEditorField> | null | undefined,
|
||||
index: number,
|
||||
): EditorField => {
|
||||
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
|
||||
const optionsText = normalizeLineEndings(
|
||||
typeof input?.optionsText === 'string'
|
||||
? input.optionsText
|
||||
: Array.isArray(input?.options)
|
||||
? input.options.join('\n')
|
||||
: '',
|
||||
)
|
||||
|
||||
return {
|
||||
uid: createUid('field'),
|
||||
name: typeof input?.name === 'string' ? input.name : '',
|
||||
type: baseType as PieceModelCustomFieldType,
|
||||
required: Boolean(input?.required),
|
||||
optionsText,
|
||||
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
|
||||
const source = ensureArray(structure?.customFields)
|
||||
return source
|
||||
.map((field, index) => toEditorField(field, index))
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||
}
|
||||
|
||||
const toEditorProduct = (
|
||||
input: Partial<PieceModelProduct> | null | undefined,
|
||||
): EditorProduct => ({
|
||||
uid: createUid('product'),
|
||||
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
|
||||
typeProductLabel:
|
||||
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
|
||||
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
|
||||
})
|
||||
|
||||
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
|
||||
const source = Array.isArray(structure?.products) ? structure?.products : []
|
||||
return source.map((product) => toEditorProduct(product))
|
||||
}
|
||||
|
||||
const productTypeOptions = computed(() => productTypes.value ?? [])
|
||||
|
||||
const productTypeMap = computed(() => {
|
||||
const map = new Map<string, any>()
|
||||
productTypeOptions.value.forEach((type: any) => {
|
||||
if (type?.id) {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const formatProductTypeOption = (type: any) => {
|
||||
if (!type) {
|
||||
return ''
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (type.code) {
|
||||
parts.push(type.code)
|
||||
}
|
||||
if (type.name) {
|
||||
parts.push(type.name)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : type.id || ''
|
||||
}
|
||||
|
||||
const updateProductTypeMetadata = (product: EditorProduct) => {
|
||||
const option = product.typeProductId
|
||||
? productTypeMap.value.get(product.typeProductId)
|
||||
: null
|
||||
product.typeProductLabel = option?.name ?? ''
|
||||
}
|
||||
|
||||
const handleProductTypeSelect = (product: EditorProduct) => {
|
||||
const option = product.typeProductId
|
||||
? productTypeMap.value.get(product.typeProductId)
|
||||
: null
|
||||
product.typeProductLabel = option?.name ?? ''
|
||||
if (option?.code) {
|
||||
product.familyCode = option.code
|
||||
}
|
||||
}
|
||||
|
||||
const createEmptyProduct = (): EditorProduct => ({
|
||||
uid: createUid('product'),
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
})
|
||||
|
||||
const addProduct = () => {
|
||||
products.value.push(createEmptyProduct())
|
||||
}
|
||||
|
||||
const removeProduct = (index: number) => {
|
||||
products.value = products.value.filter((_, idx) => idx !== index)
|
||||
}
|
||||
|
||||
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||
|
||||
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
|
||||
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
|
||||
|
||||
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)
|
||||
|
||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
list.map((field, index) => ({
|
||||
...field,
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
|
||||
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
|
||||
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
|
||||
|
||||
if (!typeProductId && !familyCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: PieceModelProduct = {}
|
||||
if (typeProductId) {
|
||||
payload.typeProductId = typeProductId
|
||||
}
|
||||
if (familyCode) {
|
||||
payload.familyCode = familyCode
|
||||
}
|
||||
if (product.typeProductLabel) {
|
||||
payload.typeProductLabel = product.typeProductLabel
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const buildPayload = (
|
||||
fieldsSource: EditorField[],
|
||||
productsSource: EditorProduct[],
|
||||
restSource: Record<string, unknown>,
|
||||
): PieceModelStructure => {
|
||||
const normalizedFields = fieldsSource
|
||||
.map<PieceModelCustomField | null>((field, index) => {
|
||||
const name = field.name.trim()
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = (field.type || 'text') as PieceModelCustomFieldType
|
||||
const required = Boolean(field.required)
|
||||
const payload: PieceModelCustomField = {
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
orderIndex: index,
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
const options = normalizeLineEndings(field.optionsText)
|
||||
.split('\n')
|
||||
.map((option) => option.trim())
|
||||
.filter((option) => option.length > 0)
|
||||
if (options.length > 0) {
|
||||
payload.options = options
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
})
|
||||
.filter((field): field is PieceModelCustomField => Boolean(field))
|
||||
|
||||
const normalizedProducts = productsSource
|
||||
.map((product) => normalizeProductEntry(product))
|
||||
.filter((product): product is PieceModelProduct => Boolean(product))
|
||||
|
||||
const draft: PieceModelStructure = {
|
||||
...safeClone(restSource, {}),
|
||||
products: normalizedProducts,
|
||||
customFields: normalizedFields,
|
||||
}
|
||||
|
||||
return normalizePieceStructureForSave(draft)
|
||||
}
|
||||
|
||||
const serializeStructure = (structure?: PieceModelStructure | null): string => {
|
||||
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
|
||||
}
|
||||
|
||||
let lastEmitted = serializeStructure(props.modelValue)
|
||||
|
||||
const emitUpdate = () => {
|
||||
const payload = buildPayload(fields.value, products.value, restState.value)
|
||||
const serialized = JSON.stringify(payload)
|
||||
if (serialized !== lastEmitted) {
|
||||
lastEmitted = serialized
|
||||
emit('update:modelValue', payload)
|
||||
}
|
||||
}
|
||||
|
||||
watch(fields, emitUpdate, { deep: true })
|
||||
watch(products, emitUpdate, { deep: true })
|
||||
watch(productTypeOptions, () => {
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const incomingSerialized = serializeStructure(value)
|
||||
if (incomingSerialized === lastEmitted) {
|
||||
return
|
||||
}
|
||||
restState.value = extractRest(value)
|
||||
fields.value = hydrateFields(value)
|
||||
products.value = hydrateProducts(value)
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
lastEmitted = incomingSerialized
|
||||
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
|
||||
initialProductUids.value = new Set(products.value.map(p => p.uid))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!productTypeOptions.value.length) {
|
||||
await loadProductTypes()
|
||||
}
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
const dragState = reactive({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
})
|
||||
|
||||
const resetDragState = () => {
|
||||
dragState.draggingIndex = null
|
||||
dragState.dropTargetIndex = null
|
||||
}
|
||||
|
||||
const reorderFields = (from: number, to: number) => {
|
||||
if (from === to) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const list = fields.value.slice()
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const [moved] = list.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
list.splice(to, 0, moved)
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
dragState.draggingIndex = index
|
||||
dragState.dropTargetIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (dragState.draggingIndex === null) {
|
||||
return
|
||||
}
|
||||
dragState.dropTargetIndex = index
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
if (dragState.draggingIndex === null) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
reorderFields(dragState.draggingIndex, index)
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number) => {
|
||||
if (dragState.draggingIndex === index) {
|
||||
return 'border-dashed border-primary bg-primary/5'
|
||||
}
|
||||
if (
|
||||
dragState.draggingIndex !== null &&
|
||||
dragState.dropTargetIndex === index &&
|
||||
dragState.draggingIndex !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/10'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||
uid: createUid('field'),
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
orderIndex,
|
||||
})
|
||||
|
||||
const addField = () => {
|
||||
const next = fields.value.slice()
|
||||
next.push(createEmptyField(next.length))
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
const removeField = (index: number) => {
|
||||
const next = fields.value.filter((_, i) => i !== index)
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
const {
|
||||
fields,
|
||||
products,
|
||||
productTypeOptions,
|
||||
restrictedMode,
|
||||
isFieldLocked,
|
||||
isProductLocked,
|
||||
formatProductTypeOption,
|
||||
handleProductTypeSelect,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addField,
|
||||
removeField,
|
||||
reorderClass,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
} = usePieceStructureEditorLogic({ props, emit })
|
||||
</script>
|
||||
|
||||
78
app/components/ProductDocumentsInline.vue
Normal file
78
app/components/ProductDocumentsInline.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="documents.length"
|
||||
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
||||
>
|
||||
<h5 class="font-medium text-base-content">Documents du produit</h5>
|
||||
<div
|
||||
v-for="document in documents"
|
||||
: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">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-5 w-5"
|
||||
: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="$emit('preview', document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps({
|
||||
documents: { type: Array, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['preview'])
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
</span>
|
||||
</label>
|
||||
<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.
|
||||
</p>
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@
|
||||
{{ formatComponentTypeOption(type) }}
|
||||
</option>
|
||||
</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' }}
|
||||
</p>
|
||||
<div v-if="!isRoot" class="form-control mt-2">
|
||||
@@ -70,16 +70,10 @@
|
||||
|
||||
<div class="px-4 py-4 space-y-5">
|
||||
<section v-if="isRoot" class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
||||
</h4>
|
||||
<p v-if="!(node.customFields?.length)" class="text-xs text-base-content/50">
|
||||
Aucun champ n'a encore été défini.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
@@ -155,19 +149,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="isRoot" class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||
</h4>
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!(node.products?.length)" class="text-xs text-gray-500">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||
</h4>
|
||||
<p v-if="!(node.products?.length)" class="text-xs text-base-content/50">
|
||||
Aucun produit défini.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
@@ -228,19 +220,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="isRoot" class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||
</h4>
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||
</h4>
|
||||
<p v-if="!(node.pieces?.length)" class="text-xs text-base-content/50">
|
||||
Aucune pièce définie.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
@@ -286,10 +276,22 @@
|
||||
</option>
|
||||
</select>
|
||||
</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' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs">Quantité</span></label>
|
||||
<input
|
||||
v-model.number="piece.quantity"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
placeholder="Qté"
|
||||
class="input input-bordered input-sm md:input-md w-20"
|
||||
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
@@ -302,25 +304,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">Sous-composants</h4>
|
||||
<button
|
||||
v-if="canManageSubcomponents && !restrictedMode"
|
||||
type="button"
|
||||
class="btn btn-outline btn-xs"
|
||||
@click="addSubComponent"
|
||||
>
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-gray-500">
|
||||
<h4 :class="headingClass">Sous-composants</h4>
|
||||
<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.
|
||||
</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.
|
||||
</p>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -357,6 +352,15 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canManageSubcomponents && !restrictedMode"
|
||||
type="button"
|
||||
class="btn btn-outline btn-xs"
|
||||
@click="addSubComponent"
|
||||
>
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,26 +368,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
|
||||
import type { EditableStructureNode, ModelTypeOption } from '~/composables/useStructureNodeLogic'
|
||||
|
||||
defineOptions({ name: 'StructureNodeEditor' })
|
||||
|
||||
type ModelTypeOption = {
|
||||
id: string
|
||||
name: string
|
||||
code?: string | null
|
||||
}
|
||||
|
||||
type EditableStructureNode = ComponentModelStructureNode & {
|
||||
customFields?: any[]
|
||||
pieces?: ComponentModelPiece[]
|
||||
products?: ComponentModelProduct[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
node: EditableStructureNode
|
||||
depth?: number
|
||||
@@ -413,754 +404,60 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const isLocked = computed(() => props.isLocked === true)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const productTypes = computed(() => props.productTypes ?? [])
|
||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||
const maxSubcomponentDepth = computed(() =>
|
||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||
)
|
||||
const currentDepth = computed(() => Math.max(0, props.depth ?? 0))
|
||||
const canManageSubcomponents = computed(
|
||||
() => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value,
|
||||
)
|
||||
const childAllowSubcomponents = computed(
|
||||
() => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value,
|
||||
)
|
||||
const hasSubcomponents = computed(
|
||||
() => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0,
|
||||
)
|
||||
|
||||
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
|
||||
const containerClass = computed(() => {
|
||||
const level = currentDepth.value
|
||||
const index = Math.min(level, depthClasses.length - 1)
|
||||
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
|
||||
})
|
||||
|
||||
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
|
||||
const lockedTypeDisplay = computed(() => {
|
||||
if (props.lockedTypeLabel) {
|
||||
return props.lockedTypeLabel
|
||||
}
|
||||
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
|
||||
})
|
||||
|
||||
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
type?.name ?? ''
|
||||
|
||||
const componentTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
componentTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const componentTypeCodeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
componentTypes.value.forEach((type) => {
|
||||
const code = typeof type?.code === 'string' ? type.code.trim() : ''
|
||||
if (code) {
|
||||
map.set(code, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
pieceTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const productTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
productTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const getComponentTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(componentTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const getPieceTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(pieceTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const _getProductTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(productTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
|
||||
if (!Array.isArray((props.node as any)[key])) {
|
||||
if (key === 'subcomponents') {
|
||||
props.node.subcomponents = []
|
||||
} else if (key === 'products') {
|
||||
props.node.products = []
|
||||
} else {
|
||||
(props.node as any)[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncComponentType = (component: EditableStructureNode) => {
|
||||
if (!component) {
|
||||
return
|
||||
}
|
||||
if (props.isRoot) {
|
||||
component.typeComposantId = ''
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
if (component.alias) {
|
||||
component.alias = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
if (props.lockType && props.isRoot) {
|
||||
if (props.lockedTypeLabel) {
|
||||
component.typeComposantLabel = props.lockedTypeLabel
|
||||
if (!component.alias || component.alias === component.typeComposantLabel) {
|
||||
component.alias = props.lockedTypeLabel
|
||||
}
|
||||
}
|
||||
if (component.typeComposantId) {
|
||||
const option = componentTypeMap.value.get(component.typeComposantId)
|
||||
component.familyCode = option?.code ?? component.familyCode
|
||||
}
|
||||
return
|
||||
}
|
||||
const id = typeof component.typeComposantId === 'string'
|
||||
? component.typeComposantId
|
||||
: ''
|
||||
|
||||
if (!id) {
|
||||
const code =
|
||||
typeof component.familyCode === 'string' && component.familyCode
|
||||
? component.familyCode
|
||||
: ''
|
||||
if (code) {
|
||||
const codeMatch = componentTypeCodeMap.value.get(code)
|
||||
if (codeMatch?.id) {
|
||||
component.typeComposantId = codeMatch.id
|
||||
component.typeComposantLabel = formatModelTypeOption(codeMatch)
|
||||
component.familyCode = codeMatch.code ?? component.familyCode
|
||||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||||
component.alias = codeMatch.name || component.typeComposantLabel
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
return
|
||||
}
|
||||
|
||||
const option = componentTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
return
|
||||
}
|
||||
|
||||
component.typeComposantLabel = formatModelTypeOption(option)
|
||||
component.familyCode = option.code ?? component.familyCode
|
||||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||||
component.alias = option.name || component.typeComposantLabel
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
|
||||
if (!piece) return
|
||||
|
||||
if (piece.typePieceId) {
|
||||
const option = pieceTypeMap.value.get(piece.typePieceId)
|
||||
if (option) {
|
||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (piece.typePieceLabel) {
|
||||
const normalized = piece.typePieceLabel.trim().toLowerCase()
|
||||
if (normalized) {
|
||||
const match = pieceTypes.value.find((type) => {
|
||||
const formatted = formatPieceTypeOption(type).toLowerCase()
|
||||
const name = (type?.name ?? '').toLowerCase()
|
||||
const code = (type?.code ?? '').toLowerCase()
|
||||
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||
})
|
||||
if (match) {
|
||||
piece.typePieceId = match.id
|
||||
piece.typePieceLabel = formatPieceTypeOption(match)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
|
||||
if (!product) return
|
||||
|
||||
if (product.typeProductId) {
|
||||
const option = productTypeMap.value.get(product.typeProductId)
|
||||
if (option) {
|
||||
product.typeProductLabel = formatProductTypeOption(option)
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (product.typeProductLabel) {
|
||||
const normalized = product.typeProductLabel.trim().toLowerCase()
|
||||
if (normalized) {
|
||||
const match = productTypes.value.find((type) => {
|
||||
const formatted = formatProductTypeOption(type).toLowerCase()
|
||||
const name = (type?.name ?? '').toLowerCase()
|
||||
const code = (type?.code ?? '').toLowerCase()
|
||||
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||
})
|
||||
if (match) {
|
||||
product.typeProductId = match.id
|
||||
product.typeProductLabel = formatProductTypeOption(match)
|
||||
product.familyCode = match.code ?? product.familyCode ?? ''
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncPieceLabels = (pieces?: any[]) => {
|
||||
if (!Array.isArray(pieces)) {
|
||||
return
|
||||
}
|
||||
pieces.forEach((piece) => {
|
||||
updatePieceTypeLabel(piece)
|
||||
})
|
||||
}
|
||||
|
||||
const syncProductLabels = (products?: any[]) => {
|
||||
if (!Array.isArray(products)) {
|
||||
return
|
||||
}
|
||||
products.forEach((product) => {
|
||||
updateProductTypeLabel(product)
|
||||
})
|
||||
}
|
||||
|
||||
const handleComponentTypeSelect = (component: any) => {
|
||||
syncComponentType(component)
|
||||
}
|
||||
|
||||
const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
|
||||
if (!piece) {
|
||||
return
|
||||
}
|
||||
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
|
||||
if (!id) {
|
||||
piece.typePieceLabel = ''
|
||||
return
|
||||
}
|
||||
const option = pieceTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
piece.typePieceId = ''
|
||||
piece.typePieceLabel = ''
|
||||
return
|
||||
}
|
||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||
}
|
||||
|
||||
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
|
||||
if (!product) {
|
||||
return
|
||||
}
|
||||
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
|
||||
if (!id) {
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
const option = productTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
product.typeProductId = ''
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
product.typeProductLabel = formatProductTypeOption(option)
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
}
|
||||
|
||||
const customFieldDragState = ref({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
})
|
||||
|
||||
const reindexCustomFields = () => {
|
||||
if (!Array.isArray(props.node.customFields)) {
|
||||
return
|
||||
}
|
||||
props.node.customFields.forEach((field: any, index: number) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
field.orderIndex = index
|
||||
})
|
||||
}
|
||||
|
||||
const resetCustomFieldDragState = () => {
|
||||
customFieldDragState.value.draggingIndex = null
|
||||
customFieldDragState.value.dropTargetIndex = null
|
||||
}
|
||||
|
||||
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
|
||||
customFieldDragState.value.draggingIndex = index
|
||||
customFieldDragState.value.dropTargetIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onCustomFieldDragEnter = (index: number) => {
|
||||
if (customFieldDragState.value.draggingIndex === null) {
|
||||
return
|
||||
}
|
||||
customFieldDragState.value.dropTargetIndex = index
|
||||
}
|
||||
|
||||
const onCustomFieldDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.customFields)) {
|
||||
resetCustomFieldDragState()
|
||||
return
|
||||
}
|
||||
const from = customFieldDragState.value.draggingIndex
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetCustomFieldDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.customFields, from, to)
|
||||
reindexCustomFields()
|
||||
resetCustomFieldDragState()
|
||||
}
|
||||
|
||||
const onCustomFieldDragEnd = () => {
|
||||
resetCustomFieldDragState()
|
||||
}
|
||||
|
||||
const customFieldReorderClass = (index: number) => {
|
||||
if (customFieldDragState.value.draggingIndex === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
customFieldDragState.value.draggingIndex !== null &&
|
||||
customFieldDragState.value.dropTargetIndex === index &&
|
||||
customFieldDragState.value.draggingIndex !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const addCustomField = () => {
|
||||
ensureArray('customFields')
|
||||
const fields = props.node.customFields!
|
||||
const nextIndex = fields.length
|
||||
fields.push({
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
options: [],
|
||||
orderIndex: nextIndex,
|
||||
})
|
||||
reindexCustomFields()
|
||||
}
|
||||
|
||||
const removeCustomField = (index: number) => {
|
||||
if (!Array.isArray(props.node.customFields)) return
|
||||
props.node.customFields.splice(index, 1)
|
||||
reindexCustomFields()
|
||||
}
|
||||
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
familyCode: '',
|
||||
role: '',
|
||||
})
|
||||
}
|
||||
|
||||
const removePiece = (index: number) => {
|
||||
if (!Array.isArray(props.node.pieces)) return
|
||||
props.node.pieces.splice(index, 1)
|
||||
}
|
||||
|
||||
const addProduct = () => {
|
||||
ensureArray('products')
|
||||
props.node.products!.push({
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
})
|
||||
}
|
||||
|
||||
const removeProduct = (index: number) => {
|
||||
if (!Array.isArray(props.node.products)) return
|
||||
props.node.products.splice(index, 1)
|
||||
}
|
||||
|
||||
const addSubComponent = () => {
|
||||
if (!canManageSubcomponents.value) {
|
||||
return
|
||||
}
|
||||
ensureArray('subcomponents')
|
||||
props.node.subcomponents.push({
|
||||
typeComposantId: '',
|
||||
typeComposantLabel: '',
|
||||
modelId: '',
|
||||
familyCode: '',
|
||||
alias: '',
|
||||
subcomponents: [],
|
||||
})
|
||||
}
|
||||
|
||||
const removeSubComponent = (index: number) => {
|
||||
if (!Array.isArray(props.node.subcomponents)) return
|
||||
props.node.subcomponents.splice(index, 1)
|
||||
}
|
||||
|
||||
const draggingPieceIndex = ref<number | null>(null)
|
||||
const pieceDropTargetIndex = ref<number | null>(null)
|
||||
const draggingProductIndex = ref<number | null>(null)
|
||||
const productDropTargetIndex = ref<number | null>(null)
|
||||
const draggingSubcomponentIndex = ref<number | null>(null)
|
||||
const subcomponentDropTargetIndex = ref<number | null>(null)
|
||||
|
||||
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
|
||||
if (from === to) {
|
||||
return
|
||||
}
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
return
|
||||
}
|
||||
const updated = list.slice()
|
||||
const [item] = updated.splice(from, 1)
|
||||
if (item === undefined) return
|
||||
updated.splice(to, 0, item)
|
||||
list.splice(0, list.length, ...updated)
|
||||
}
|
||||
|
||||
const resetPieceDragState = () => {
|
||||
draggingPieceIndex.value = null
|
||||
pieceDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const resetProductDragState = () => {
|
||||
draggingProductIndex.value = null
|
||||
productDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onPieceDragStart = (index: number, event: DragEvent) => {
|
||||
draggingPieceIndex.value = index
|
||||
pieceDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onPieceDragEnter = (index: number) => {
|
||||
if (draggingPieceIndex.value === null) {
|
||||
return
|
||||
}
|
||||
pieceDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onPieceDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onPieceDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.pieces)) {
|
||||
resetPieceDragState()
|
||||
return
|
||||
}
|
||||
const from = draggingPieceIndex.value
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetPieceDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.pieces, from, to)
|
||||
resetPieceDragState()
|
||||
}
|
||||
|
||||
const onPieceDragEnd = () => {
|
||||
resetPieceDragState()
|
||||
}
|
||||
|
||||
const pieceReorderClass = (index: number) => {
|
||||
if (draggingPieceIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingPieceIndex.value !== null &&
|
||||
pieceDropTargetIndex.value === index &&
|
||||
draggingPieceIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const onProductDragStart = (index: number, event: DragEvent) => {
|
||||
draggingProductIndex.value = index
|
||||
productDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onProductDragEnter = (index: number) => {
|
||||
if (draggingProductIndex.value === null) {
|
||||
return
|
||||
}
|
||||
productDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onProductDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onProductDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.products)) {
|
||||
resetProductDragState()
|
||||
return
|
||||
}
|
||||
const from = draggingProductIndex.value
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetProductDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.products, from, to)
|
||||
resetProductDragState()
|
||||
}
|
||||
|
||||
const onProductDragEnd = () => {
|
||||
resetProductDragState()
|
||||
}
|
||||
|
||||
const productReorderClass = (index: number) => {
|
||||
if (draggingProductIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingProductIndex.value !== null &&
|
||||
productDropTargetIndex.value === index &&
|
||||
draggingProductIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resetSubcomponentDragState = () => {
|
||||
draggingSubcomponentIndex.value = null
|
||||
subcomponentDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onSubcomponentDragStart = (index: number, event: DragEvent) => {
|
||||
draggingSubcomponentIndex.value = index
|
||||
subcomponentDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onSubcomponentDragEnter = (index: number) => {
|
||||
if (draggingSubcomponentIndex.value === null) {
|
||||
return
|
||||
}
|
||||
subcomponentDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onSubcomponentDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onSubcomponentDrop = (index: number) => {
|
||||
if (!Array.isArray(props.node.subcomponents)) {
|
||||
resetSubcomponentDragState()
|
||||
return
|
||||
}
|
||||
const from = draggingSubcomponentIndex.value
|
||||
const to = index
|
||||
if (from === null || to === null) {
|
||||
resetSubcomponentDragState()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(props.node.subcomponents, from, to)
|
||||
resetSubcomponentDragState()
|
||||
}
|
||||
|
||||
const onSubcomponentDragEnd = () => {
|
||||
resetSubcomponentDragState()
|
||||
}
|
||||
|
||||
const subcomponentReorderClass = (index: number) => {
|
||||
if (draggingSubcomponentIndex.value === index) {
|
||||
return 'ring-2 ring-primary'
|
||||
}
|
||||
if (
|
||||
draggingSubcomponentIndex.value !== null &&
|
||||
subcomponentDropTargetIndex.value === index &&
|
||||
draggingSubcomponentIndex.value !== index
|
||||
) {
|
||||
return 'ring-2 ring-primary/70'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
watch(
|
||||
const {
|
||||
isCustomFieldLocked,
|
||||
isPieceLocked,
|
||||
isProductLocked,
|
||||
isSubcomponentLocked,
|
||||
isLocked,
|
||||
restrictedMode,
|
||||
componentTypes,
|
||||
pieceTypes,
|
||||
productTypes,
|
||||
canManageSubcomponents,
|
||||
(allowed) => {
|
||||
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
|
||||
props.node.subcomponents.splice(0, props.node.subcomponents.length)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(componentTypes, () => {
|
||||
syncComponentType(props.node)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.typeComposantId,
|
||||
() => {
|
||||
syncComponentType(props.node)
|
||||
},
|
||||
)
|
||||
|
||||
watch(pieceTypes, () => {
|
||||
syncPieceLabels(props.node?.pieces)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.pieces,
|
||||
(value) => {
|
||||
syncPieceLabels(value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(productTypes, () => {
|
||||
syncProductLabels(props.node?.products)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.products,
|
||||
(value) => {
|
||||
syncProductLabels(value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.node.customFields,
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
value.sort((a: any, b: any) => {
|
||||
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||||
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||||
return left - right
|
||||
})
|
||||
reindexCustomFields()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.lockedTypeLabel, props.lockType],
|
||||
() => {
|
||||
if (props.lockType && props.isRoot) {
|
||||
const label = props.lockedTypeLabel || lockedTypeDisplay.value
|
||||
props.node.typeComposantLabel = label
|
||||
if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
|
||||
props.node.alias = label
|
||||
}
|
||||
if (props.node.typeComposantId) {
|
||||
const option = componentTypeMap.value.get(props.node.typeComposantId)
|
||||
props.node.familyCode = option?.code ?? props.node.familyCode
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
childAllowSubcomponents,
|
||||
hasSubcomponents,
|
||||
containerClass,
|
||||
headingClass,
|
||||
lockedTypeDisplay,
|
||||
getComponentTypeLabel,
|
||||
getPieceTypeLabel,
|
||||
formatComponentTypeOption,
|
||||
formatPieceTypeOption,
|
||||
formatProductTypeOption,
|
||||
handleComponentTypeSelect,
|
||||
handlePieceTypeSelect,
|
||||
handleProductTypeSelect,
|
||||
addCustomField,
|
||||
removeCustomField,
|
||||
addPiece,
|
||||
removePiece,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addSubComponent,
|
||||
removeSubComponent,
|
||||
onCustomFieldDragStart,
|
||||
onCustomFieldDragEnter,
|
||||
onCustomFieldDrop,
|
||||
onCustomFieldDragEnd,
|
||||
customFieldReorderClass,
|
||||
onPieceDragStart,
|
||||
onPieceDragEnter,
|
||||
onPieceDragOver,
|
||||
onPieceDrop,
|
||||
onPieceDragEnd,
|
||||
pieceReorderClass,
|
||||
onProductDragStart,
|
||||
onProductDragEnter,
|
||||
onProductDragOver,
|
||||
onProductDrop,
|
||||
onProductDragEnd,
|
||||
productReorderClass,
|
||||
onSubcomponentDragStart,
|
||||
onSubcomponentDragEnter,
|
||||
onSubcomponentDragOver,
|
||||
onSubcomponentDrop,
|
||||
onSubcomponentDragEnd,
|
||||
subcomponentReorderClass,
|
||||
} = useStructureNodeLogic(props)
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideX
|
||||
<IconLucideCircleX
|
||||
v-else-if="toast.type === 'error'"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
@@ -64,6 +64,7 @@
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucideCircleX from '~icons/lucide/circle-x'
|
||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||
import IconLucideInfo from '~icons/lucide/info'
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<div class="card-actions justify-end">
|
||||
<button type="button" class="btn btn-outline" @click="$emit('reset')">
|
||||
Réinitialiser
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
<IconLucideRefreshCw
|
||||
v-if="saving"
|
||||
class="w-5 h-5 mr-2 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideCheck v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ saving ? 'Sauvegarde...' : 'Sauvegarder les modifications' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconLucideRefreshCw from '~icons/lucide/refresh-cw'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
|
||||
defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['reset'])
|
||||
</script>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">
|
||||
Informations de base
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du type</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="nameModel"
|
||||
type="text"
|
||||
placeholder="Nom du type de machine"
|
||||
class="input input-bordered"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="categoryModel"
|
||||
type="text"
|
||||
placeholder="Catégorie du type"
|
||||
class="input input-bordered"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="descriptionModel"
|
||||
placeholder="Description du type de machine"
|
||||
class="textarea textarea-bordered h-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fréquence de maintenance</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="maintenanceModel"
|
||||
type="text"
|
||||
placeholder="ex: Mensuelle, Trimestrielle"
|
||||
class="input input-bordered"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
maintenanceFrequency: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:name', 'update:category', 'update:description', 'update:maintenanceFrequency'])
|
||||
|
||||
const nameModel = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value)
|
||||
})
|
||||
|
||||
const categoryModel = computed({
|
||||
get: () => props.category,
|
||||
set: value => emit('update:category', value)
|
||||
})
|
||||
|
||||
const descriptionModel = computed({
|
||||
get: () => props.description,
|
||||
set: value => emit('update:description', value)
|
||||
})
|
||||
|
||||
const maintenanceModel = computed({
|
||||
get: () => props.maintenanceFrequency,
|
||||
set: value => emit('update:maintenanceFrequency', value)
|
||||
})
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="componentTypes"
|
||||
type-field="typeComposantId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="true"
|
||||
:min-fallback="1"
|
||||
:type-loading="loadingComponentTypes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typeComposantId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typeComposantId: null,
|
||||
label: '',
|
||||
minCount: 1,
|
||||
maxCount: null,
|
||||
required: true,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Familles de composants',
|
||||
addButton: 'Ajouter une famille',
|
||||
description:
|
||||
"Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.",
|
||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de composants.',
|
||||
typeSelectLabel: 'Type de composant',
|
||||
typePlaceholder: 'Sélectionner un type',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex: Sangles principales',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!componentTypes.value.length) {
|
||||
await loadComponentTypes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,356 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm p-1"
|
||||
@click="toggleSection"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': expanded }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<h3 class="card-title text-lg">
|
||||
Champs personnalisés du type
|
||||
</h3>
|
||||
<span class="badge badge-primary">{{ fields.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="space-y-4">
|
||||
<div
|
||||
v-for="(field, fieldIndex) in fields"
|
||||
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
|
||||
class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
|
||||
:class="fieldReorderClass(fieldIndex)"
|
||||
draggable="true"
|
||||
@dragstart="onFieldDragStart(fieldIndex, $event)"
|
||||
@dragenter="onFieldDragEnter(fieldIndex)"
|
||||
@dragover.prevent
|
||||
@drop="onFieldDrop(fieldIndex)"
|
||||
@dragend="onFieldDragEnd"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
title="Réorganiser"
|
||||
draggable="false"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-1"
|
||||
title="Plier / déplier le champ"
|
||||
@click="toggleField(fieldIndex)"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': isFieldExpanded(fieldIndex) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<IconLucideListChecks class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
||||
<h5 class="text-sm font-medium">
|
||||
Champ personnalisé {{ fieldIndex + 1 }}
|
||||
</h5>
|
||||
<span v-if="!isFieldExpanded(fieldIndex)" class="text-xs text-gray-500 truncate max-w-[160px]">
|
||||
{{ field.name || 'Sans nom' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
title="Supprimer ce champ"
|
||||
@click="removeField(fieldIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isFieldExpanded(fieldIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du champ</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="field.name"
|
||||
type="text"
|
||||
placeholder="Nom du champ"
|
||||
class="input input-bordered input-sm"
|
||||
required
|
||||
@input="updateField(fieldIndex, { name: $event.target.value })"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type de champ</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered select-sm"
|
||||
required
|
||||
:value="field.type"
|
||||
@change="updateField(fieldIndex, { type: $event.target.value })"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner un type
|
||||
</option>
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
<option value="number">
|
||||
Nombre
|
||||
</option>
|
||||
<option value="select">
|
||||
Liste déroulante
|
||||
</option>
|
||||
<option value="boolean">
|
||||
Oui/Non
|
||||
</option>
|
||||
<option value="date">
|
||||
Date
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isFieldExpanded(fieldIndex)" class="mt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="field.required"
|
||||
@change="updateField(fieldIndex, { required: $event.target.checked })"
|
||||
>
|
||||
<span class="text-sm">Champ obligatoire</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFieldExpanded(fieldIndex) && field.type === 'select'"
|
||||
class="mt-3"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">Options de la liste</span>
|
||||
<span class="label-text-alt">Une option par ligne</span>
|
||||
</label>
|
||||
<textarea
|
||||
:value="field.optionsText || ''"
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
class="textarea textarea-bordered textarea-sm w-full h-20"
|
||||
@input="updateOptions(fieldIndex, $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addField">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-end">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addField">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideListChecks from '~icons/lucide/list-checks'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
expandAllTrigger: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const fields = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||
|
||||
const expanded = ref(false)
|
||||
const expandedFields = ref([])
|
||||
const draggingFieldIndex = ref(null)
|
||||
const fieldDropTargetIndex = ref(null)
|
||||
|
||||
const applyOrderIndex = (list = []) => {
|
||||
if (!Array.isArray(list)) { return [] }
|
||||
list.forEach((field, index) => {
|
||||
if (field && typeof field === 'object') {
|
||||
field.orderIndex = index
|
||||
if (typeof field.__key !== 'string' || !field.__key) {
|
||||
field.__key = createFieldKey()
|
||||
}
|
||||
}
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
const createEmptyField = () => ({
|
||||
name: '',
|
||||
type: '',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
orderIndex: fields.value.length,
|
||||
__key: createFieldKey()
|
||||
})
|
||||
|
||||
const resetDragState = () => {
|
||||
draggingFieldIndex.value = null
|
||||
fieldDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const reorderFields = (from, to) => {
|
||||
const list = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
const [moved] = list.splice(from, 1)
|
||||
list.splice(to, 0, moved)
|
||||
if (Array.isArray(expandedFields.value)) {
|
||||
const expandedCopy = expandedFields.value.slice()
|
||||
const [expandedState] = expandedCopy.splice(from, 1)
|
||||
expandedCopy.splice(to, 0, expandedState)
|
||||
expandedFields.value = expandedCopy
|
||||
}
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.expandAllTrigger,
|
||||
() => {
|
||||
expanded.value = props.allExpanded
|
||||
expandedFields.value = fields.value.map(() => props.allExpanded)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => fields.value.length,
|
||||
(length) => {
|
||||
expandedFields.value = Array.from({ length }, (_, index) => expandedFields.value[index] ?? props.allExpanded)
|
||||
}
|
||||
)
|
||||
|
||||
const toggleSection = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const ensureFieldState = (index) => {
|
||||
if (expandedFields.value[index] === undefined) {
|
||||
expandedFields.value[index] = props.allExpanded
|
||||
}
|
||||
}
|
||||
|
||||
const isFieldExpanded = (index) => {
|
||||
ensureFieldState(index)
|
||||
return expandedFields.value[index]
|
||||
}
|
||||
|
||||
const toggleField = (index) => {
|
||||
ensureFieldState(index)
|
||||
expandedFields.value[index] = !expandedFields.value[index]
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||
next.push(createEmptyField())
|
||||
fields.value = applyOrderIndex(next)
|
||||
expandedFields.value.push(true)
|
||||
expanded.value = true
|
||||
}
|
||||
|
||||
const removeField = (index) => {
|
||||
const next = Array.isArray(fields.value)
|
||||
? fields.value.filter((_, i) => i !== index)
|
||||
: []
|
||||
fields.value = applyOrderIndex(next)
|
||||
expandedFields.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const updateField = (index, patch) => {
|
||||
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||
next[index] = { ...next[index], ...patch }
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
const updateOptions = (index, value) => {
|
||||
updateField(index, {
|
||||
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
})
|
||||
}
|
||||
|
||||
const onFieldDragStart = (index, event) => {
|
||||
draggingFieldIndex.value = index
|
||||
fieldDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onFieldDragEnter = (index) => {
|
||||
if (draggingFieldIndex.value === null) { return }
|
||||
fieldDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onFieldDrop = (index) => {
|
||||
if (draggingFieldIndex.value === null) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
reorderFields(draggingFieldIndex.value, index)
|
||||
}
|
||||
|
||||
const onFieldDragEnd = () => {
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const fieldReorderClass = (index) => {
|
||||
if (draggingFieldIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingFieldIndex.value !== null &&
|
||||
fieldDropTargetIndex.value === index &&
|
||||
draggingFieldIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<TypeEditToolbar :all-expanded="allExpanded" @toggle="toggleAllSections" />
|
||||
|
||||
<TypeEditBaseInfoSection
|
||||
v-model:name="formData.name"
|
||||
v-model:category="formData.category"
|
||||
v-model:description="formData.description"
|
||||
v-model:maintenance-frequency="formData.maintenanceFrequency"
|
||||
/>
|
||||
|
||||
<TypeEditCustomFieldsSection
|
||||
:model-value="formData.customFields"
|
||||
:all-expanded="allExpanded"
|
||||
:expand-all-trigger="expandAllTrigger"
|
||||
@update:model-value="(value) => (formData.customFields = value)"
|
||||
/>
|
||||
|
||||
<TypeEditComponentRequirementsSection
|
||||
:model-value="formData.componentRequirements"
|
||||
@update:model-value="(value) => (formData.componentRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditPieceRequirementsSection
|
||||
:model-value="formData.pieceRequirements"
|
||||
@update:model-value="(value) => (formData.pieceRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditProductRequirementsSection
|
||||
:model-value="formData.productRequirements"
|
||||
@update:model-value="(value) => (formData.productRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
|
||||
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
|
||||
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
|
||||
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
||||
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
|
||||
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
|
||||
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit'])
|
||||
|
||||
const deepClone = value => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||
|
||||
const normalizeCustomField = (field = {}, index = 0) => {
|
||||
const clone = deepClone(field)
|
||||
if (clone.type === 'select') {
|
||||
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
|
||||
if (Array.isArray(clone.options)) {
|
||||
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
|
||||
} else {
|
||||
clone.optionsText = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const currentOrder =
|
||||
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
||||
clone.orderIndex = currentOrder
|
||||
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
||||
clone.__key = createFieldKey()
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
const withNormalizedOrder = (items = []) => {
|
||||
if (!Array.isArray(items)) { return [] }
|
||||
return items
|
||||
.map((item, index) => normalizeCustomField(item, index))
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.map((item, index) => ({ ...item, orderIndex: index }))
|
||||
}
|
||||
|
||||
const createDefaultForm = (source = {}) => ({
|
||||
name: source.name || '',
|
||||
description: source.description || '',
|
||||
category: source.category || '',
|
||||
maintenanceFrequency: source.maintenanceFrequency || '',
|
||||
customFields: withNormalizedOrder(source.customFields || []),
|
||||
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
|
||||
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
|
||||
productRequirements: withNormalizedOrder(source.productRequirements || []),
|
||||
})
|
||||
|
||||
const formData = reactive(createDefaultForm(props.modelValue))
|
||||
const allExpanded = ref(false)
|
||||
const expandAllTrigger = ref(0)
|
||||
|
||||
let syncingFromParent = false
|
||||
const toPlainObject = value => JSON.parse(JSON.stringify(value))
|
||||
const lastSnapshot = ref(toPlainObject(createDefaultForm(props.modelValue)))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const normalized = createDefaultForm(value)
|
||||
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
syncingFromParent = true
|
||||
Object.assign(formData, normalized)
|
||||
lastSnapshot.value = toPlainObject(normalized)
|
||||
syncingFromParent = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
formData,
|
||||
(value) => {
|
||||
if (syncingFromParent) { return }
|
||||
const normalized = createDefaultForm(value)
|
||||
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSnapshot.value = toPlainObject(normalized)
|
||||
emit('update:modelValue', normalized)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const toggleAllSections = () => {
|
||||
allExpanded.value = !allExpanded.value
|
||||
expandAllTrigger.value += 1
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
const normalized = createDefaultForm(props.modelValue)
|
||||
syncingFromParent = true
|
||||
Object.assign(formData, normalized)
|
||||
lastSnapshot.value = toPlainObject(normalized)
|
||||
syncingFromParent = false
|
||||
allExpanded.value = false
|
||||
expandAllTrigger.value += 1
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="pieceTypes"
|
||||
type-field="typePieceId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="false"
|
||||
:min-fallback="0"
|
||||
:type-loading="loadingPieceTypes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typePieceId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typePieceId: null,
|
||||
label: '',
|
||||
minCount: 0,
|
||||
maxCount: null,
|
||||
required: false,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Pièces principales',
|
||||
addButton: 'Ajouter un groupe',
|
||||
description:
|
||||
"Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.",
|
||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de pièces.',
|
||||
typeSelectLabel: 'Type de pièce',
|
||||
typePlaceholder: 'Sélectionner un type',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex: Vis principale',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!pieceTypes.value.length) {
|
||||
await loadPieceTypes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="productTypes"
|
||||
type-field="typeProductId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="false"
|
||||
:min-fallback="0"
|
||||
:type-loading="loadingProductTypes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typeProductId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typeProductId: null,
|
||||
label: '',
|
||||
minCount: 0,
|
||||
maxCount: null,
|
||||
required: false,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Produits requis',
|
||||
addButton: 'Ajouter un produit',
|
||||
description:
|
||||
"Définissez les produits catalogue attendus pour ce type de machine. Sélectionnez la catégorie de produit, précisez les quantités minimales et maximales, puis indiquez si de nouveaux produits peuvent être créés à l'usage.",
|
||||
emptyState: 'Aucun produit requis configuré pour le moment.',
|
||||
typeSelectLabel: 'Catégorie de produit',
|
||||
typePlaceholder: 'Sélectionner une catégorie',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex : Lubrifiant recommandé',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux produits lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!productTypes.value.length) {
|
||||
await loadProductTypes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="$emit('toggle')">
|
||||
<IconLucideMinus v-if="allExpanded" class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
<IconLucidePlus v-else class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
{{ allExpanded ? 'Tout plier' : 'Tout déplier' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconLucideMinus from '~icons/lucide/minus'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
defineProps({
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<div class="alert alert-info mb-6">
|
||||
<div>
|
||||
<h3 class="font-bold">
|
||||
Type existant
|
||||
</h3>
|
||||
<div class="text-sm">
|
||||
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
|
||||
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
|
||||
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
|
||||
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
|
||||
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
|
||||
<p v-if="type.description">
|
||||
<strong>Description:</strong> {{ type.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
173
app/components/common/CustomFieldDisplay.vue
Normal file
173
app/components/common/CustomFieldDisplay.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="fields.length"
|
||||
class="mt-4 pt-4 border-t border-base-200"
|
||||
>
|
||||
<h5 class="text-sm font-medium text-base-content/80 mb-3">
|
||||
Champs personnalisés
|
||||
</h5>
|
||||
<div :class="layoutClass">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="resolveFieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{
|
||||
resolveFieldName(field)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="resolveFieldRequired(field)"
|
||||
class="label-text-alt text-error"
|
||||
>*</span>
|
||||
</label>
|
||||
|
||||
<!-- Mode édition -->
|
||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type NUMBER -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type SELECT -->
|
||||
<select
|
||||
v-else-if="resolveFieldType(field) === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner...
|
||||
</option>
|
||||
<option
|
||||
v-for="option in resolveFieldOptions(field)"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<div
|
||||
v-else-if="resolveFieldType(field) === 'boolean'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span class="text-sm">{{
|
||||
String(field.value).toLowerCase() === "true" ? "Oui" : "Non"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type TEXTAREA -->
|
||||
<textarea
|
||||
v-else-if="resolveFieldType(field) === 'textarea'"
|
||||
:value="field.value ?? ''"
|
||||
class="textarea textarea-bordered textarea-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
/>
|
||||
|
||||
<!-- Fallback: input text -->
|
||||
<input
|
||||
v-else
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Mode lecture seule -->
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatFieldDisplayValue(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: any[]
|
||||
isEditMode: boolean
|
||||
columns?: 1 | 2
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'field-input': [field: any, value: string]
|
||||
'field-blur': [field: any]
|
||||
}>()
|
||||
|
||||
const layoutClass = computed(() =>
|
||||
props.columns === 2
|
||||
? 'grid grid-cols-1 md:grid-cols-2 gap-4'
|
||||
: 'space-y-3',
|
||||
)
|
||||
|
||||
function onInput(field: any, value: string) {
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
}
|
||||
|
||||
function onBooleanChange(field: any, checked: boolean) {
|
||||
const value = checked ? 'true' : 'false'
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
emit('field-blur', field)
|
||||
}
|
||||
|
||||
function onBlur(field: any) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
</script>
|
||||
83
app/components/common/CustomFieldInputGrid.vue
Normal file
83
app/components/common/CustomFieldInputGrid.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<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">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
defineProps<{
|
||||
fields: CustomFieldInput[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
308
app/components/common/DataTable.vue
Normal file
308
app/components/common/DataTable.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar + counter row -->
|
||||
<div
|
||||
v-if="$slots.toolbar || showCounter || showPerPage"
|
||||
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="showPerPage && pagination?.perPageOptions?.length" class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="dt-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="dt-per-page"
|
||||
:value="pagination.perPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="opt in pagination.perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="showCounter && pagination" class="text-xs text-base-content/50 whitespace-nowrap">
|
||||
{{ pagination.pageItems }} / {{ pagination.totalItems }}
|
||||
résultat{{ pagination.totalItems > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state (full spinner only when no filterable columns to keep visible) -->
|
||||
<div v-if="loading && !hasFilterableColumns" class="flex justify-center py-8">
|
||||
<slot name="loading">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (no data at all, no filterable columns to keep visible) -->
|
||||
<template v-else-if="isEmpty && !hasFilterableColumns">
|
||||
<slot name="empty">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ emptyMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- No results without filterable columns -->
|
||||
<template v-else-if="rows.length === 0 && !hasFilterableColumns">
|
||||
<slot name="no-results">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ noResultsMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto overflow-y-clip relative rounded-lg border border-base-300/40">
|
||||
<!-- Loading overlay (keeps table & filter inputs visible) -->
|
||||
<div
|
||||
v-if="loading && hasFilterableColumns"
|
||||
class="absolute inset-0 bg-base-100/60 backdrop-blur-[1px] z-10 flex items-center justify-center"
|
||||
>
|
||||
<span class="loading loading-spinner text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<table :class="['table table-sm md:table-md', tableClass]">
|
||||
<thead>
|
||||
<!-- Header labels + sort -->
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.width,
|
||||
col.class,
|
||||
col.headerClass,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`header-${col.key}`" :column="col">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1',
|
||||
col.sortable ? 'cursor-pointer select-none hover:text-base-content' : '',
|
||||
]"
|
||||
@click="col.sortable && handleHeaderSort(col)"
|
||||
>
|
||||
{{ col.label }}
|
||||
<template v-if="col.sortable">
|
||||
<IconLucideChevronUp
|
||||
v-if="isSortedAsc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié croissant"
|
||||
/>
|
||||
<IconLucideChevronDown
|
||||
v-else-if="isSortedDesc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié décroissant"
|
||||
/>
|
||||
<IconLucideChevronsUpDown
|
||||
v-else
|
||||
class="h-3.5 w-3.5 opacity-30"
|
||||
aria-label="Triable"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
<th v-if="expandable" class="w-12" />
|
||||
</tr>
|
||||
<!-- Filter inputs row -->
|
||||
<tr v-if="hasFilterableColumns">
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="`filter-${col.key}`"
|
||||
class="p-1"
|
||||
:class="{ 'hidden sm:table-cell': col.hiddenMobile }"
|
||||
>
|
||||
<input
|
||||
v-if="col.filterable"
|
||||
type="text"
|
||||
class="input input-bordered input-xs w-full"
|
||||
:placeholder="col.filterPlaceholder || 'Filtrer…'"
|
||||
:value="columnFilters[col.key] ?? ''"
|
||||
@input="handleFilterInput(col.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="expandable" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- No results message (inside table to keep headers visible) -->
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="expandable ? columns.length + 1 : columns.length" class="text-center py-8">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{{ isEmpty ? emptyMessage : noResultsMessage }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="(row, idx) in rows" :key="getRowKey(row)">
|
||||
<tr>
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.class,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${col.key}`" :row="row" :column="col" :index="idx">
|
||||
{{ row[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="expandable" class="text-center">
|
||||
<button
|
||||
v-if="!canExpand || canExpand(row)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="emit('toggle-expand', getRowKey(row))"
|
||||
>
|
||||
{{ isExpanded(row) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded row -->
|
||||
<tr v-if="expandable && isExpanded(row)">
|
||||
<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" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination && pagination.totalPages > 1"
|
||||
:current-page="pagination.currentPage"
|
||||
:total-pages="pagination.totalPages"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataTableColumn, DataTableSort, DataTablePagination, DataTableColumnFilters } from '~/shared/types/dataTable'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import IconLucideChevronUp from '~icons/lucide/chevron-up'
|
||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: DataTableColumn[]
|
||||
rows: any[]
|
||||
rowKey?: string
|
||||
loading?: boolean
|
||||
sort?: DataTableSort | null
|
||||
pagination?: DataTablePagination | null
|
||||
columnFilters?: DataTableColumnFilters
|
||||
emptyMessage?: string
|
||||
noResultsMessage?: string
|
||||
expandable?: boolean
|
||||
expandedKeys?: Set<string>
|
||||
canExpand?: (row: any) => boolean
|
||||
tableClass?: string
|
||||
showCounter?: boolean
|
||||
showPerPage?: boolean
|
||||
}>(), {
|
||||
rowKey: 'id',
|
||||
loading: false,
|
||||
sort: null,
|
||||
pagination: null,
|
||||
columnFilters: () => ({}),
|
||||
emptyMessage: 'Aucune donnée disponible.',
|
||||
noResultsMessage: 'Aucun résultat ne correspond à vos critères.',
|
||||
expandable: false,
|
||||
expandedKeys: () => new Set<string>(),
|
||||
canExpand: undefined,
|
||||
tableClass: '',
|
||||
showCounter: true,
|
||||
showPerPage: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'sort', sort: DataTableSort): void
|
||||
(e: 'update:currentPage', page: number): void
|
||||
(e: 'update:perPage', perPage: number): void
|
||||
(e: 'update:columnFilters', filters: DataTableColumnFilters): void
|
||||
(e: 'toggle-expand', key: string): void
|
||||
}>()
|
||||
|
||||
const hasFilterableColumns = computed(() =>
|
||||
props.columns.some(col => col.filterable),
|
||||
)
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (props.pagination) {
|
||||
return props.pagination.totalItems === 0
|
||||
}
|
||||
return props.rows.length === 0
|
||||
})
|
||||
|
||||
const getRowKey = (row: any): string => {
|
||||
return String(row[props.rowKey] ?? '')
|
||||
}
|
||||
|
||||
const isExpanded = (row: any): boolean => {
|
||||
return props.expandedKeys?.has(getRowKey(row)) ?? false
|
||||
}
|
||||
|
||||
const sortKeyForColumn = (col: DataTableColumn): string => {
|
||||
return col.sortKey ?? col.key
|
||||
}
|
||||
|
||||
const isSortedAsc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'asc'
|
||||
}
|
||||
|
||||
const isSortedDesc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'desc'
|
||||
}
|
||||
|
||||
const handleHeaderSort = (col: DataTableColumn) => {
|
||||
const key = sortKeyForColumn(col)
|
||||
const currentDirection = props.sort?.field === key ? props.sort.direction : null
|
||||
|
||||
emit('sort', {
|
||||
field: key,
|
||||
direction: currentDirection === 'asc' ? 'desc' : 'asc',
|
||||
})
|
||||
}
|
||||
|
||||
let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleFilterInput = (key: string, value: string) => {
|
||||
if (filterDebounceTimer) clearTimeout(filterDebounceTimer)
|
||||
filterDebounceTimer = setTimeout(() => {
|
||||
const updated = { ...props.columnFilters, [key]: value }
|
||||
// Remove empty filter keys
|
||||
for (const k of Object.keys(updated)) {
|
||||
if (!updated[k]) delete updated[k]
|
||||
}
|
||||
emit('update:columnFilters', updated)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const alignClass = (col: DataTableColumn): string => {
|
||||
if (col.align === 'center') return 'text-center'
|
||||
if (col.align === 'right') return 'text-right'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
104
app/components/common/DocumentListInline.vue
Normal file
104
app/components/common/DocumentListInline.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.id || document.path || document.name"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-6 w-6"
|
||||
:class="documentIcon(document).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ 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">
|
||||
<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="$emit('preview', document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(document)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="deleteDisabled"
|
||||
@click="$emit('delete', document.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-base-content/70">
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
documentIcon,
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
import type { Document } from '~/composables/useDocuments'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
canDelete?: boolean
|
||||
deleteDisabled?: boolean
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
canDelete: false,
|
||||
deleteDisabled: false,
|
||||
emptyText: 'Aucun document.',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'preview', document: Document): void
|
||||
(e: 'delete', documentId: string): void
|
||||
}>()
|
||||
</script>
|
||||
97
app/components/common/EntityHistorySection.vue
Normal file
97
app/components/common/EntityHistorySection.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="entries.length" class="badge badge-outline">
|
||||
{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l'historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="entries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="diffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in diffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
type HistoryDiffEntry,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
action: string
|
||||
createdAt: string
|
||||
actor?: { label?: string } | null
|
||||
diff?: Record<string, { from?: unknown; to?: unknown }> | null
|
||||
snapshot?: { name?: string } | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entries: HistoryEntry[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
fieldLabels: Record<string, string>
|
||||
}>()
|
||||
|
||||
const diffEntries = (entry: HistoryEntry): HistoryDiffEntry[] =>
|
||||
historyDiffEntries(entry, props.fieldLabels)
|
||||
</script>
|
||||
@@ -9,11 +9,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="text-sm text-base-content/50">
|
||||
{{ labels.description }}
|
||||
</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 }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@
|
||||
@keydown.enter.prevent="selectHighlighted"
|
||||
@input="handleInput"
|
||||
>
|
||||
<button
|
||||
v-if="clearable && modelValue"
|
||||
type="button"
|
||||
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
|
||||
aria-label="Effacer la sélection"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="toggleButtonClasses"
|
||||
@@ -32,11 +41,11 @@
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
Recherche en cours…
|
||||
</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 }}
|
||||
</div>
|
||||
<ul v-else class="flex flex-col">
|
||||
@@ -60,7 +69,7 @@
|
||||
{{ resolveLabel(option) }}
|
||||
</slot>
|
||||
</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">
|
||||
{{ resolveDescription(option) }}
|
||||
</slot>
|
||||
@@ -77,6 +86,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -111,6 +121,10 @@ const props = defineProps({
|
||||
type: [String, Function],
|
||||
default: null
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
@@ -155,7 +169,8 @@ const displayedOptions = computed(() => {
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const base = ['input', 'input-bordered', 'w-full', 'pr-10']
|
||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||
if (props.size === 'xs') base.push('input-xs')
|
||||
if (props.size === 'sm') base.push('input-sm')
|
||||
if (props.size === 'lg') base.push('input-lg')
|
||||
@@ -269,9 +284,17 @@ function handleInput () {
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
function clearSelection () {
|
||||
emit('update:modelValue', '')
|
||||
searchTerm.value = ''
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (selectedOption.value) {
|
||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||
emit('update:modelValue', '')
|
||||
} else if (selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
162
app/components/common/StructureSkeletonPreview.vue
Normal file
162
app/components/common/StructureSkeletonPreview.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ previewBadge }}</span>
|
||||
</div>
|
||||
|
||||
<details v-if="structure" class="collapse collapse-arrow bg-base-100">
|
||||
<summary class="collapse-title text-sm font-medium">
|
||||
Consulter le détail du squelette
|
||||
</summary>
|
||||
<div class="collapse-content text-sm text-base-content/80" :class="variant === 'component' ? 'space-y-4' : 'space-y-2'">
|
||||
<!-- Custom fields: component variant (rich display) -->
|
||||
<div v-if="variant === 'component' && customFields.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="field in customFields"
|
||||
:key="field.customFieldId || field.id || field.name"
|
||||
class="rounded bg-base-200/60 px-3 py-2"
|
||||
>
|
||||
<p class="font-medium text-sm text-base-content">
|
||||
{{ field.name || field.key }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/70 mt-1">
|
||||
Type : {{ field.type || 'text' }}<span v-if="field.required"> • Obligatoire</span>
|
||||
<span v-if="Array.isArray(field.options) && field.options.length">
|
||||
• Options : {{ field.options.join(', ') }}
|
||||
</span>
|
||||
<span v-if="field.defaultValue">
|
||||
• Défaut : {{ field.defaultValue }}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Custom fields: piece variant (simple display) -->
|
||||
<div v-if="variant === 'piece' && customFields.length" class="space-y-1">
|
||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-for="field in customFields" :key="field.name">
|
||||
<span class="font-medium">{{ field.name }}</span>
|
||||
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Pieces: component variant only -->
|
||||
<div v-if="variant === 'component' && pieces.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(piece, index) in pieces"
|
||||
:key="piece.role || piece.typePieceId || piece.familyCode || index"
|
||||
>
|
||||
{{ resolvePieceLabelFn(piece) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Products: component variant only -->
|
||||
<div v-if="variant === 'component' && products.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(product, index) in products"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabelFn(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Subcomponents: component variant only -->
|
||||
<div v-if="variant === 'component' && subcomponents.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(subcomponent, index) in subcomponents"
|
||||
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
|
||||
>
|
||||
{{ resolveSubcomponentLabelFn(subcomponent) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: component variant -->
|
||||
<p
|
||||
v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
||||
</p>
|
||||
|
||||
<!-- Empty state: piece variant -->
|
||||
<p v-if="variant === 'piece' && !customFields.length" class="text-xs text-base-content/70">
|
||||
Ce squelette ne définit pas encore de champs personnalisés.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getStructureCustomFields,
|
||||
getStructurePieces,
|
||||
getStructureProducts,
|
||||
getStructureSubcomponents,
|
||||
} from '~/shared/utils/structureDisplayUtils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
structure: Record<string, any> | null
|
||||
description?: string
|
||||
previewBadge: string
|
||||
variant: 'component' | 'piece'
|
||||
showEmptyState?: boolean
|
||||
resolvePieceLabel?: (piece: Record<string, any>) => string
|
||||
resolveProductLabel?: (product: Record<string, any>) => string
|
||||
resolveSubcomponentLabel?: (subcomponent: Record<string, any>) => string
|
||||
}>(), {
|
||||
description: '',
|
||||
showEmptyState: false,
|
||||
resolvePieceLabel: undefined,
|
||||
resolveProductLabel: undefined,
|
||||
resolveSubcomponentLabel: undefined,
|
||||
})
|
||||
|
||||
const customFields = computed(() =>
|
||||
getStructureCustomFields(props.structure),
|
||||
)
|
||||
|
||||
const pieces = computed(() =>
|
||||
props.variant === 'component' ? getStructurePieces(props.structure) : [],
|
||||
)
|
||||
|
||||
const products = computed(() =>
|
||||
props.variant === 'component' ? getStructureProducts(props.structure) : [],
|
||||
)
|
||||
|
||||
const subcomponents = computed(() =>
|
||||
props.variant === 'component' ? getStructureSubcomponents(props.structure) : [],
|
||||
)
|
||||
|
||||
const fallbackLabel = (item: Record<string, any>) =>
|
||||
item?.name || item?.label || item?.role || item?.alias || 'N/A'
|
||||
|
||||
const resolvePieceLabelFn = (piece: Record<string, any>) =>
|
||||
props.resolvePieceLabel ? props.resolvePieceLabel(piece) : fallbackLabel(piece)
|
||||
|
||||
const resolveProductLabelFn = (product: Record<string, any>) =>
|
||||
props.resolveProductLabel ? props.resolveProductLabel(product) : fallbackLabel(product)
|
||||
|
||||
const resolveSubcomponentLabelFn = (subcomponent: Record<string, any>) =>
|
||||
props.resolveSubcomponentLabel ? props.resolveSubcomponentLabel(subcomponent) : fallbackLabel(subcomponent)
|
||||
</script>
|
||||
@@ -23,7 +23,7 @@
|
||||
@blur="onBlur"
|
||||
@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 }}
|
||||
</p>
|
||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||
|
||||
108
app/components/home/AddMachineModal.vue
Normal file
108
app/components/home/AddMachineModal.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Ajouter une nouvelle machine
|
||||
</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom de la machine</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Ex: Presse hydraulique #1"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Site</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="form.siteId"
|
||||
class="select select-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner un site
|
||||
</option>
|
||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.reference"
|
||||
type="text"
|
||||
placeholder="Ex: PRESS-001"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||
Créer la machine
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
sites: Array<{ id: string, name: string }>
|
||||
disabled: boolean
|
||||
preselectedSiteId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
create: [data: { name: string, siteId: string, reference: string }]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
reference: '',
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
emit('create', { ...form })
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen && props.preselectedSiteId) {
|
||||
form.siteId = props.preselectedSiteId
|
||||
}
|
||||
if (!isOpen) {
|
||||
form.name = ''
|
||||
form.siteId = ''
|
||||
form.reference = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
78
app/components/home/AddSiteModal.vue
Normal file
78
app/components/home/AddSiteModal.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Ajouter un nouveau site
|
||||
</h3>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du site</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Ex: Usine de production"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<SiteContactFormFields :form="form" :disabled="disabled" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||
Créer le site
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
create: [data: { name: string, contactName: string, contactPhone: string, contactAddress: string, contactPostalCode: string, contactCity: string }]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactAddress: '',
|
||||
contactPostalCode: '',
|
||||
contactCity: '',
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
emit('create', { ...form })
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
form.name = ''
|
||||
form.contactName = ''
|
||||
form.contactPhone = ''
|
||||
form.contactAddress = ''
|
||||
form.contactPostalCode = ''
|
||||
form.contactCity = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +1,28 @@
|
||||
<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">
|
||||
<!-- Mobile hamburger menu -->
|
||||
<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" />
|
||||
</div>
|
||||
<ul
|
||||
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">
|
||||
<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')"
|
||||
>
|
||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||
@@ -24,9 +34,10 @@
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
@@ -39,16 +50,19 @@
|
||||
>
|
||||
<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)"
|
||||
:aria-expanded="openDropdown === group.id + '-mobile'"
|
||||
@click="toggleDropdown(group.id + '-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
>
|
||||
<span>{{ group.label }}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ group.label }}
|
||||
</span>
|
||||
<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' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -56,12 +70,12 @@
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
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">
|
||||
<NuxtLink
|
||||
: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)"
|
||||
>
|
||||
{{ child.label }}
|
||||
@@ -77,32 +91,31 @@
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="avatar">
|
||||
<div class="w-14">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink to="/" class="flex items-center gap-2.5 group">
|
||||
<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">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Desktop navbar -->
|
||||
<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 -->
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
@@ -119,29 +132,30 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 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)"
|
||||
:aria-expanded="openDropdown === group.id + '-desktop'"
|
||||
@click="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
>
|
||||
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ group.label }}
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === group.id + '-desktop' ? 'rotate-90' : ''"
|
||||
<IconLucideChevronDown
|
||||
class="h-3.5 w-3.5 transition-transform duration-200"
|
||||
:class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
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">
|
||||
<NuxtLink
|
||||
: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)"
|
||||
>
|
||||
{{ child.label }}
|
||||
@@ -158,13 +172,21 @@
|
||||
|
||||
<!-- Navbar end -->
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<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"
|
||||
@click="$emit('open-settings')"
|
||||
>
|
||||
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
|
||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<ClientOnly>
|
||||
@@ -172,7 +194,7 @@
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar placeholder indicator"
|
||||
class="indicator cursor-pointer"
|
||||
>
|
||||
<span
|
||||
v-if="unresolvedCount > 0"
|
||||
@@ -181,47 +203,49 @@
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
<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
|
||||
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
|
||||
>
|
||||
<span class="text-xs font-semibold">
|
||||
{{ activeProfileInitials }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
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">
|
||||
Connecté en tant que<br />
|
||||
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
||||
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||
<li class="px-3 py-2">
|
||||
<div class="flex flex-col gap-1 pointer-events-none">
|
||||
<span class="text-xs text-base-content/50">Connecté en tant que</span>
|
||||
<span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
|
||||
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<div class="divider my-0.5 px-2" />
|
||||
<li v-if="isAdmin">
|
||||
<NuxtLink to="/admin" class="justify-between">
|
||||
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
|
||||
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>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/comments" class="justify-between">
|
||||
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
|
||||
Commentaires
|
||||
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||
{{ unresolvedCount }}
|
||||
</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>
|
||||
</li>
|
||||
<div class="divider my-0.5 px-2" />
|
||||
<li>
|
||||
<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')"
|
||||
>
|
||||
Déconnexion
|
||||
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" />
|
||||
<IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -233,7 +257,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
@@ -242,7 +266,17 @@ import { useComments } from '~/composables/useComments'
|
||||
import IconLucideMenu from '~icons/lucide/menu'
|
||||
import IconLucideSettings from '~icons/lucide/settings'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
|
||||
import IconLucideCpu from '~icons/lucide/cpu'
|
||||
import IconLucidePuzzle from '~icons/lucide/puzzle'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
import IconLucideLink from '~icons/lucide/link'
|
||||
import IconLucideSun from '~icons/lucide/sun'
|
||||
import IconLucideMoon from '~icons/lucide/moon'
|
||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||
|
||||
defineEmits<{
|
||||
@@ -253,25 +287,37 @@ defineEmits<{
|
||||
interface NavLink {
|
||||
to: string
|
||||
label: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component
|
||||
activePaths: string[]
|
||||
children: NavLink[]
|
||||
}
|
||||
|
||||
const simpleLinks: NavLink[] = [
|
||||
{ to: '/', label: 'Vue d\'ensemble' },
|
||||
{ to: '/machines', label: 'Parc Machines' },
|
||||
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
|
||||
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composants',
|
||||
icon: IconLucideCpu,
|
||||
activePaths: ['/component-category', '/component-catalog'],
|
||||
children: [
|
||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pieces',
|
||||
label: 'Pièces',
|
||||
icon: IconLucidePuzzle,
|
||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||
children: [
|
||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||
@@ -281,24 +327,17 @@ const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Produits',
|
||||
icon: IconLucidePackage,
|
||||
activePaths: ['/product-category', '/product-catalog'],
|
||||
children: [
|
||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composant',
|
||||
activePaths: ['/component-category', '/component-catalog'],
|
||||
children: [
|
||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
icon: IconLucideLink,
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
@@ -315,6 +354,7 @@ const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = use
|
||||
const { activeProfile } = useProfileSession()
|
||||
const { isAdmin, canEdit } = usePermissions()
|
||||
const { fetchUnresolvedCount } = useComments()
|
||||
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
||||
|
||||
const unresolvedCount = ref(0)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
@@ -325,6 +365,7 @@ const refreshUnresolvedCount = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initDarkMode()
|
||||
refreshUnresolvedCount()
|
||||
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||
})
|
||||
@@ -347,19 +388,19 @@ const isGroupActive = (group: NavGroup) => {
|
||||
const linkClass = (link: NavLink) => {
|
||||
return isActive(link.to)
|
||||
? '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) => {
|
||||
return isGroupActive(group)
|
||||
? '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) => {
|
||||
return isActive(child.to)
|
||||
? '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(() => {
|
||||
@@ -400,12 +441,12 @@ const activeProfileInitials = computed(() => {
|
||||
.nav-dropdown-desktop-enter-from,
|
||||
.nav-dropdown-desktop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
.nav-dropdown-desktop-enter-to,
|
||||
.nav-dropdown-desktop-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.nav-dropdown-mobile-enter-active,
|
||||
|
||||
207
app/components/machine/AddEntityToMachineModal.vue
Normal file
207
app/components/machine/AddEntityToMachineModal.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box max-w-xl w-full" style="overflow: visible">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
|
||||
@click="handleClose"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h3 class="font-bold text-lg mb-6">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- Step 1: Choose category -->
|
||||
<div class="form-control mb-5" style="position: relative; z-index: 20">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">Catégorie</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedTypeId"
|
||||
:options="types"
|
||||
:loading="loadingTypes"
|
||||
:max-visible="8"
|
||||
placeholder="Rechercher une catégorie..."
|
||||
empty-text="Aucune catégorie disponible"
|
||||
:option-label="(t: any) => t.name"
|
||||
:option-description="(t: any) => t.code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Choose entity (visible only after category selected) -->
|
||||
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">{{ entityLabel }}</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedEntityId"
|
||||
:options="entities"
|
||||
:loading="loadingEntities"
|
||||
:max-visible="8"
|
||||
:placeholder="`Rechercher ${entityLabelLower}...`"
|
||||
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
||||
:option-label="entityOptionLabel"
|
||||
:option-description="entityOptionDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary of selection -->
|
||||
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
|
||||
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
|
||||
Réf : {{ selectedEntitySummary.reference }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
|
||||
<button type="button" class="btn btn-ghost" @click="handleClose">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedEntityId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
|
||||
type EntityKind = 'component' | 'piece' | 'product'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityKind: EntityKind
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [entityId: string]
|
||||
}>()
|
||||
|
||||
const selectedTypeId = ref('')
|
||||
const selectedEntityId = ref('')
|
||||
const loadingEntities = ref(false)
|
||||
const entities = ref<any[]>([])
|
||||
|
||||
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
|
||||
const { loadComposants } = useComposants()
|
||||
const { loadPieces } = usePieces()
|
||||
const { loadProducts } = useProducts()
|
||||
|
||||
const title = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Ajouter un composant',
|
||||
piece: 'Ajouter une pièce',
|
||||
product: 'Ajouter un produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabel = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Composant',
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
|
||||
|
||||
const types = computed(() => {
|
||||
if (props.entityKind === 'component') return componentTypes.value
|
||||
if (props.entityKind === 'piece') return pieceTypes.value
|
||||
return productTypes.value
|
||||
})
|
||||
|
||||
const loadingTypes = computed(() => {
|
||||
if (props.entityKind === 'component') return loadingComponentTypes.value
|
||||
if (props.entityKind === 'piece') return loadingPieceTypes.value
|
||||
return loadingProductTypes.value
|
||||
})
|
||||
|
||||
const selectedTypeName = computed(() => {
|
||||
if (!selectedTypeId.value) return ''
|
||||
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
|
||||
return found?.name || ''
|
||||
})
|
||||
|
||||
const entityOptionLabel = (e: any) => e.name || '(sans nom)'
|
||||
const entityOptionDescription = (e: any) => e.reference || ''
|
||||
|
||||
const selectedEntitySummary = computed(() => {
|
||||
if (!selectedEntityId.value || !entities.value.length) return null
|
||||
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
|
||||
if (!found) return null
|
||||
return { name: found.name || '(sans nom)', reference: found.reference || null }
|
||||
})
|
||||
|
||||
// Load types when modal opens
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
if (props.entityKind === 'component') await loadComponentTypes()
|
||||
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||
else await loadProductTypes()
|
||||
})
|
||||
|
||||
// Load entities when type changes
|
||||
watch(selectedTypeId, async () => {
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
|
||||
if (!selectedTypeName.value) return
|
||||
|
||||
loadingEntities.value = true
|
||||
try {
|
||||
if (props.entityKind === 'component') {
|
||||
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else if (props.entityKind === 'piece') {
|
||||
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else {
|
||||
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
}
|
||||
} finally {
|
||||
loadingEntities.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedEntityId.value) return
|
||||
emit('confirm', selectedEntityId.value)
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
selectedTypeId.value = ''
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Composants</h2>
|
||||
@@ -20,15 +20,32 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ComponentHierarchy
|
||||
:components="components"
|
||||
:is-edit-mode="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-component', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
/>
|
||||
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucun composant associé à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="component in components" :key="component.id">
|
||||
<ComponentHierarchy
|
||||
:components="[component]"
|
||||
:is-edit-mode="false"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-component', component.linkId || component.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-component')"
|
||||
>
|
||||
Ajouter un composant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,5 +66,7 @@ defineEmits<{
|
||||
'update-component': [component: any]
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-component': []
|
||||
'remove-component': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
124
app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
124
app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Définitions des champs personnalisés
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="saving"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs" />
|
||||
Enregistrer les champs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
|
||||
</p>
|
||||
|
||||
<ul v-else class="space-y-2" role="list">
|
||||
<li
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.uid"
|
||||
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
|
||||
:class="reorderClass(index)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(index, $event)"
|
||||
@dragenter="onDragEnter(index)"
|
||||
@dragover.prevent="onDragEnter(index)"
|
||||
@drop.prevent="onDrop(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||
title="Réordonner"
|
||||
draggable="false"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-sm">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
<option value="number">
|
||||
Nombre
|
||||
</option>
|
||||
<option value="select">
|
||||
Liste
|
||||
</option>
|
||||
<option value="boolean">
|
||||
Oui/Non
|
||||
</option>
|
||||
<option value="date">
|
||||
Date
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-sm h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="$emit('remove-field', index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="$emit('add-field')">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MachineCustomFieldEditorField } from '~/composables/useMachineCustomFieldDefs'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
|
||||
defineProps<{
|
||||
fields: MachineCustomFieldEditorField[]
|
||||
saving: boolean
|
||||
reorderClass: (index: number) => string
|
||||
onDragStart: (index: number, event: DragEvent) => void
|
||||
onDragEnter: (index: number) => void
|
||||
onDrop: (index: number) => void
|
||||
onDragEnd: () => void
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'add-field': []
|
||||
'remove-field': [index: number]
|
||||
}>()
|
||||
</script>
|
||||
221
app/components/machine/MachineCustomFieldsCard.vue
Normal file
221
app/components/machine/MachineCustomFieldsCard.vue
Normal 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 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>
|
||||
@@ -4,25 +4,6 @@
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<div class="btn-group w-full max-w-xs print:hidden" data-print-hide>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="isDetailsView ? 'btn-primary' : 'btn-outline'"
|
||||
@click="$emit('change-view', 'details')"
|
||||
>
|
||||
Vue machine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="isSkeletonView ? 'btn-primary' : 'btn-outline'"
|
||||
:disabled="!hasSkeletonRequirements"
|
||||
@click="$emit('change-view', 'skeleton')"
|
||||
>
|
||||
Squelette
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||
<button
|
||||
@@ -43,7 +24,7 @@
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isDetailsView && !isEditMode"
|
||||
v-if="!isEditMode"
|
||||
@click="$emit('open-print')"
|
||||
type="button"
|
||||
class="btn btn-outline btn-secondary"
|
||||
@@ -60,18 +41,12 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucidePrinter from '~icons/lucide/printer'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
isDetailsView: boolean
|
||||
isSkeletonView: boolean
|
||||
isEditMode: boolean
|
||||
hasSkeletonRequirements: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'change-view': [view: 'details' | 'skeleton']
|
||||
'toggle-edit': []
|
||||
'open-print': []
|
||||
}>()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -32,8 +32,8 @@
|
||||
:class="documentThumbnailClass(doc)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(doc) && doc.path"
|
||||
:src="doc.path"
|
||||
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
|
||||
:src="doc.fileUrl || doc.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${doc.name}`"
|
||||
>
|
||||
@@ -74,7 +74,7 @@
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="uploading"
|
||||
@click="$emit('remove', doc.id)"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<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="form-control">
|
||||
<label class="label">
|
||||
@@ -20,6 +20,29 @@
|
||||
{{ machineName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Site</span>
|
||||
</label>
|
||||
<select
|
||||
v-if="isEditMode"
|
||||
:value="machineSiteId"
|
||||
class="select select-bordered"
|
||||
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"
|
||||
>
|
||||
<option value="">Sélectionner un site</option>
|
||||
<option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:value="site.id"
|
||||
>
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineSiteName || 'Non défini' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEditMode || machineReference" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
@@ -49,30 +72,30 @@
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
@update:modelValue="$emit('update:constructeur-ids', $event)"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
<div v-if="machineConstructeursDisplay.length" class="space-y-1">
|
||||
<div
|
||||
<div v-else class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||
<div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="constructeur in machineConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="flex flex-col"
|
||||
class="badge badge-ghost gap-1"
|
||||
>
|
||||
<span class="font-medium">{{ constructeur.name }}</span>
|
||||
{{ constructeur.name }}
|
||||
<span
|
||||
v-if="formatConstructeurContactSummary(constructeur)"
|
||||
class="text-xs text-gray-500"
|
||||
class="text-xs opacity-60"
|
||||
>
|
||||
{{ formatConstructeurContactSummary(constructeur) }}
|
||||
· {{ formatConstructeurContactSummary(constructeur) }}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="font-medium">Non défini</span>
|
||||
<span v-else class="text-base-content/50">Non défini</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs personnalisés -->
|
||||
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-gray-200">
|
||||
<h4 class="font-semibold text-gray-700 mb-3">Champs personnalisés de la machine</h4>
|
||||
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-base-200">
|
||||
<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
|
||||
v-for="field in visibleCustomFields"
|
||||
@@ -151,34 +174,69 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode" class="mt-6 pt-4 border-t border-base-200">
|
||||
<MachineCustomFieldDefEditor
|
||||
:fields="fieldDefs.fields.value"
|
||||
:saving="fieldDefs.saving.value"
|
||||
:reorder-class="fieldDefs.reorderClass"
|
||||
:on-drag-start="fieldDefs.onDragStart"
|
||||
:on-drag-enter="fieldDefs.onDragEnter"
|
||||
:on-drop="fieldDefs.onDrop"
|
||||
:on-drag-end="fieldDefs.onDragEnd"
|
||||
@save="fieldDefs.saveDefinitions()"
|
||||
@add-field="fieldDefs.addField()"
|
||||
@remove-field="fieldDefs.removeField($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isEditMode: boolean
|
||||
machineName: string
|
||||
machineReference: string
|
||||
machineSiteId: string
|
||||
machineSiteName: string
|
||||
sites: any[]
|
||||
machineConstructeurIds: string[]
|
||||
machineConstructeursDisplay: any[]
|
||||
hasMachineConstructeur: boolean
|
||||
visibleCustomFields: any[]
|
||||
getMachineFieldId: (fieldName: string) => string
|
||||
machineId: string
|
||||
machineCustomFieldDefs: any[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
'update:machine-name': [value: string]
|
||||
'update:machine-reference': [value: string]
|
||||
'update:machine-site-id': [value: string]
|
||||
'update:constructeur-ids': [ids: unknown]
|
||||
'blur-field': []
|
||||
'set-custom-field-value': [field: any, value: unknown]
|
||||
'update-custom-field': [field: any]
|
||||
'custom-fields-saved': []
|
||||
}>()
|
||||
|
||||
const fieldDefs = useMachineCustomFieldDefs({
|
||||
machineId: props.machineId,
|
||||
initialDefs: props.machineCustomFieldDefs,
|
||||
onSaved: () => emit('custom-fields-saved'),
|
||||
})
|
||||
|
||||
watch(() => props.machineCustomFieldDefs, (newDefs) => {
|
||||
fieldDefs.reinit(newDefs)
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
@@ -1,34 +1,71 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Pièces de la machine</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<PieceItem
|
||||
v-for="piece in pieces"
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode"
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
/>
|
||||
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucune pièce associée à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="piece in pieces" :key="piece.id">
|
||||
<PieceItem
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-piece', piece.linkId || piece.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-piece')"
|
||||
>
|
||||
Ajouter une pièce
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
|
||||
defineProps<{
|
||||
pieces: any[]
|
||||
isEditMode: boolean
|
||||
collapsed: boolean
|
||||
collapseToggleToken: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-collapse': []
|
||||
'update-piece': [piece: any]
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-piece': []
|
||||
'remove-piece': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="previewDocumentList"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Produits associés</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
Produits sélectionnés directement pour cette machine selon le squelette.
|
||||
Produits sélectionnés directement pour cette machine.
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline" v-if="products.length">
|
||||
@@ -17,15 +24,26 @@
|
||||
<div
|
||||
v-for="product in products"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-1"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<p class="font-semibold text-base-content">
|
||||
{{ product.name }}
|
||||
</p>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
title="Supprimer ce produit"
|
||||
@click="$emit('remove-product', (product.linkId || product.id) as string)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="product.reference" class="text-xs text-base-content/70">
|
||||
<span class="font-medium">Référence :</span>
|
||||
@@ -39,24 +57,119 @@
|
||||
<span class="font-medium">Prix indicatif :</span>
|
||||
<span class="ml-1">{{ product.priceLabel }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Documents liés au produit -->
|
||||
<div v-if="product.documents?.length" class="mt-2 space-y-1">
|
||||
<p class="text-xs font-medium text-base-content/70">Documents :</p>
|
||||
<div
|
||||
v-for="doc in product.documents"
|
||||
:key="doc.id || doc.name"
|
||||
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
|
||||
>
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-4 w-4"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-base-content">{{ doc.name }}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="openPreview(doc, product.documents || [])"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(doc)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Aucun produit n'a été associé directement à cette machine.
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-product')"
|
||||
>
|
||||
Ajouter un produit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
products: Array<{
|
||||
id?: string | null
|
||||
linkId?: string | null
|
||||
name?: string
|
||||
reference?: string | null
|
||||
supplierLabel?: string | null
|
||||
priceLabel?: string | null
|
||||
groupLabel?: string
|
||||
documents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
fileUrl?: string
|
||||
downloadUrl?: string
|
||||
}>
|
||||
}>
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-product': []
|
||||
'remove-product': [linkId: string]
|
||||
}>()
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewDocumentList = ref<any[]>([])
|
||||
|
||||
const openPreview = (doc: any, docs: any[]) => {
|
||||
previewDocument.value = doc
|
||||
previewDocumentList.value = docs
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="componentRequirementGroups.length || pieceRequirementGroups.length || productRequirementGroups.length"
|
||||
class="card bg-base-100 shadow-lg"
|
||||
>
|
||||
<div class="card-body space-y-6">
|
||||
<div>
|
||||
<h2 class="card-title">Structure sélectionnée</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Synthèse des familles définies dans le type et des modèles utilisés pour cette machine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Component requirement groups -->
|
||||
<div v-if="componentRequirementGroups.length" class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Composants</h3>
|
||||
<div
|
||||
v-for="group in componentRequirementGroups"
|
||||
:key="group.requirement.id"
|
||||
class="rounded-lg border border-base-200 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h4 class="font-medium text-sm">
|
||||
{{ group.requirement.label || group.requirement.typeComposant?.name || 'Famille de composants' }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ group.requirement.typeComposant?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline badge-sm">{{ group.components.length }} composant(s)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="group.components.length" class="space-y-2">
|
||||
<div
|
||||
v-for="component in group.components"
|
||||
:key="component.id"
|
||||
class="flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="font-medium">{{ component.name }}</span>
|
||||
<span v-if="component.parentComposantId" class="text-xs text-gray-500">
|
||||
(Sous-composant)
|
||||
</span>
|
||||
<div
|
||||
v-if="summarizeCustomFields(component.customFields || []).length"
|
||||
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
||||
>
|
||||
<span
|
||||
v-for="field in summarizeCustomFields(component.customFields || [])"
|
||||
:key="field.key"
|
||||
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
||||
>
|
||||
<span class="font-medium">{{ field.label }} :</span>
|
||||
<span class="ml-1">{{ field.value }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<SkeletonProductDisplay :product-display="component.__productDisplay" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">Aucun composant rattaché à ce groupe.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Piece requirement groups -->
|
||||
<div v-if="pieceRequirementGroups.length" class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Pièces principales</h3>
|
||||
<div
|
||||
v-for="group in pieceRequirementGroups"
|
||||
:key="group.requirement.id"
|
||||
class="rounded-lg border border-base-200 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h4 class="font-medium text-sm">
|
||||
{{ group.requirement.label || group.requirement.typePiece?.name || 'Groupe de pièces' }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ group.requirement.typePiece?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline badge-sm">{{ group.pieces.length }} pièce(s)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="group.pieces.length" class="space-y-2">
|
||||
<div
|
||||
v-for="piece in group.pieces"
|
||||
:key="piece.id"
|
||||
class="flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="font-medium">{{ piece.name }}</span>
|
||||
<span v-if="piece.parentComponentName" class="text-xs text-gray-500">
|
||||
(Rattachée à {{ piece.parentComponentName }})
|
||||
</span>
|
||||
<div
|
||||
v-if="summarizeCustomFields(piece.customFields || []).length"
|
||||
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
||||
>
|
||||
<span
|
||||
v-for="field in summarizeCustomFields(piece.customFields || [])"
|
||||
:key="field.key"
|
||||
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
||||
>
|
||||
<span class="font-medium">{{ field.label }} :</span>
|
||||
<span class="ml-1">{{ field.value }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<SkeletonProductDisplay :product-display="piece.__productDisplay" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">Aucune pièce rattachée à ce groupe.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product requirement groups -->
|
||||
<div v-if="productRequirementGroups.length" class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Produits requis</h3>
|
||||
<div
|
||||
v-for="group in productRequirementGroups"
|
||||
:key="group.requirement.id"
|
||||
class="rounded-lg border border-base-200 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h4 class="font-medium text-sm">
|
||||
{{ group.requirement.label || group.requirement.typeProduct?.name || 'Groupe de produits' }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
Catégorie : {{ group.requirement.typeProduct?.name || 'Non définie' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-outline badge-sm">Total {{ group.totalCount }}</span>
|
||||
<span class="badge badge-ghost badge-sm">Direct {{ group.directProducts.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 mb-3">
|
||||
Via composants : {{ group.componentCount }} • Via pièces : {{ group.pieceCount }}
|
||||
</div>
|
||||
|
||||
<div v-if="group.directProducts.length" class="space-y-2">
|
||||
<div
|
||||
v-for="product in group.directProducts"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm"
|
||||
>
|
||||
<div class="font-medium">{{ product.name }}</div>
|
||||
<div v-if="product.reference" class="text-xs text-gray-500">
|
||||
Référence : {{ product.reference }}
|
||||
</div>
|
||||
<div v-if="product.supplierLabel" class="text-xs text-gray-500">
|
||||
Fournisseurs : {{ product.supplierLabel }}
|
||||
</div>
|
||||
<div v-if="product.priceLabel" class="text-xs text-gray-500">
|
||||
Prix indicatif : {{ product.priceLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Aucune sélection directe. Couverture assurée via composants ou pièces associés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { summarizeCustomFields } from '~/shared/utils/customFieldUtils'
|
||||
|
||||
defineProps<{
|
||||
componentRequirementGroups: any[]
|
||||
pieceRequirementGroups: any[]
|
||||
productRequirementGroups: any[]
|
||||
}>()
|
||||
|
||||
const SkeletonProductDisplay = defineComponent({
|
||||
name: 'SkeletonProductDisplay',
|
||||
props: {
|
||||
productDisplay: { type: Object, default: null },
|
||||
},
|
||||
template: `
|
||||
<div v-if="productDisplay" class="w-full text-xs text-gray-600 space-y-1">
|
||||
<div><span class="font-medium">Produit :</span> <span>{{ productDisplay.name }}</span></div>
|
||||
<div v-if="productDisplay.category"><span class="font-medium">Catégorie :</span> <span>{{ productDisplay.category }}</span></div>
|
||||
<div v-if="productDisplay.reference"><span class="font-medium">Référence :</span> <span>{{ productDisplay.reference }}</span></div>
|
||||
<div v-if="productDisplay.suppliers"><span class="font-medium">Fournisseurs :</span> <span>{{ productDisplay.suppliers }}</span></div>
|
||||
<div v-if="productDisplay.price"><span class="font-medium">Prix indicatif :</span> <span>{{ productDisplay.price }}</span></div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
</script>
|
||||
@@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<div v-if="preview" class="space-y-4">
|
||||
<div class="border border-base-200 rounded-lg bg-base-100/80">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<IconLucideEye class="w-4 h-4" aria-hidden="true" />
|
||||
<span>Prévisualisation avant création</span>
|
||||
</div>
|
||||
<span class="badge" :class="getStatusBadgeClass(preview.status)">
|
||||
{{ preview.status === 'ready' ? 'Prête à créer' : preview.status === 'warning' ? 'À compléter' : 'Bloquante' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="field in preview.base.fields"
|
||||
:key="field.key"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<span class="text-[11px] uppercase tracking-wide text-gray-500">{{ field.label }}</span>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="field.status === 'missing'
|
||||
? 'text-error'
|
||||
: field.status === 'optional'
|
||||
? 'text-gray-500 italic'
|
||||
: 'text-gray-900'"
|
||||
>
|
||||
{{ field.display }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<span class="badge badge-ghost badge-sm">Type : {{ preview.type.name }}</span>
|
||||
<span v-if="preview.type.category" class="badge badge-ghost badge-sm">Catégorie : {{ preview.type.category }}</span>
|
||||
<span class="badge badge-ghost badge-sm">Structure JSON : {{ preview.type.hasStructuredDefinition ? 'Oui' : 'Legacy' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Base issues -->
|
||||
<div v-if="preview.base.issues.length" class="rounded-md bg-warning/10 border border-warning/30 p-3 text-xs text-warning">
|
||||
<p class="font-medium mb-1">
|
||||
Informations générales incomplètes :
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="issue in preview.base.issues" :key="issue.message">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 text-left hover:underline"
|
||||
@click="handleIssueClick(issue)"
|
||||
>
|
||||
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
||||
<span>{{ issue.message }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Component groups -->
|
||||
<div v-if="preview.componentGroups.length" class="space-y-3">
|
||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Composants hérités
|
||||
</h5>
|
||||
<PreviewRequirementGroup
|
||||
v-for="group in preview.componentGroups"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500">
|
||||
Aucun composant n'est requis pour ce type de machine.
|
||||
</div>
|
||||
|
||||
<!-- Piece groups -->
|
||||
<div v-if="preview.pieceGroups.length" class="space-y-3">
|
||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Pièces associées
|
||||
</h5>
|
||||
<PreviewRequirementGroup
|
||||
v-for="group in preview.pieceGroups"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500">
|
||||
Aucun groupe de pièces à configurer pour ce type.
|
||||
</div>
|
||||
|
||||
<!-- Product groups -->
|
||||
<div v-if="preview.productGroups.length" class="space-y-3">
|
||||
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Produits requis
|
||||
</h5>
|
||||
<div
|
||||
v-for="group in preview.productGroups"
|
||||
:key="group.id"
|
||||
:id="`product-group-${group.id}`"
|
||||
class="border border-base-200 rounded-md p-3 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">
|
||||
{{ group.label }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
|
||||
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
||||
Couverture : {{ group.count }}
|
||||
</span>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
Direct {{ group.completed }} / {{ group.total || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<li v-for="issue in group.issues" :key="issue.message">
|
||||
{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul v-if="group.entries?.length" class="space-y-2">
|
||||
<li
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.key"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<component
|
||||
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
||||
class="w-4 h-4 mt-0.5"
|
||||
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
||||
{{ entry.title }}
|
||||
</p>
|
||||
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
||||
{{ entry.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Couverture assurée via composants ou pièces liés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global issues -->
|
||||
<div
|
||||
v-if="preview.issues.length && preview.status !== 'ready'"
|
||||
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<IconLucideAlertTriangle class="w-4 h-4 mt-0.5" aria-hidden="true" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium">
|
||||
Points à vérifier avant la création :
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="issue in preview.issues" :key="`${issue.scope}-${issue.message}`">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 text-left hover:underline"
|
||||
@click="handleIssueClick(issue)"
|
||||
>
|
||||
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
||||
<span>
|
||||
<span class="font-medium">{{ issue.scope }} :</span> {{ issue.message }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getStatusBadgeClass,
|
||||
handleIssueClick,
|
||||
} from '~/composables/useMachineCreatePreview'
|
||||
import PreviewRequirementGroup from './PreviewRequirementGroup.vue'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||
import IconLucideCircle from '~icons/lucide/circle'
|
||||
|
||||
defineProps<{
|
||||
preview: any
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div class="border border-base-200 rounded-md p-3 space-y-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">
|
||||
{{ group.label }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ group.typeName }} · Min {{ group.min }} ·
|
||||
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
||||
{{ group.completed }} / {{ group.total || 0 }} complétée(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<li v-for="issue in group.issues" :key="issue.message">
|
||||
{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.key"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<component
|
||||
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
||||
class="w-4 h-4 mt-0.5"
|
||||
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
||||
{{ entry.title }}
|
||||
</p>
|
||||
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
||||
{{ entry.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getStatusBadgeClass } from '~/composables/useMachineCreatePreview'
|
||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||
import IconLucideCircle from '~icons/lucide/circle'
|
||||
|
||||
defineProps<{
|
||||
group: any
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div v-if="requirements?.length" class="space-y-4">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Sélection des composants
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-for="requirement in requirements"
|
||||
:id="`component-group-${requirement.id}`"
|
||||
:key="requirement.id"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h5 class="font-medium text-sm">
|
||||
{{ requirement.label || requirement.typeComposant?.name || 'Famille de composants' }}
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ requirement.typeComposant?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||
@click="$emit('add-entry', requirement)"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||
Aucun composant sélectionné pour ce groupe.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||
:key="`${requirement.id}-${entryIndex}`"
|
||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
Type appliqué :
|
||||
{{ resolveTypeLabel(requirement, entry) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-xs btn-error"
|
||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Composant existant</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
:model-value="entry.composantId || ''"
|
||||
:options="getOptions(requirement, entry)"
|
||||
:loading="loading"
|
||||
size="sm"
|
||||
placeholder="Rechercher un composant…"
|
||||
empty-text="Aucun composant disponible"
|
||||
:option-label="optionLabel"
|
||||
:option-description="optionDescription"
|
||||
@update:modelValue="$emit('set-component', requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="getOptions(requirement, entry).length === 0"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Aucun composant disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entry.composantId"
|
||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ findById(entry.composantId)?.name || "Composant" }}
|
||||
</div>
|
||||
<div>
|
||||
Référence : {{ findById(entry.composantId)?.reference || "—" }}
|
||||
</div>
|
||||
<div>
|
||||
Fournisseur :
|
||||
{{ findById(entry.composantId)?.constructeur?.name || findById(entry.composantId)?.constructeurName || "—" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
defineProps<{
|
||||
requirements: any[]
|
||||
loading: boolean
|
||||
getEntries: (requirementId: string) => any[]
|
||||
getOptions: (requirement: any, entry: any) => any[]
|
||||
resolveTypeLabel: (requirement: any, entry: any) => string
|
||||
findById: (id: string) => any
|
||||
optionLabel: (item: any) => string
|
||||
optionDescription: (item: any) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-entry': [requirement: any]
|
||||
'remove-entry': [requirementId: string, entryIndex: number]
|
||||
'set-component': [requirement: any, entryIndex: number, componentId: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,130 +0,0 @@
|
||||
<template>
|
||||
<div v-if="requirements?.length" class="space-y-4">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Sélection des pièces principales
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-for="requirement in requirements"
|
||||
:id="`piece-group-${requirement.id}`"
|
||||
:key="requirement.id"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h5 class="font-medium text-sm">
|
||||
{{ requirement.label || requirement.typePiece?.name || 'Groupe de pièces' }}
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Type : {{ requirement.typePiece?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||
@click="$emit('add-entry', requirement)"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||
Aucune pièce sélectionnée pour ce groupe.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||
:key="`${requirement.id}-piece-${entryIndex}`"
|
||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
Type appliqué :
|
||||
{{ resolveTypeLabel(requirement, entry) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-xs btn-error"
|
||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Pièce existante</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
:model-value="entry.pieceId || ''"
|
||||
:options="getOptions(requirement, entry, entryIndex)"
|
||||
:loading="loading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
|
||||
size="sm"
|
||||
placeholder="Rechercher une pièce…"
|
||||
empty-text="Aucune pièce disponible"
|
||||
:option-label="optionLabel"
|
||||
:option-description="optionDescription"
|
||||
@search="(term: string) => $emit('search', requirement, entryIndex, term)"
|
||||
@update:modelValue="$emit('set-piece', requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="getOptions(requirement, entry, entryIndex).length === 0"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Aucune pièce disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entry.pieceId"
|
||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ findById(entry.pieceId)?.name || "Pièce" }}
|
||||
</div>
|
||||
<div>
|
||||
Référence : {{ findById(entry.pieceId)?.reference || "—" }}
|
||||
</div>
|
||||
<div>
|
||||
Fournisseur :
|
||||
{{ findById(entry.pieceId)?.constructeur?.name || findById(entry.pieceId)?.constructeurName || "—" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
defineProps<{
|
||||
requirements: any[]
|
||||
loading: boolean
|
||||
pieceLoadingByKey: Record<string, boolean>
|
||||
getEntries: (requirementId: string) => any[]
|
||||
getOptions: (requirement: any, entry: any, entryIndex: number) => any[]
|
||||
getPieceKey: (requirement: any, entryIndex: number) => string
|
||||
resolveTypeLabel: (requirement: any, entry: any) => string
|
||||
findById: (id: string) => any
|
||||
optionLabel: (item: any) => string
|
||||
optionDescription: (item: any) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-entry': [requirement: any]
|
||||
'remove-entry': [requirementId: string, entryIndex: number]
|
||||
'set-piece': [requirement: any, entryIndex: number, pieceId: string]
|
||||
'search': [requirement: any, entryIndex: number, term: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div v-if="requirements?.length" class="space-y-4">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Produits catalogue requis
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-for="requirement in requirements"
|
||||
:id="`product-group-${requirement.id}`"
|
||||
:key="requirement.id"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h5 class="font-medium text-sm">
|
||||
{{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500">
|
||||
Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="(requirement.allowNewModels ?? true) === false"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Sélection de produits existants uniquement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||
@click="$emit('add-entry', requirement)"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||
Aucun produit sélectionné pour ce groupe.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||
:key="`${requirement.id}-product-${entryIndex}`"
|
||||
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
Catégorie appliquée :
|
||||
{{ requirement.typeProduct?.name || 'Non définie' }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-xs btn-error"
|
||||
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Produit existant</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="entry.productId || ''"
|
||||
:type-product-id="requirement.typeProductId || requirement.typeProduct?.id || null"
|
||||
:placeholder="productsLoading ? 'Chargement…' : 'Sélectionner un produit…'"
|
||||
empty-text="Aucun produit disponible pour cette catégorie"
|
||||
:disabled="productsLoading"
|
||||
@update:modelValue="$emit('set-product', requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="!productsLoading && getProductOptions(requirement).length === 0"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="entry.productId"
|
||||
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ findById(entry.productId)?.name || 'Produit' }}
|
||||
</div>
|
||||
<div>
|
||||
Référence : {{ findById(entry.productId)?.reference || "—" }}
|
||||
</div>
|
||||
<div>
|
||||
Prix indicatif :
|
||||
<span
|
||||
v-if="findById(entry.productId)?.supplierPrice !== undefined && findById(entry.productId)?.supplierPrice !== null"
|
||||
>
|
||||
{{ Number(findById(entry.productId)?.supplierPrice).toFixed(2) }} €
|
||||
</span>
|
||||
<span v-else>
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Fournisseurs :
|
||||
<span v-if="findById(entry.productId)?.constructeurs?.length">
|
||||
{{ findById(entry.productId)?.constructeurs.map((constructeur: any) => constructeur?.name).filter(Boolean).join(', ') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
defineProps<{
|
||||
requirements: any[]
|
||||
productsLoading: boolean
|
||||
getEntries: (requirementId: string) => any[]
|
||||
getProductOptions: (requirement: any) => any[]
|
||||
findById: (id: string) => any
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-entry': [requirement: any]
|
||||
'remove-entry': [requirementId: string, entryIndex: number]
|
||||
'set-product': [requirement: any, entryIndex: number, productId: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -9,137 +9,151 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ModelTypesToolbar
|
||||
:category="selectedCategory"
|
||||
:search="searchInput"
|
||||
:sort="sort"
|
||||
:dir="dir"
|
||||
:loading="loading"
|
||||
:show-category-tabs="allowCategorySwitch"
|
||||
:can-edit="canEdit"
|
||||
@update:category="onCategoryChange"
|
||||
@update:search="onSearchInput"
|
||||
@update:sort="onSortChange"
|
||||
@update:dir="onDirChange"
|
||||
@create="openCreatePage"
|
||||
/>
|
||||
<nav
|
||||
v-if="allowCategorySwitch"
|
||||
class="tabs tabs-boxed inline-flex"
|
||||
role="tablist"
|
||||
aria-label="Catégories"
|
||||
>
|
||||
<button
|
||||
v-for="option in categories"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': option.value === selectedCategory }"
|
||||
role="tab"
|
||||
:aria-selected="option.value === selectedCategory"
|
||||
@click="onCategoryChange(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<ModelTypesTable
|
||||
:items="items"
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="items"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
:category="selectedCategory"
|
||||
:can-edit="canEdit"
|
||||
@related="openRelatedModal"
|
||||
@edit="openEditPage"
|
||||
@delete="confirmDelete"
|
||||
@convert="openConversionModal"
|
||||
@update:offset="onOffsetChange"
|
||||
/>
|
||||
:sort="currentSort"
|
||||
:pagination="paginationState"
|
||||
:show-per-page="true"
|
||||
row-key="id"
|
||||
empty-message="Aucune catégorie trouvée."
|
||||
no-results-message="Aucune catégorie ne correspond à votre recherche."
|
||||
@sort="handleSort"
|
||||
@update:current-page="handlePageChange"
|
||||
@update:per-page="handlePerPageChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
class="grow min-w-0"
|
||||
placeholder="Rechercher par nom…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<ModelTypesConversionModal
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="loading"
|
||||
@click="openCreatePage"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
||||
Créer
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
<span class="font-medium">{{ row.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-notes="{ row }">
|
||||
<span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-warning"
|
||||
@click="openConversionModal(row)"
|
||||
>
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||
Éditer
|
||||
</button>
|
||||
<button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ConversionModal
|
||||
:open="conversionModalOpen"
|
||||
:model-type="conversionTarget"
|
||||
@close="closeConversionModal"
|
||||
@converted="onConverted"
|
||||
/>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
{{ relatedModalTitle }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ relatedModalSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Chargement des éléments liés…
|
||||
</div>
|
||||
|
||||
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
|
||||
{{ relatedError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="relatedItems.length === 0"
|
||||
class="px-4 py-6 text-sm text-base-content/60"
|
||||
>
|
||||
Aucun élément lié à cette catégorie.
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||
<li
|
||||
v-for="entry in relatedItems"
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@click="openRelatedEdit(entry)"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeRelatedModal">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<RelatedItemsModal
|
||||
:open="relatedModalOpen"
|
||||
:model-type="relatedType"
|
||||
@close="relatedModalOpen = false"
|
||||
@open-edit="openRelatedEdit"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
|
||||
import { useHead, useRouter } from "#imports";
|
||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import { useUrlState } from "~/composables/useUrlState";
|
||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||
import {
|
||||
deleteModelType,
|
||||
listModelTypes,
|
||||
type ModelCategory,
|
||||
type ModelType,
|
||||
type ModelTypeListResponse,
|
||||
} from "~/services/modelTypes";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
||||
} from '~/services/modelTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import IconLucideSearch from '~icons/lucide/search'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
||||
const DEFAULT_DESCRIPTION
|
||||
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
category: ModelCategory;
|
||||
heading: string;
|
||||
description?: string;
|
||||
allowCategorySwitch?: boolean;
|
||||
category: ModelCategory
|
||||
heading: string
|
||||
description?: string
|
||||
allowCategorySwitch?: boolean
|
||||
}>(),
|
||||
{
|
||||
allowCategorySwitch: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
const selectedCategory = ref<ModelCategory>(props.category);
|
||||
const searchInput = ref("");
|
||||
const selectedCategory = ref<ModelCategory>(props.category)
|
||||
const searchInput = ref('')
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
// State synced with URL query params
|
||||
const urlState = useUrlState({
|
||||
q: { default: '' },
|
||||
sort: { default: 'name' },
|
||||
@@ -148,81 +162,125 @@ const urlState = useUrlState({
|
||||
offset: { default: 0, type: 'number' },
|
||||
}, {
|
||||
onRestore: () => {
|
||||
searchInput.value = urlState.q.value;
|
||||
refresh();
|
||||
searchInput.value = urlState.q.value
|
||||
doRefresh()
|
||||
},
|
||||
});
|
||||
const searchTerm = urlState.q;
|
||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
|
||||
const dir = urlState.dir as Ref<'asc' | 'desc'>;
|
||||
const limit = urlState.limit;
|
||||
const offset = urlState.offset;
|
||||
})
|
||||
const searchTerm = urlState.q
|
||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>
|
||||
const dir = urlState.dir as Ref<'asc' | 'desc'>
|
||||
const limit = urlState.limit
|
||||
const offset = urlState.offset
|
||||
|
||||
// Initialize searchInput from URL (for direct navigation with ?q=...)
|
||||
searchInput.value = searchTerm.value;
|
||||
// Initialize searchInput from URL
|
||||
searchInput.value = searchTerm.value
|
||||
|
||||
const items = ref<ModelType[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const items = ref<ModelType[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeController: AbortController | null = null;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activeController: AbortController | null = null
|
||||
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const { get } = useApi();
|
||||
const { canEdit } = usePermissions();
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const headingText = computed(() => props.heading);
|
||||
const descriptionText = computed(
|
||||
() => props.description ?? DEFAULT_DESCRIPTION
|
||||
);
|
||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
|
||||
const headingText = computed(() => props.heading)
|
||||
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
|
||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
|
||||
|
||||
useHead(() => ({
|
||||
title: headingText.value,
|
||||
}));
|
||||
useHead(() => ({ title: headingText.value }))
|
||||
|
||||
const extractErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object") {
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||
]
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
||||
)
|
||||
|
||||
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||
{ label: 'Composants', value: 'COMPONENT' },
|
||||
{ label: 'Pièces', value: 'PIECE' },
|
||||
{ label: 'Produits', value: 'PRODUCT' },
|
||||
]
|
||||
|
||||
// Sort state for DataTable
|
||||
const currentSort = computed<DataTableSort>(() => ({
|
||||
field: sort.value,
|
||||
direction: dir.value,
|
||||
}))
|
||||
|
||||
const handleSort = (newSort: DataTableSort) => {
|
||||
sort.value = newSort.field as 'name' | 'createdAt'
|
||||
dir.value = newSort.direction as 'asc' | 'desc'
|
||||
offset.value = 0
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
// Pagination: convert offset/limit to page-based for DataTable
|
||||
const currentPage = computed(() => {
|
||||
if (limit.value <= 0) return 1
|
||||
return Math.floor(offset.value / limit.value) + 1
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (limit.value <= 0) return 1
|
||||
return Math.max(1, Math.ceil(total.value / limit.value))
|
||||
})
|
||||
|
||||
const paginationState = computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: totalPages.value,
|
||||
totalItems: total.value,
|
||||
pageItems: items.value.length,
|
||||
perPageOptions: [20, 50, 100],
|
||||
perPage: limit.value,
|
||||
}))
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
offset.value = (page - 1) * limit.value
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
const handlePerPageChange = (perPage: number) => {
|
||||
limit.value = perPage
|
||||
offset.value = 0
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
const extractErrorMessage = (error: unknown): string => {
|
||||
let raw: string | null = null
|
||||
if (error && typeof error === 'object') {
|
||||
const maybeFetchError = error as {
|
||||
data?: Record<string, unknown>;
|
||||
statusMessage?: string;
|
||||
message?: string;
|
||||
};
|
||||
data?: Record<string, unknown>
|
||||
statusMessage?: string
|
||||
message?: string
|
||||
}
|
||||
if (maybeFetchError.data) {
|
||||
const data = maybeFetchError.data;
|
||||
if (typeof data.message === "string") {
|
||||
return data.message;
|
||||
}
|
||||
if (Array.isArray(data.message) && data.message.length > 0) {
|
||||
return data.message[0];
|
||||
}
|
||||
}
|
||||
if (typeof maybeFetchError.statusMessage === "string") {
|
||||
return maybeFetchError.statusMessage;
|
||||
}
|
||||
if (typeof maybeFetchError.message === "string") {
|
||||
return maybeFetchError.message;
|
||||
const data = maybeFetchError.data
|
||||
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||
else if (typeof data.detail === 'string') raw = data.detail
|
||||
else if (typeof data.message === 'string') raw = data.message
|
||||
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
|
||||
else if (typeof data.error === 'string') raw = data.error
|
||||
}
|
||||
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
|
||||
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
|
||||
}
|
||||
return "Une erreur est survenue lors de la communication avec le serveur.";
|
||||
};
|
||||
return humanizeError(raw)
|
||||
}
|
||||
|
||||
const refresh = async ({
|
||||
resetOffset = false,
|
||||
}: { resetOffset?: boolean } = {}) => {
|
||||
if (resetOffset) {
|
||||
offset.value = 0;
|
||||
}
|
||||
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
|
||||
if (resetOffset) offset.value = 0
|
||||
|
||||
if (activeController) {
|
||||
activeController.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
loading.value = true;
|
||||
if (activeController) activeController.abort()
|
||||
const controller = new AbortController()
|
||||
activeController = controller
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response: ModelTypeListResponse = await listModelTypes(
|
||||
@@ -234,312 +292,147 @@ const refresh = async ({
|
||||
limit: limit.value,
|
||||
offset: offset.value,
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
items.value = response.items;
|
||||
total.value = response.total;
|
||||
offset.value = response.offset;
|
||||
limit.value = response.limit;
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
showError(extractErrorMessage(error));
|
||||
} finally {
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
offset.value = response.offset
|
||||
limit.value = response.limit
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
finally {
|
||||
if (activeController === controller) {
|
||||
loading.value = false;
|
||||
activeController = null;
|
||||
loading.value = false
|
||||
activeController = null
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.category,
|
||||
(value) => {
|
||||
if (value !== selectedCategory.value) {
|
||||
selectedCategory.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
selectedCategory.value = value
|
||||
doRefresh({ resetOffset: true })
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onSearchInput = (value: string) => {
|
||||
searchInput.value = value;
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
const onCategoryChange = (value: ModelCategory) => {
|
||||
if (!allowCategorySwitch.value) {
|
||||
return;
|
||||
}
|
||||
if (!props.allowCategorySwitch) return
|
||||
if (selectedCategory.value !== value) {
|
||||
selectedCategory.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
selectedCategory.value = value
|
||||
doRefresh({ resetOffset: true })
|
||||
}
|
||||
};
|
||||
|
||||
const onSortChange = (value: "name" | "createdAt") => {
|
||||
if (sort.value !== value) {
|
||||
sort.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onDirChange = (value: "asc" | "desc") => {
|
||||
if (dir.value !== value) {
|
||||
dir.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onOffsetChange = (value: number) => {
|
||||
const nextOffset = Math.max(0, value);
|
||||
if (nextOffset !== offset.value) {
|
||||
offset.value = nextOffset;
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component-category";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/piece-category";
|
||||
}
|
||||
return "/product-category";
|
||||
};
|
||||
if (category === 'COMPONENT') return '/component-category'
|
||||
if (category === 'PIECE') return '/piece-category'
|
||||
return '/product-category'
|
||||
}
|
||||
|
||||
const openCreatePage = () => {
|
||||
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
||||
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
||||
router.push(`${basePath}/new`).catch(() => {
|
||||
showError("Navigation impossible vers la page de création.");
|
||||
});
|
||||
};
|
||||
showError('Navigation impossible vers la page de création.')
|
||||
})
|
||||
}
|
||||
|
||||
const openEditPage = (item: ModelType) => {
|
||||
const category = item.category ?? selectedCategory.value;
|
||||
const basePath = resolveCategoryBasePath(category);
|
||||
const category = item.category ?? selectedCategory.value
|
||||
const basePath = resolveCategoryBasePath(category)
|
||||
router.push(`${basePath}/${item.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la page d'édition.");
|
||||
});
|
||||
};
|
||||
showError("Navigation impossible vers la page d'édition.")
|
||||
})
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const confirmDelete = async (item: ModelType) => {
|
||||
const confirmed = await confirm({
|
||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await deleteModelType(item.id);
|
||||
invalidateEntityTypeCache(item.category);
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
||||
|
||||
await deleteModelType(item.id)
|
||||
invalidateEntityTypeCache(item.category)
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`)
|
||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||
offset.value = Math.max(0, offset.value - limit.value);
|
||||
offset.value = Math.max(0, offset.value - limit.value)
|
||||
}
|
||||
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(extractErrorMessage(error));
|
||||
await doRefresh()
|
||||
}
|
||||
};
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
reference?: string | null;
|
||||
};
|
||||
|
||||
const relatedModalOpen = ref(false);
|
||||
const relatedLoading = ref(false);
|
||||
const relatedError = ref<string | null>(null);
|
||||
const relatedItems = ref<RelatedEntry[]>([]);
|
||||
const relatedType = ref<ModelType | null>(null);
|
||||
|
||||
const relatedCategoryLabels: Record<
|
||||
ModelCategory,
|
||||
{ plural: string; singular: string }
|
||||
> = {
|
||||
COMPONENT: { plural: "composants", singular: "composant" },
|
||||
PIECE: { plural: "pièces", singular: "pièce" },
|
||||
PRODUCT: { plural: "produits", singular: "produit" },
|
||||
};
|
||||
|
||||
const relatedModalTitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "Éléments liés";
|
||||
catch (error) {
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
return `Éléments liés à « ${current.name} »`;
|
||||
});
|
||||
}
|
||||
|
||||
const relatedModalSubtitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "";
|
||||
}
|
||||
const labels =
|
||||
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
|
||||
const count = relatedItems.value.length;
|
||||
if (relatedLoading.value) {
|
||||
return `Chargement des ${labels.plural}…`;
|
||||
}
|
||||
if (count === 0) {
|
||||
return `Aucun ${labels.singular} lié.`;
|
||||
}
|
||||
if (count === 1) {
|
||||
return `1 ${labels.singular} lié.`;
|
||||
}
|
||||
return `${count} ${labels.plural} liés.`;
|
||||
});
|
||||
|
||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return { endpoint: "/composants", filterKey: "typeComposant" };
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return { endpoint: "/pieces", filterKey: "typePiece" };
|
||||
}
|
||||
return { endpoint: "/products", filterKey: "typeProduct" };
|
||||
};
|
||||
const relatedModalOpen = ref(false)
|
||||
const relatedType = ref<ModelType | null>(null)
|
||||
|
||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/pieces";
|
||||
}
|
||||
return "/product";
|
||||
};
|
||||
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name
|
||||
: "Sans nom";
|
||||
const reference =
|
||||
typeof record.reference === "string" && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === "string" && record.code.trim()
|
||||
? record.code
|
||||
: null;
|
||||
return {
|
||||
id: record.id,
|
||||
name,
|
||||
reference,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRelatedItems = async (item: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
|
||||
const params = new URLSearchParams();
|
||||
params.set("itemsPerPage", "200");
|
||||
params.set(filterKey, buildModelTypeIri(item.id));
|
||||
params.set("order[name]", "asc");
|
||||
|
||||
relatedLoading.value = true;
|
||||
relatedError.value = null;
|
||||
relatedItems.value = [];
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`);
|
||||
if (!result.success) {
|
||||
relatedError.value =
|
||||
result.error ?? "Impossible de charger les éléments liés.";
|
||||
return;
|
||||
}
|
||||
const collection = extractCollection(result.data);
|
||||
relatedItems.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry));
|
||||
} catch (error) {
|
||||
relatedError.value = extractErrorMessage(error);
|
||||
} finally {
|
||||
relatedLoading.value = false;
|
||||
}
|
||||
};
|
||||
if (category === 'COMPONENT') return '/component'
|
||||
if (category === 'PIECE') return '/pieces'
|
||||
return '/product'
|
||||
}
|
||||
|
||||
const openRelatedModal = (item: ModelType) => {
|
||||
relatedType.value = item;
|
||||
relatedModalOpen.value = true;
|
||||
void loadRelatedItems(item);
|
||||
};
|
||||
relatedType.value = item
|
||||
relatedModalOpen.value = true
|
||||
}
|
||||
|
||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const basePath = resolveRelatedEditBasePath(current.category);
|
||||
relatedModalOpen.value = false;
|
||||
const openRelatedEdit = (entry: { id: string }) => {
|
||||
const current = relatedType.value
|
||||
if (!current) return
|
||||
const basePath = resolveRelatedEditBasePath(current.category)
|
||||
relatedModalOpen.value = false
|
||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la fiche d'édition.");
|
||||
});
|
||||
};
|
||||
showError("Navigation impossible vers la fiche d'édition.")
|
||||
})
|
||||
}
|
||||
|
||||
const closeRelatedModal = () => {
|
||||
relatedModalOpen.value = false;
|
||||
};
|
||||
|
||||
const conversionModalOpen = ref(false);
|
||||
const conversionTarget = ref<ModelType | null>(null);
|
||||
const conversionModalOpen = ref(false)
|
||||
const conversionTarget = ref<ModelType | null>(null)
|
||||
|
||||
const openConversionModal = (item: ModelType) => {
|
||||
conversionTarget.value = item;
|
||||
conversionModalOpen.value = true;
|
||||
};
|
||||
conversionTarget.value = item
|
||||
conversionModalOpen.value = true
|
||||
}
|
||||
|
||||
const closeConversionModal = () => {
|
||||
conversionModalOpen.value = false;
|
||||
};
|
||||
conversionModalOpen.value = false
|
||||
}
|
||||
|
||||
const onConverted = () => {
|
||||
conversionModalOpen.value = false;
|
||||
invalidateEntityTypeCache("PIECE");
|
||||
invalidateEntityTypeCache("COMPONENT");
|
||||
showSuccess("Catégorie convertie avec succès.");
|
||||
refresh();
|
||||
};
|
||||
conversionModalOpen.value = false
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
showSuccess('Catégorie convertie avec succès.')
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
searchTerm.value = value.trim();
|
||||
refresh({ resetOffset: true });
|
||||
}, 300);
|
||||
}
|
||||
);
|
||||
searchTerm.value = value.trim()
|
||||
doRefresh({ resetOffset: true })
|
||||
}, 300)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
});
|
||||
doRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
if (activeController) {
|
||||
activeController.abort();
|
||||
}
|
||||
});
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (activeController) activeController.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
182
app/components/model-types/RelatedItemsModal.vue
Normal file
182
app/components/model-types/RelatedItemsModal.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
{{ modalTitle }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ modalSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||
<div v-if="loading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement des éléments liés…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="px-4 py-6 text-sm text-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="px-4 py-6 text-sm text-base-content/60"
|
||||
>
|
||||
Aucun élément lié à cette catégorie.
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||
<li
|
||||
v-for="entry in items"
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@click="onOpenEdit(entry)"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="emit('close')">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import type { ModelCategory, ModelType } from '~/services/modelTypes'
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
modelType: ModelType | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'open-edit': [entry: RelatedEntry]
|
||||
}>()
|
||||
|
||||
const { get } = useApi()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const items = ref<RelatedEntry[]>([])
|
||||
|
||||
const categoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
|
||||
COMPONENT: { plural: 'composants', singular: 'composant' },
|
||||
PIECE: { plural: 'pièces', singular: 'pièce' },
|
||||
PRODUCT: { plural: 'produits', singular: 'produit' },
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (!props.modelType) return 'Éléments liés'
|
||||
return `Éléments liés à « ${props.modelType.name} »`
|
||||
})
|
||||
|
||||
const modalSubtitle = computed(() => {
|
||||
if (!props.modelType) return ''
|
||||
const labels = categoryLabels[props.modelType.category] ?? categoryLabels.COMPONENT
|
||||
const count = items.value.length
|
||||
if (loading.value) return `Chargement des ${labels.plural}…`
|
||||
if (count === 0) return `Aucun ${labels.singular} lié.`
|
||||
if (count === 1) return `1 ${labels.singular} lié.`
|
||||
return `${count} ${labels.plural} liés.`
|
||||
})
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
||||
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
||||
return { endpoint: '/products', filterKey: 'typeProduct' }
|
||||
}
|
||||
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const record = item as Record<string, unknown>
|
||||
if (typeof record.id !== 'string') return null
|
||||
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
||||
const reference
|
||||
= typeof record.reference === 'string' && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === 'string' && record.code.trim()
|
||||
? record.code
|
||||
: null
|
||||
return { id: record.id, name, reference }
|
||||
}
|
||||
|
||||
const loadRelatedItems = async (modelType: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '200')
|
||||
params.set(filterKey, `/api/model_types/${modelType.id}`)
|
||||
params.set('order[name]', 'asc')
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
items.value = []
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||
return
|
||||
}
|
||||
const collection = extractCollection(result.data)
|
||||
items.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
||||
}
|
||||
catch (err) {
|
||||
let raw: string | null = null
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
|
||||
if (e.data) {
|
||||
const data = e.data
|
||||
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||
else if (typeof data.detail === 'string') raw = data.detail
|
||||
else if (typeof data.message === 'string') raw = data.message
|
||||
else if (typeof data.error === 'string') raw = data.error
|
||||
}
|
||||
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
|
||||
if (!raw && typeof e.message === 'string') raw = e.message
|
||||
}
|
||||
error.value = humanizeError(raw)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenEdit = (entry: RelatedEntry) => {
|
||||
emit('open-edit', entry)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen && props.modelType) {
|
||||
void loadRelatedItems(props.modelType)
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -1,27 +1,37 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div
|
||||
class="card site-card shadow-md hover:shadow-xl transition-shadow overflow-hidden"
|
||||
:style="{
|
||||
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
|
||||
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
|
||||
}"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="card-title text-lg">
|
||||
<h3 class="card-title text-lg text-base-content">
|
||||
{{ site.name }}
|
||||
</h3>
|
||||
<div class="badge badge-primary badge-sm">
|
||||
<div
|
||||
class="badge font-bold"
|
||||
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
||||
:class="!site.color ? 'badge-primary' : ''"
|
||||
>
|
||||
{{ machineCount }} machines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<span class="font-medium">{{ site.contactName }}</span>
|
||||
</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" />
|
||||
<span>{{ formattedContactPhone }}</span>
|
||||
</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" />
|
||||
<span>
|
||||
{{ site.contactAddress }}<br>
|
||||
@@ -29,7 +39,7 @@
|
||||
</span>
|
||||
</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" />
|
||||
<span>{{ machineCount }} machine(s)</span>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,46 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Couleur</span>
|
||||
</label>
|
||||
<div v-if="siteRef.color" class="flex items-center gap-3">
|
||||
<input
|
||||
:value="siteRef.color"
|
||||
type="color"
|
||||
class="w-10 h-10 rounded cursor-pointer border border-base-300"
|
||||
:disabled="disabled"
|
||||
@input="(e: Event) => { siteRef.color = (e.target as HTMLInputElement).value }"
|
||||
>
|
||||
<input
|
||||
v-model="siteRef.color"
|
||||
type="text"
|
||||
placeholder="#000000"
|
||||
class="input input-bordered input-sm flex-1"
|
||||
:disabled="disabled"
|
||||
maxlength="7"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="disabled"
|
||||
@click="siteRef.color = ''"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm w-fit"
|
||||
:disabled="disabled"
|
||||
@click="siteRef.color = '#3b82f6'"
|
||||
>
|
||||
Choisir une couleur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
||||
|
||||
<div class="modal-action">
|
||||
@@ -39,6 +79,7 @@ import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
|
||||
type SiteForm = {
|
||||
name: string
|
||||
color: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactAddress: string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="modal-box max-w-md">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
{{ 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>
|
||||
<form class="space-y-4" @submit.prevent="emit('submit')">
|
||||
<div class="form-control">
|
||||
@@ -20,6 +20,46 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Couleur</span>
|
||||
</label>
|
||||
<div v-if="form.color" class="flex items-center gap-3">
|
||||
<input
|
||||
:value="form.color"
|
||||
type="color"
|
||||
class="w-10 h-10 rounded cursor-pointer border border-base-300"
|
||||
:disabled="disabled"
|
||||
@input="form.color = $event.target.value"
|
||||
>
|
||||
<input
|
||||
v-model="form.color"
|
||||
type="text"
|
||||
placeholder="#000000"
|
||||
class="input input-bordered input-sm flex-1"
|
||||
:disabled="disabled"
|
||||
maxlength="7"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="disabled"
|
||||
@click="form.color = ''"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm w-fit"
|
||||
:disabled="disabled"
|
||||
@click="form.color = '#3b82f6'"
|
||||
>
|
||||
Choisir une couleur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
||||
|
||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||
@@ -28,7 +68,7 @@
|
||||
<h4 class="font-semibold text-sm">
|
||||
Documents liés
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
<p class="text-xs text-base-content/50">
|
||||
Ajoutez des documents (PDF, images...) relatifs à ce site.
|
||||
</p>
|
||||
</div>
|
||||
@@ -57,8 +97,8 @@
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
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}`"
|
||||
>
|
||||
@@ -74,7 +114,7 @@
|
||||
<div class="font-medium">
|
||||
{{ document.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-base-content/50">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +156,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { isImageDocument } from '~/utils/documentPreview'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
@@ -173,8 +213,6 @@ const emit = defineEmits([
|
||||
'update:selectedFiles'
|
||||
])
|
||||
|
||||
const form = toRefs(props.form)
|
||||
|
||||
const selectedFilesModel = computed({
|
||||
get: () => props.selectedFiles,
|
||||
set: value => emit('update:selectedFiles', value)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useToast } from './useToast'
|
||||
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
@@ -20,11 +21,10 @@ export function useApi() {
|
||||
|
||||
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
||||
const url = `${API_BASE_URL}${endpoint}`
|
||||
const isFormData = options.body instanceof FormData
|
||||
const defaultOptions: ApiCallOptions = {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
|
||||
}
|
||||
|
||||
// Ajouter un timeout à la requête
|
||||
@@ -60,23 +60,26 @@ export function useApi() {
|
||||
} else {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let errorData: Record<string, unknown> = {}
|
||||
if (contentType.includes('application/json')) {
|
||||
if (contentType.includes('json')) {
|
||||
errorData = await response.json().catch(() => ({}))
|
||||
} else {
|
||||
const text = await response.text().catch(() => '')
|
||||
errorData = text ? { message: text } : {}
|
||||
}
|
||||
const errorMessage = response.status === 403
|
||||
const rawMessage = response.status === 403
|
||||
? 'Permissions insuffisantes pour cette action.'
|
||||
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
||||
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
|
||||
const errorMessage = humanizeError(rawMessage)
|
||||
showError(errorMessage)
|
||||
return { success: false, error: errorMessage, status: response.status }
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
|
||||
showError(`Erreur réseau: ${errorMessage}`)
|
||||
const errorMessage = err.name === 'AbortError'
|
||||
? 'La requête a pris trop de temps. Veuillez réessayer.'
|
||||
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
|
||||
showError(errorMessage)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
@@ -115,6 +118,13 @@ export function useApi() {
|
||||
})
|
||||
}
|
||||
|
||||
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, { method: 'DELETE' })
|
||||
}
|
||||
@@ -123,6 +133,7 @@ export function useApi() {
|
||||
apiCall,
|
||||
get,
|
||||
post,
|
||||
postFormData,
|
||||
patch,
|
||||
put,
|
||||
delete: del,
|
||||
|
||||
@@ -68,15 +68,21 @@ export function useComments() {
|
||||
const fetchAllComments = async (options: {
|
||||
status?: string
|
||||
entityType?: string
|
||||
entityName?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: string
|
||||
} = {}): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (options.status) params.set('status', options.status)
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
params.set('order[createdAt]', 'desc')
|
||||
if (options.entityName) params.set('entityName', options.entityName)
|
||||
const sortField = options.orderBy || 'createdAt'
|
||||
const sortDir = options.orderDir || 'desc'
|
||||
params.set(`order[${sortField}]`, sortDir)
|
||||
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||
params.set('page', String(options.page || 1))
|
||||
|
||||
|
||||
417
app/composables/useComponentCreate.ts
Normal file
417
app/composables/useComponentCreate.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Component creation page – orchestration composable.
|
||||
*
|
||||
* Pure structure-assignment helpers live in
|
||||
* `~/shared/utils/structureAssignmentHelpers.ts`.
|
||||
*/
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from '#imports'
|
||||
import type { StructureAssignmentNode } from '~/components/ComponentStructureAssignmentNode.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import {
|
||||
getStructurePieces,
|
||||
resolvePieceLabel as _resolvePieceLabel,
|
||||
resolveProductLabel as _resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
fetchModelTypeNames,
|
||||
buildTypeLabelMap,
|
||||
} from '~/shared/utils/structureDisplayUtils'
|
||||
import {
|
||||
hasAssignments,
|
||||
initializeStructureAssignments,
|
||||
isAssignmentNodeComplete,
|
||||
serializeStructureAssignments,
|
||||
} from '~/shared/utils/structureAssignmentHelpers'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
structure: ComponentModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useComponentCreate() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const {
|
||||
createComposant,
|
||||
composants: componentCatalogRef,
|
||||
loading: componentsLoading,
|
||||
} = useComposants()
|
||||
const {
|
||||
pieces: pieceCatalogRef,
|
||||
loading: piecesLoading,
|
||||
} = usePieces()
|
||||
const {
|
||||
products: productCatalogRef,
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Local state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const selectedTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const submitting = ref(false)
|
||||
const creationForm = reactive({
|
||||
name: '' as string,
|
||||
description: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const lastSuggestedName = ref('')
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||||
const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Computed
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
||||
const availableProducts = computed(() => productCatalogRef.value ?? [])
|
||||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||||
const structureDataLoading = computed(
|
||||
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||||
)
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
)
|
||||
const productTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(productTypes.value),
|
||||
)
|
||||
const componentTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(componentTypes.value),
|
||||
)
|
||||
|
||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||
(componentTypes.value || [])
|
||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||
)
|
||||
|
||||
const typeOptionLabel = (type?: ComponentCatalogType) =>
|
||||
type?.name || 'Catégorie'
|
||||
|
||||
const typeOptionDescription = (type?: ComponentCatalogType) =>
|
||||
type?.description ? String(type.description) : ''
|
||||
|
||||
const selectedType = computed(() => {
|
||||
if (!selectedTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
|
||||
const structure = selectedType.value?.structure ?? null
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const structureHasRequirements = computed(() =>
|
||||
hasAssignments(structureAssignments.value),
|
||||
)
|
||||
|
||||
const structureSelectionsComplete = computed(() => {
|
||||
if (!structureHasRequirements.value) {
|
||||
return true
|
||||
}
|
||||
if (structureDataLoading.value) {
|
||||
return false
|
||||
}
|
||||
if (!structureAssignments.value) {
|
||||
return false
|
||||
}
|
||||
return isAssignmentNodeComplete(structureAssignments.value, true)
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
&& selectedType.value
|
||||
&& creationForm.name
|
||||
&& requiredCustomFieldsFilled.value
|
||||
&& structureSelectionsComplete.value
|
||||
&& !submitting.value,
|
||||
))
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) =>
|
||||
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Watchers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
watch(
|
||||
() => route.query.typeId,
|
||||
(value) => {
|
||||
if (typeof value === 'string') {
|
||||
selectedTypeId.value = value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedTypeId, (id) => {
|
||||
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
|
||||
if ((id || '') === current) {
|
||||
return
|
||||
}
|
||||
const nextQuery = { ...route.query }
|
||||
if (id) {
|
||||
nextQuery.typeId = id
|
||||
}
|
||||
else {
|
||||
delete nextQuery.typeId
|
||||
}
|
||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||
})
|
||||
|
||||
const clearCreationForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.description = ''
|
||||
creationForm.reference = ''
|
||||
creationForm.constructeurIds = []
|
||||
creationForm.prix = ''
|
||||
lastSuggestedName.value = ''
|
||||
structureAssignments.value = null
|
||||
}
|
||||
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
customFieldInputs.value = []
|
||||
structureAssignments.value = null
|
||||
return
|
||||
}
|
||||
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
||||
creationForm.name = type.name
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
|
||||
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const ids = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
fetchModelTypeNames(Array.from(new Set(ids)), pieceTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Submission
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const submitCreation = async () => {
|
||||
if (!selectedType.value) {
|
||||
toast.showError('Sélectionnez une catégorie de composant.')
|
||||
return
|
||||
}
|
||||
const payload: Record<string, any> = {
|
||||
name: creationForm.name.trim(),
|
||||
typeComposantId: selectedType.value.id,
|
||||
}
|
||||
|
||||
const description = creationForm.description.trim()
|
||||
if (description) {
|
||||
payload.description = description
|
||||
}
|
||||
|
||||
const reference = creationForm.reference.trim()
|
||||
if (reference) {
|
||||
payload.reference = reference
|
||||
}
|
||||
|
||||
if (creationForm.constructeurIds.length) {
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
}
|
||||
|
||||
const rawPrice = typeof creationForm.prix === 'string'
|
||||
? creationForm.prix.trim()
|
||||
: creationForm.prix === null || creationForm.prix === undefined
|
||||
? ''
|
||||
: String(creationForm.prix).trim()
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
const rootProductSelection
|
||||
= structureAssignments.value?.products?.find(
|
||||
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
|
||||
) ?? null
|
||||
|
||||
if (rootProductSelection?.selectedProductId) {
|
||||
payload.productId = rootProductSelection.selectedProductId.trim()
|
||||
}
|
||||
|
||||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
||||
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
||||
return
|
||||
}
|
||||
|
||||
const serializedStructure = structureHasRequirements.value
|
||||
? serializeStructureAssignments(structureAssignments.value)
|
||||
: null
|
||||
|
||||
if (serializedStructure) {
|
||||
payload.structure = serializedStructure
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await createComposant(payload)
|
||||
if (result.success) {
|
||||
const createdComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
createdComponent.id,
|
||||
[createdComponent?.typeComposant?.customFields],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
if (selectedDocuments.value.length && result.data?.id) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
{
|
||||
files: selectedDocuments.value,
|
||||
context: { composantId: result.data.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (!uploadResult.success) {
|
||||
const message = uploadResult.error
|
||||
? `Documents non ajoutés : ${uploadResult.error}`
|
||||
: 'Documents non ajoutés : une erreur est survenue.'
|
||||
toast.showError(message)
|
||||
}
|
||||
selectedDocuments.value = []
|
||||
}
|
||||
toast.showSuccess('Composant créé avec succès')
|
||||
await router.push('/component-catalog')
|
||||
}
|
||||
else if (result.error) {
|
||||
toast.showError(result.error)
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTypeId,
|
||||
submitting,
|
||||
creationForm,
|
||||
customFieldInputs,
|
||||
structureAssignments,
|
||||
selectedDocuments,
|
||||
uploadingDocuments,
|
||||
|
||||
// Computed
|
||||
loadingTypes,
|
||||
componentTypeList,
|
||||
selectedType,
|
||||
selectedTypeStructure,
|
||||
availablePieces,
|
||||
availableProducts,
|
||||
availableComponents,
|
||||
piecesLoading,
|
||||
productsLoading,
|
||||
componentsLoading,
|
||||
structureDataLoading,
|
||||
pieceTypeLabelMap,
|
||||
productTypeLabelMap,
|
||||
componentTypeLabelMap,
|
||||
structureHasRequirements,
|
||||
structureSelectionsComplete,
|
||||
canEdit,
|
||||
canSubmit,
|
||||
|
||||
// Functions
|
||||
typeOptionLabel,
|
||||
typeOptionDescription,
|
||||
formatStructurePreview,
|
||||
resolvePieceLabel,
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
submitCreation,
|
||||
}
|
||||
}
|
||||
465
app/composables/useComponentEdit.ts
Normal file
465
app/composables/useComponentEdit.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from '#imports'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import {
|
||||
getStructurePieces,
|
||||
getStructureProducts,
|
||||
resolvePieceLabel as _resolvePieceLabel,
|
||||
resolveProductLabel as _resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
fetchModelTypeNames,
|
||||
buildTypeLabelMap,
|
||||
} from '~/shared/utils/structureDisplayUtils'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
structure: ComponentModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
structure: 'Structure',
|
||||
typeComposant: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
export function useComponentEdit(componentId: string) {
|
||||
const { canEdit } = usePermissions()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { products, loadProducts } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useComponentHistory()
|
||||
|
||||
const component = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const componentDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
description: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
)
|
||||
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
||||
const productTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(productTypes.value, fetchedProductTypeMap.value),
|
||||
)
|
||||
const pieceCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(pieces.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const productCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(products.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const componentCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(componentCatalogRef.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
return
|
||||
}
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||
if (!documentId) {
|
||||
return
|
||||
}
|
||||
const result = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success) {
|
||||
componentDocuments.value = componentDocuments.value.filter((doc) => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
if (!component.value?.id) {
|
||||
componentDocuments.value = []
|
||||
return
|
||||
}
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files: File[]) => {
|
||||
if (!files?.length || !component.value?.id) {
|
||||
return
|
||||
}
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const result = await uploadDocuments(
|
||||
{
|
||||
files,
|
||||
context: { composantId: component.value.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (result.success) {
|
||||
selectedFiles.value = []
|
||||
await refreshDocuments()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||
(componentTypes.value || [])
|
||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||
)
|
||||
|
||||
const selectedType = computed(() => {
|
||||
if (!selectedTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
|
||||
const structure = selectedType.value?.structure ?? null
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ComponentModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? selectedTypeStructure.value ?? null
|
||||
const values = valuesOverride ?? component.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
&& component.value
|
||||
&& editionForm.name
|
||||
&& requiredCustomFieldsFilled.value
|
||||
&& !saving.value,
|
||||
))
|
||||
|
||||
const fetchComponent = async () => {
|
||||
if (!componentId || typeof componentId !== 'string') {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
return
|
||||
}
|
||||
const result = await get(`/composants/${componentId}`)
|
||||
if (result.success) {
|
||||
component.value = result.data
|
||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
}
|
||||
else {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) =>
|
||||
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||
|
||||
const structureSelections = computed(() => {
|
||||
const selections = collectStructureSelections(
|
||||
component.value?.structure,
|
||||
{
|
||||
pieceCatalogMap: pieceCatalogMap.value,
|
||||
productCatalogMap: productCatalogMap.value,
|
||||
componentCatalogMap: componentCatalogMap.value,
|
||||
},
|
||||
{ resolvePieceLabel, resolveProductLabel, resolveSubcomponentLabel },
|
||||
)
|
||||
const total
|
||||
= selections.pieces.length + selections.products.length + selections.components.length
|
||||
return {
|
||||
...selections,
|
||||
total,
|
||||
hasAny: total > 0,
|
||||
}
|
||||
})
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!component.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.prix === 'string'
|
||||
? editionForm.prix.trim()
|
||||
: editionForm.prix === null || editionForm.prix === undefined
|
||||
? ''
|
||||
: String(editionForm.prix).trim()
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
description: editionForm.description.trim() || null,
|
||||
}
|
||||
|
||||
const reference = editionForm.reference.trim()
|
||||
payload.reference = reference || null
|
||||
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
else {
|
||||
payload.prix = null
|
||||
}
|
||||
|
||||
if (component.value.structure) {
|
||||
payload.structure = component.value.structure
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updateComposant(component.value.id, payload)
|
||||
if (result.success && result.data) {
|
||||
const updatedComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
updatedComponent.id,
|
||||
[
|
||||
updatedComponent?.typeComposant?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/component-catalog')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la mise à jour du composant')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
watch(
|
||||
[component, selectedTypeStructure],
|
||||
([currentComponent, currentStructure]) => {
|
||||
if (!currentComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
const resolvedTypeId = currentComponent.typeComposantId
|
||||
|| extractRelationId(currentComponent.typeComposant)
|
||||
|| ''
|
||||
if (resolvedTypeId && !currentComponent.typeComposantId) {
|
||||
currentComponent.typeComposantId = resolvedTypeId
|
||||
}
|
||||
selectedTypeId.value = resolvedTypeId
|
||||
|
||||
editionForm.name = currentComponent.name || ''
|
||||
editionForm.description = currentComponent.description || ''
|
||||
editionForm.reference = currentComponent.reference || ''
|
||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||
currentComponent,
|
||||
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
|
||||
currentComponent.constructeur ? [currentComponent.constructeur] : [],
|
||||
)
|
||||
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const pieceIds = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (pieceIds.length) {
|
||||
fetchModelTypeNames(Array.from(new Set(pieceIds)), pieceTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const productIds = getStructureProducts(structure)
|
||||
.map((product: any) => product?.typeProductId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (productIds.length) {
|
||||
fetchModelTypeNames(Array.from(new Set(productIds)), productTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedProductTypeMap.value = { ...fetchedProductTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
fetchComponent(),
|
||||
])
|
||||
loading.value = false
|
||||
|
||||
// Defer bulk catalog loads — only needed when component has structure selections
|
||||
if (component.value?.structure) {
|
||||
Promise.allSettled([
|
||||
loadPieces({ itemsPerPage: 200 }),
|
||||
loadProducts({ itemsPerPage: 200 }),
|
||||
loadComposants({ itemsPerPage: 200 }),
|
||||
]).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
component,
|
||||
loading,
|
||||
saving,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
componentDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
customFieldInputs,
|
||||
historyFieldLabels,
|
||||
|
||||
// Computed
|
||||
canEdit,
|
||||
canSubmit,
|
||||
componentTypeList,
|
||||
selectedType,
|
||||
selectedTypeStructure,
|
||||
structureSelections,
|
||||
|
||||
// History
|
||||
history,
|
||||
historyLoading,
|
||||
historyError,
|
||||
|
||||
// Methods
|
||||
openPreview,
|
||||
closePreview,
|
||||
removeDocument,
|
||||
handleFilesAdded,
|
||||
refreshDocuments,
|
||||
submitEdition,
|
||||
resolvePieceLabel,
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
formatStructurePreview,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface Composant {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
description?: string | null
|
||||
typeComposantId?: string | null
|
||||
typeComposant?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
@@ -40,6 +41,7 @@ interface LoadComposantsOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -106,10 +108,11 @@ export function useComposants() {
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
@@ -134,6 +137,10 @@ export function useComposants() {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeComposant.name', typeName.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
|
||||
26
app/composables/useDarkMode.ts
Normal file
26
app/composables/useDarkMode.ts
Normal 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 }
|
||||
}
|
||||
186
app/composables/useDataTable.ts
Normal file
186
app/composables/useDataTable.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { useUrlState } from './useUrlState'
|
||||
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
|
||||
|
||||
export interface UseDataTableDeps {
|
||||
/** Called whenever sort/page/search/perPage/filter changes. The composable does NOT fetch data itself. */
|
||||
fetchData: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface UseDataTableOptions {
|
||||
/** Default sort field */
|
||||
defaultSort?: string
|
||||
/** Default sort direction */
|
||||
defaultDirection?: SortDirection
|
||||
/** Default items per page */
|
||||
defaultPerPage?: number
|
||||
/** Available per-page options */
|
||||
perPageOptions?: number[]
|
||||
/** Search debounce in ms. Default: 300 */
|
||||
searchDebounceMs?: number
|
||||
/** Whether to persist state to URL. Default: true */
|
||||
persistToUrl?: boolean
|
||||
/** Extra URL state params for page-specific filters */
|
||||
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
|
||||
}
|
||||
|
||||
export interface UseDataTableReturn {
|
||||
searchTerm: Ref<string>
|
||||
sortField: Ref<string>
|
||||
sortDirection: Ref<SortDirection>
|
||||
currentPage: Ref<number>
|
||||
itemsPerPage: Ref<number>
|
||||
columnFilters: Ref<DataTableColumnFilters>
|
||||
filters: Record<string, Ref<string | number>>
|
||||
sort: ComputedRef<DataTableSort>
|
||||
pagination: (total: Ref<number>, pageItems: Ref<number>) => ComputedRef<DataTablePagination>
|
||||
handleSort: (newSort: DataTableSort) => void
|
||||
handlePageChange: (page: number) => void
|
||||
handlePerPageChange: (perPage: number) => void
|
||||
handleFilterChange: () => void
|
||||
handleColumnFiltersChange: (filters: DataTableColumnFilters) => void
|
||||
debouncedSearch: () => void
|
||||
refresh: () => void
|
||||
perPageOptions: number[]
|
||||
}
|
||||
|
||||
export function useDataTable(
|
||||
deps: UseDataTableDeps,
|
||||
options: UseDataTableOptions = {},
|
||||
): UseDataTableReturn {
|
||||
const {
|
||||
defaultSort = 'name',
|
||||
defaultDirection = 'asc',
|
||||
defaultPerPage = 20,
|
||||
perPageOptions = [20, 50, 100],
|
||||
searchDebounceMs = 300,
|
||||
persistToUrl = true,
|
||||
extraParams = {},
|
||||
} = options
|
||||
|
||||
let searchTerm: Ref<string>
|
||||
let sortField: Ref<string>
|
||||
let sortDirection: Ref<SortDirection>
|
||||
let currentPage: Ref<number>
|
||||
let itemsPerPage: Ref<number>
|
||||
const filters: Record<string, Ref<string | number>> = {}
|
||||
|
||||
if (persistToUrl) {
|
||||
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: defaultPerPage, type: 'number' },
|
||||
q: { default: '', debounce: searchDebounceMs },
|
||||
sort: { default: defaultSort },
|
||||
dir: { default: defaultDirection },
|
||||
...extraParams,
|
||||
}
|
||||
|
||||
const state = useUrlState(paramDefs, {
|
||||
onRestore: () => deps.fetchData(),
|
||||
})
|
||||
|
||||
searchTerm = state.q as Ref<string>
|
||||
sortField = state.sort as Ref<string>
|
||||
sortDirection = state.dir as unknown as Ref<SortDirection>
|
||||
currentPage = state.page as unknown as Ref<number>
|
||||
itemsPerPage = state.perPage as unknown as Ref<number>
|
||||
|
||||
for (const key of Object.keys(extraParams)) {
|
||||
filters[key] = (state as Record<string, Ref<string | number>>)[key]!
|
||||
}
|
||||
}
|
||||
else {
|
||||
searchTerm = ref('')
|
||||
sortField = ref(defaultSort)
|
||||
sortDirection = ref(defaultDirection) as Ref<SortDirection>
|
||||
currentPage = ref(1)
|
||||
itemsPerPage = ref(defaultPerPage)
|
||||
|
||||
for (const [key, def] of Object.entries(extraParams)) {
|
||||
filters[key] = ref(def.default)
|
||||
}
|
||||
}
|
||||
|
||||
// Search debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}, searchDebounceMs)
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sort = computed<DataTableSort>(() => ({
|
||||
field: sortField.value,
|
||||
direction: sortDirection.value,
|
||||
}))
|
||||
|
||||
const handleSort = (newSort: DataTableSort) => {
|
||||
sortField.value = newSort.field
|
||||
sortDirection.value = newSort.direction
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
const handlePerPageChange = (perPage: number) => {
|
||||
itemsPerPage.value = perPage
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Column filters
|
||||
const columnFilters = ref<DataTableColumnFilters>({})
|
||||
|
||||
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
|
||||
columnFilters.value = newFilters
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Generic filter change handler (resets page and refetches)
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
const pagination = (total: Ref<number>, pageItems: Ref<number>): ComputedRef<DataTablePagination> =>
|
||||
computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: Math.ceil(total.value / itemsPerPage.value) || 1,
|
||||
totalItems: total.value,
|
||||
pageItems: pageItems.value,
|
||||
perPageOptions,
|
||||
perPage: itemsPerPage.value,
|
||||
}))
|
||||
|
||||
const refresh = () => deps.fetchData()
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
sortField,
|
||||
sortDirection,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
columnFilters,
|
||||
filters,
|
||||
sort,
|
||||
pagination,
|
||||
handleSort,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleFilterChange,
|
||||
handleColumnFiltersChange,
|
||||
debouncedSearch,
|
||||
refresh,
|
||||
perPageOptions,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Document {
|
||||
@@ -10,12 +9,21 @@ export interface Document {
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
path: string
|
||||
fileUrl: string
|
||||
downloadUrl: string
|
||||
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||
path?: string
|
||||
createdAt?: string
|
||||
siteId?: string
|
||||
machineId?: string
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
site?: { id: string; name?: string } | null
|
||||
machine?: { id: string; name?: string } | null
|
||||
composant?: { id: string; name?: string } | null
|
||||
piece?: { id: string; name?: string } | null
|
||||
product?: { id: string; name?: string } | null
|
||||
}
|
||||
|
||||
export interface UploadContext {
|
||||
@@ -32,19 +40,30 @@ export interface DocumentResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
const documents = ref<Document[]>([])
|
||||
const loading = ref(false)
|
||||
interface LoadDocumentsOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
attachmentFilter?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
const documents = ref<Document[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') return p.totalItems
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useDocuments() {
|
||||
const { get, post, delete: del } = useApi()
|
||||
const { get, postFormData, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
@@ -76,10 +95,61 @@ export function useDocuments() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocuments = async (
|
||||
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'createdAt',
|
||||
orderDir = 'desc',
|
||||
attachmentFilter = 'all',
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') {
|
||||
return { success: true, data: documents.value }
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
return { success: true, data: documents.value }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (attachmentFilter && attachmentFilter !== 'all') {
|
||||
params.set(`exists[${attachmentFilter}]`, 'true')
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/documents?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
documents.value = items
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return { success: true, data: items }
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement des documents:', error)
|
||||
showError('Impossible de charger les documents')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocumentsBySite = async (
|
||||
@@ -145,18 +215,17 @@ export function useDocuments() {
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const dataUrl = await fileToBase64(file)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('name', file.name)
|
||||
|
||||
const payload = normalizeRelationIds({
|
||||
name: file.name,
|
||||
filename: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
path: dataUrl,
|
||||
...context,
|
||||
})
|
||||
if (context.siteId) formData.append('siteId', context.siteId)
|
||||
if (context.machineId) formData.append('machineId', context.machineId)
|
||||
if (context.composantId) formData.append('composantId', context.composantId)
|
||||
if (context.productId) formData.append('productId', context.productId)
|
||||
if (context.pieceId) formData.append('pieceId', context.pieceId)
|
||||
|
||||
const result = await post('/documents', payload)
|
||||
const result = await postFormData('/documents', formData)
|
||||
if (result.success) {
|
||||
created.push(result.data as Document)
|
||||
showSuccess(`Document "${file.name}" ajouté`)
|
||||
@@ -213,7 +282,9 @@ export function useDocuments() {
|
||||
|
||||
return {
|
||||
documents,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadDocuments,
|
||||
loadDocumentsBySite,
|
||||
loadDocumentsByMachine,
|
||||
|
||||
109
app/composables/useDragReorder.ts
Normal file
109
app/composables/useDragReorder.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface DragReorderHandlers {
|
||||
draggingIndex: Ref<number | null>
|
||||
dropTargetIndex: Ref<number | null>
|
||||
onDragStart: (index: number, event: DragEvent) => void
|
||||
onDragEnter: (index: number) => void
|
||||
onDragOver: (event: DragEvent) => void
|
||||
onDrop: (index: number) => void
|
||||
onDragEnd: () => void
|
||||
reorderClass: (index: number) => string
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
interface DragReorderOptions {
|
||||
draggingClass?: string
|
||||
dropTargetClass?: string
|
||||
onReorder?: () => void
|
||||
}
|
||||
|
||||
function moveItemInPlace<T>(list: T[], from: number, to: number): void {
|
||||
if (from === to) return
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return
|
||||
const updated = list.slice()
|
||||
const [item] = updated.splice(from, 1)
|
||||
if (item === undefined) return
|
||||
updated.splice(to, 0, item)
|
||||
list.splice(0, list.length, ...updated)
|
||||
}
|
||||
|
||||
export function useDragReorder(
|
||||
getList: () => unknown[] | undefined,
|
||||
options: DragReorderOptions = {},
|
||||
): DragReorderHandlers {
|
||||
const {
|
||||
draggingClass = 'border-dashed border-primary',
|
||||
dropTargetClass = 'border-primary border-dashed bg-primary/5',
|
||||
onReorder,
|
||||
} = options
|
||||
|
||||
const draggingIndex = ref<number | null>(null)
|
||||
const dropTargetIndex = ref<number | null>(null)
|
||||
|
||||
const reset = () => {
|
||||
draggingIndex.value = null
|
||||
dropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
draggingIndex.value = index
|
||||
dropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (draggingIndex.value === null) return
|
||||
dropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
const list = getList()
|
||||
if (!Array.isArray(list)) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
const from = draggingIndex.value
|
||||
if (from === null) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(list, from, index)
|
||||
onReorder?.()
|
||||
reset()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
reset()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number): string => {
|
||||
if (draggingIndex.value === index) return draggingClass
|
||||
if (
|
||||
draggingIndex.value !== null
|
||||
&& dropTargetIndex.value === index
|
||||
&& draggingIndex.value !== index
|
||||
) {
|
||||
return dropTargetClass
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return {
|
||||
draggingIndex,
|
||||
dropTargetIndex,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
reorderClass,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
// CRUD operations
|
||||
const refreshDocuments = async () => {
|
||||
const e = entity()
|
||||
if (!e?.id) return
|
||||
if (!e?.id || e._structurePiece) return
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import {
|
||||
listModelTypes,
|
||||
createModelType,
|
||||
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true, data: state.types.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
const message = err?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de ${label}: ${message}`)
|
||||
const message = humanizeError(err?.message)
|
||||
showError(`Impossible de charger les types de ${label}.`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de ${label}: ${message}`)
|
||||
const raw = err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
|
||||
const raw = err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
|
||||
const raw = err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
|
||||
@@ -1,46 +1,23 @@
|
||||
/**
|
||||
* Machine creation page – orchestration composable.
|
||||
*
|
||||
* Consolidates entity lookup maps, option filters, label helpers,
|
||||
* template wrappers, and the finalization logic that were previously
|
||||
* inlined in pages/machines/new.vue.
|
||||
* Simplified: no more TypeMachine / skeleton system.
|
||||
* Supports direct creation or cloning from an existing machine.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
||||
import {
|
||||
useMachineCreatePreview,
|
||||
validateRequirementSelections as _validateRequirementSelections,
|
||||
resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel,
|
||||
resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel,
|
||||
} from '~/composables/useMachineCreatePreview'
|
||||
import {
|
||||
getComponentMachineAssignments,
|
||||
getPieceMachineAssignments,
|
||||
getPieceComponentAssignments,
|
||||
formatAssignmentList,
|
||||
} from '~/shared/utils/assignmentUtils'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
|
||||
export function useMachineCreatePage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
|
||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
||||
const { products, loadProducts, loading: productsLoading } = useProducts()
|
||||
const { get } = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -48,355 +25,84 @@ export function useMachineCreatePage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
const newMachine = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
typeMachineId: '',
|
||||
reference: '',
|
||||
cloneFromMachineId: '',
|
||||
})
|
||||
|
||||
const selectedMachineType = computed(() => {
|
||||
if (!newMachine.typeMachineId) return null
|
||||
return (machineTypes as any).value.find((type: any) => type.id === newMachine.typeMachineId) || null
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentById = computed(() => {
|
||||
const map = new Map()
|
||||
;((composants as any).value || []).forEach((component: any) => {
|
||||
if (component?.id) map.set(component.id, component)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceById = computed(() => {
|
||||
const map = new Map()
|
||||
;((pieces as any).value || []).forEach((piece: any) => {
|
||||
if (piece?.id) map.set(piece.id, piece)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const componentInventory = computed(() => (composants as any).value || [])
|
||||
const pieceInventory = computed(() => (pieces as any).value || [])
|
||||
const productInventory = computed(() => (products as any).value || [])
|
||||
|
||||
const productById = computed(() => {
|
||||
const map = new Map()
|
||||
;(productInventory.value || []).forEach((product: any) => {
|
||||
if (product?.id) map.set(product.id, product)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity finders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const findComponentById = (id: string) => {
|
||||
if (!id) return null
|
||||
return componentById.value.get(id) || null
|
||||
}
|
||||
|
||||
const findPieceById = (id: string): any => {
|
||||
if (!id) return null
|
||||
return pieceById.value.get(id) || findPieceInCachedOptions(id) || null
|
||||
}
|
||||
|
||||
const findProductById = (id: string) => {
|
||||
if (!id) return null
|
||||
return productById.value.get(id) || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection state (from composable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
pieceOptionsByKey,
|
||||
pieceLoadingByKey,
|
||||
selectedPieceIds,
|
||||
getPieceKey,
|
||||
findPieceInCachedOptions,
|
||||
fetchPieceOptions,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setComponentRequirementComponent,
|
||||
setPieceRequirementPiece,
|
||||
setProductRequirementProduct: _setProductRequirementProduct,
|
||||
clearRequirementSelections,
|
||||
initializeRequirementSelections,
|
||||
} = useMachineCreateSelections({
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
pieces: pieces as any,
|
||||
get: get as any,
|
||||
toast,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview / validation (from composable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { machinePreview, blockingPreviewIssues, canCreateMachine } = useMachineCreatePreview({
|
||||
newMachine,
|
||||
sites: sites as any,
|
||||
selectedMachineType,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
findProductById,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveComponentRequirementTypeLabel = (requirement: any, entry: any) =>
|
||||
_resolveComponentRequirementTypeLabel(requirement, entry, findComponentById)
|
||||
|
||||
const resolvePieceRequirementTypeLabel = (requirement: any, entry: any) =>
|
||||
_resolvePieceRequirementTypeLabel(requirement, entry, findPieceById)
|
||||
|
||||
const setProductRequirementProduct = (requirement: any, index: number, productId: string) =>
|
||||
_setProductRequirementProduct(requirement, index, productId, findProductById)
|
||||
|
||||
const validateRequirementSelections = (type: any) =>
|
||||
_validateRequirementSelections(type, {
|
||||
newMachine,
|
||||
sites: sites as any,
|
||||
selectedMachineType,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
findProductById,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine type helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineTypeLabel = (type: any) => {
|
||||
if (!type) return ''
|
||||
return type.name || 'Type de machine'
|
||||
}
|
||||
|
||||
const machineTypeDescription = (type: any) => {
|
||||
if (!type) return ''
|
||||
const parts: string[] = []
|
||||
if (type.category) parts.push(`Catégorie : ${type.category}`)
|
||||
const componentCount = type.componentRequirements?.length ?? 0
|
||||
const pieceCount = type.pieceRequirements?.length ?? 0
|
||||
const productCount = type.productRequirements?.length ?? 0
|
||||
parts.push(
|
||||
`${componentCount} composant(s)`,
|
||||
`${pieceCount} pièce(s)`,
|
||||
`${productCount} produit(s)`,
|
||||
)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getComponentOptions = (requirement: any, currentEntry: any) => {
|
||||
const requirementTypeId = requirement?.typeComposantId || requirement?.typeComposant?.id || null
|
||||
return componentInventory.value.filter((component: any) => {
|
||||
if (!component?.id) return false
|
||||
if (requirementTypeId && component.typeComposantId !== requirementTypeId) {
|
||||
return currentEntry?.composantId === component.id
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const getPieceOptions = (requirement: any, currentEntry: any, entryIndex: number) => {
|
||||
const key = getPieceKey(requirement, entryIndex)
|
||||
const cached = pieceOptionsByKey.value[key]
|
||||
if (cached) return cached
|
||||
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
||||
const usedIds = new Set(
|
||||
selectedPieceIds.value.filter((id: any) => id && (!currentEntry || id !== currentEntry.pieceId)),
|
||||
)
|
||||
return pieceInventory.value.filter((piece: any) => {
|
||||
if (requirementTypeId && piece.typePieceId !== requirementTypeId) return false
|
||||
if (!piece.id) return false
|
||||
if (currentEntry?.pieceId === piece.id) return true
|
||||
return !usedIds.has(piece.id)
|
||||
})
|
||||
}
|
||||
|
||||
const getProductOptions = (requirement: any) => {
|
||||
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
|
||||
return productInventory.value.filter((product: any) => {
|
||||
if (!product?.id) return false
|
||||
if (!requirementTypeId) return true
|
||||
const productTypeId = product.typeProductId || product.typeProduct?.id || null
|
||||
return productTypeId === requirementTypeId
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option label / description helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentOptionLabel = (component: any) => component?.name || 'Composant'
|
||||
|
||||
const componentOptionDescription = (component: any) => {
|
||||
if (!component) return ''
|
||||
const parts: string[] = []
|
||||
if (component.reference) parts.push(`Réf. ${component.reference}`)
|
||||
const constructeurName = component.constructeur?.name || component.constructeurName
|
||||
if (constructeurName) parts.push(constructeurName)
|
||||
const machineAssignments = getComponentMachineAssignments(component)
|
||||
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
||||
const productTypeName = component.product?.typeProduct?.name
|
||||
const productLabel = component.product?.name || component.product?.reference
|
||||
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const pieceOptionLabel = (piece: any) => piece?.name || 'Pièce'
|
||||
|
||||
const pieceOptionDescription = (piece: any) => {
|
||||
if (!piece) return ''
|
||||
const parts: string[] = []
|
||||
if (piece.reference) parts.push(`Réf. ${piece.reference}`)
|
||||
const constructeurName = piece.constructeur?.name || piece.constructeurName
|
||||
if (constructeurName) parts.push(constructeurName)
|
||||
const machineAssignments = getPieceMachineAssignments(piece)
|
||||
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
||||
const componentAssignments = getPieceComponentAssignments(piece)
|
||||
if (componentAssignments.length) parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
|
||||
const productTypeName = piece.product?.typeProduct?.name
|
||||
const productLabel = piece.product?.name || piece.product?.reference
|
||||
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const finalizeMachineCreation = async () => {
|
||||
if (submitting.value) return
|
||||
const type = selectedMachineType.value
|
||||
if (!type) {
|
||||
toast.showError('Merci de sélectionner un type de machine')
|
||||
return
|
||||
}
|
||||
if (!canCreateMachine.value) {
|
||||
toast.showError('Compléter les informations obligatoires avant de créer la machine')
|
||||
|
||||
if (!newMachine.name?.trim()) {
|
||||
toast.showError('Merci de renseigner un nom pour la machine')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const baseMachineData = {
|
||||
name: newMachine.name,
|
||||
siteId: newMachine.siteId,
|
||||
reference: newMachine.reference,
|
||||
typeMachineId: type.id,
|
||||
let result: any
|
||||
|
||||
if (newMachine.cloneFromMachineId) {
|
||||
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
||||
name: newMachine.name,
|
||||
siteId: newMachine.siteId,
|
||||
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
||||
})
|
||||
} else {
|
||||
result = await createMachine({
|
||||
name: newMachine.name,
|
||||
siteId: newMachine.siteId || undefined,
|
||||
reference: newMachine.reference || undefined,
|
||||
} as any)
|
||||
}
|
||||
|
||||
const hasRequirements =
|
||||
(type.componentRequirements?.length || 0) > 0 ||
|
||||
(type.pieceRequirements?.length || 0) > 0 ||
|
||||
(type.productRequirements?.length || 0) > 0
|
||||
|
||||
let componentLinks: any[] = []
|
||||
let pieceLinks: any[] = []
|
||||
let productLinks: any[] = []
|
||||
|
||||
if (hasRequirements) {
|
||||
const validationResult = validateRequirementSelections(type)
|
||||
if (!validationResult.valid) {
|
||||
toast.showError(validationResult.error as string)
|
||||
return
|
||||
}
|
||||
componentLinks = validationResult.componentLinks as any[]
|
||||
pieceLinks = validationResult.pieceLinks as any[]
|
||||
productLinks = validationResult.productLinks as any[]
|
||||
}
|
||||
|
||||
const result: any = hasRequirements
|
||||
? await createMachine(baseMachineData as any)
|
||||
: await createMachineFromType(baseMachineData as any, type)
|
||||
|
||||
if (result.success) {
|
||||
const machineId = result.data?.id
|
||||
if (hasRequirements && machineId) {
|
||||
const skeletonResult: any = await reconfigureSkeleton(machineId, {
|
||||
componentLinks,
|
||||
pieceLinks,
|
||||
productLinks,
|
||||
} as any)
|
||||
if (!skeletonResult.success) {
|
||||
// Rollback: delete the orphaned machine
|
||||
await deleteMachine(machineId).catch(() => {})
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants. La machine n\'a pas été créée.')
|
||||
return
|
||||
}
|
||||
}
|
||||
// Initialize custom fields for the machine type
|
||||
if (machineId) {
|
||||
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
|
||||
}
|
||||
|| (result.data?.machine as any)?.id
|
||||
|| null
|
||||
|
||||
newMachine.name = ''
|
||||
newMachine.siteId = ''
|
||||
newMachine.typeMachineId = ''
|
||||
newMachine.reference = ''
|
||||
clearRequirementSelections()
|
||||
await navigateTo('/machines')
|
||||
newMachine.cloneFromMachineId = ''
|
||||
|
||||
if (machineId) {
|
||||
await navigateTo(`/machine/${machineId}`)
|
||||
} else {
|
||||
await navigateTo('/machines')
|
||||
}
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine: ${result.error}`)
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Erreur lors de la création: ${error.message}`)
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watchers & lifecycle
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
watch(
|
||||
() => newMachine.typeMachineId,
|
||||
(typeId) => {
|
||||
clearRequirementSelections()
|
||||
if (!typeId) return
|
||||
const type = (machineTypes as any).value.find((item: any) => item.id === typeId)
|
||||
if (!type) return
|
||||
initializeRequirementSelections(type)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadSites(),
|
||||
loadMachineTypes(),
|
||||
loadComposants({ itemsPerPage: 200, force: true }),
|
||||
loadPieces({ itemsPerPage: 200, force: true }),
|
||||
loadProducts({ itemsPerPage: 200, force: true }),
|
||||
])
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadSites(),
|
||||
loadMachines(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -405,59 +111,11 @@ export function useMachineCreatePage() {
|
||||
|
||||
return {
|
||||
// State
|
||||
submitting,
|
||||
newMachine,
|
||||
sites,
|
||||
machineTypes,
|
||||
machineTypesLoading,
|
||||
composantsLoading,
|
||||
piecesLoading,
|
||||
productsLoading,
|
||||
selectedMachineType,
|
||||
|
||||
// Selection state
|
||||
pieceLoadingByKey,
|
||||
getPieceKey,
|
||||
fetchPieceOptions,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setComponentRequirementComponent,
|
||||
setPieceRequirementPiece,
|
||||
setProductRequirementProduct,
|
||||
|
||||
// Preview
|
||||
machinePreview,
|
||||
blockingPreviewIssues,
|
||||
canCreateMachine,
|
||||
|
||||
// Entity finders
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
findProductById,
|
||||
|
||||
// Options
|
||||
getComponentOptions,
|
||||
getPieceOptions,
|
||||
getProductOptions,
|
||||
|
||||
// Label helpers
|
||||
machineTypeLabel,
|
||||
machineTypeDescription,
|
||||
componentOptionLabel,
|
||||
componentOptionDescription,
|
||||
pieceOptionLabel,
|
||||
pieceOptionDescription,
|
||||
|
||||
// Type label resolvers
|
||||
resolveComponentRequirementTypeLabel,
|
||||
resolvePieceRequirementTypeLabel,
|
||||
machines,
|
||||
submitting,
|
||||
loading,
|
||||
|
||||
// Actions
|
||||
finalizeMachineCreation,
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
/**
|
||||
* Machine creation – preview computation and validation.
|
||||
*
|
||||
* Extracted from pages/machines/new.vue. Builds the live preview model
|
||||
* and validates requirement selections before machine creation.
|
||||
*/
|
||||
|
||||
import { computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import { extractParentLinkIdentifiers } from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
getComponentMachineAssignments,
|
||||
getPieceMachineAssignments,
|
||||
getPieceComponentAssignments,
|
||||
formatAssignmentList,
|
||||
} from '~/shared/utils/assignmentUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineCreatePreviewDeps {
|
||||
newMachine: { name: string; siteId: string; typeMachineId: string; reference: string }
|
||||
sites: Ref<AnyRecord[]>
|
||||
selectedMachineType: ComputedRef<AnyRecord | null>
|
||||
findComponentById: (id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
findProductById: (id: string) => AnyRecord | null
|
||||
getComponentRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
getPieceRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
getProductRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product type ID extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getProductTypeIdFromComponent = (component: AnyRecord | null): string | null => {
|
||||
if (!component || typeof component !== 'object') return null
|
||||
return (
|
||||
(component.product as AnyRecord)?.typeProductId ||
|
||||
((component.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
||||
component.productTypeId ||
|
||||
null
|
||||
) as string | null
|
||||
}
|
||||
|
||||
const getProductTypeIdFromPiece = (piece: AnyRecord | null): string | null => {
|
||||
if (!piece || typeof piece !== 'object') return null
|
||||
return (
|
||||
(piece.product as AnyRecord)?.typeProductId ||
|
||||
((piece.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
||||
piece.productTypeId ||
|
||||
null
|
||||
) as string | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badge helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getStatusBadgeClass = (status: string): string => {
|
||||
if (status === 'ready') return 'badge-success'
|
||||
if (status === 'warning') return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll / issue click helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2']
|
||||
|
||||
export const scrollToAnchor = (anchor: string): void => {
|
||||
if (!anchor || typeof window === 'undefined' || typeof document === 'undefined') return
|
||||
const target = document.getElementById(anchor)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
highlightClasses.forEach((cls) => target.classList.add(cls))
|
||||
window.setTimeout(() => {
|
||||
highlightClasses.forEach((cls) => target.classList.remove(cls))
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
export const handleIssueClick = (issue: AnyRecord): void => {
|
||||
if (!issue?.anchor) return
|
||||
scrollToAnchor(issue.anchor as string)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type label resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveComponentRequirementTypeLabel = (
|
||||
requirement: AnyRecord,
|
||||
entry: AnyRecord,
|
||||
findComponentById: (id: string) => AnyRecord | null,
|
||||
): string => {
|
||||
if (entry?.composantId) {
|
||||
const component = findComponentById(entry.composantId as string)
|
||||
if ((component?.typeComposant as AnyRecord)?.name) {
|
||||
return (component!.typeComposant as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
export const resolvePieceRequirementTypeLabel = (
|
||||
requirement: AnyRecord,
|
||||
entry: AnyRecord,
|
||||
findPieceById: (id: string) => AnyRecord | null,
|
||||
): string => {
|
||||
if (entry?.pieceId) {
|
||||
const piece = findPieceById(entry.pieceId as string)
|
||||
if ((piece?.typePiece as AnyRecord)?.name) {
|
||||
return (piece!.typePiece as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product requirement stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const computeProductUsageFromSelections = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): Map<string, number> => {
|
||||
const usage = new Map<string, number>()
|
||||
|
||||
const increment = (typeProductId: string | null) => {
|
||||
if (!typeProductId) return
|
||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.composantId) return
|
||||
const component = deps.findComponentById(entry.composantId as string)
|
||||
increment(getProductTypeIdFromComponent(component))
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.pieceId) return
|
||||
const piece = deps.findPieceById(entry.pieceId as string)
|
||||
increment(getProductTypeIdFromPiece(piece))
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.productId) return
|
||||
const product = deps.findProductById(entry.productId as string)
|
||||
const typeProductId = (
|
||||
product?.typeProductId ||
|
||||
(product?.typeProduct as AnyRecord)?.id ||
|
||||
entry?.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
) as string | null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
const buildProductRequirementStats = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): { stats: AnyRecord[]; usage: Map<string, number> } => {
|
||||
const usage = computeProductUsageFromSelections(type, deps)
|
||||
|
||||
const stats = ((type.productRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const typeProductId = (
|
||||
requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null
|
||||
) as string | null
|
||||
|
||||
const label = (
|
||||
(requirement.label as string)?.trim() ||
|
||||
(requirement.typeProduct as AnyRecord)?.name ||
|
||||
(requirement.typeProduct as AnyRecord)?.code ||
|
||||
'Produit requis'
|
||||
) as string
|
||||
|
||||
const typeName = ((requirement.typeProduct as AnyRecord)?.name || 'Non défini') as string
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
|
||||
|
||||
const rawEntries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = rawEntries.map((entry, index) => {
|
||||
const product = entry?.productId ? deps.findProductById(entry.productId as string) : null
|
||||
const subtitleParts: string[] = []
|
||||
if (product?.reference) subtitleParts.push(`Réf. ${product.reference}`)
|
||||
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
|
||||
const price = Number(product.supplierPrice)
|
||||
if (!Number.isNaN(price)) subtitleParts.push(`${price.toFixed(2)} €`)
|
||||
}
|
||||
if (Array.isArray(product?.constructeurs) && (product!.constructeurs as AnyRecord[]).length) {
|
||||
const cLabel = (product!.constructeurs as AnyRecord[])
|
||||
.map((c) => c?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
if (cLabel) subtitleParts.push(`Fournisseurs: ${cLabel}`)
|
||||
}
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: product ? 'complete' : 'pending',
|
||||
title: (product?.name || product?.reference || `Sélection #${index + 1}`) as string,
|
||||
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
|
||||
}
|
||||
})
|
||||
|
||||
const issues: AnyRecord[] = []
|
||||
if (count < min) {
|
||||
issues.push({
|
||||
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
if (max !== null && count > max) {
|
||||
issues.push({
|
||||
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
if (normalizedEntries.length > 0 && normalizedEntries.some((e) => e.status !== 'complete')) {
|
||||
issues.push({
|
||||
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const total = normalizedEntries.length
|
||||
const status = issues.some((i) => i.kind === 'error')
|
||||
? 'error'
|
||||
: issues.some((i) => i.kind === 'warning')
|
||||
? 'warning'
|
||||
: 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
requirement,
|
||||
label,
|
||||
typeName,
|
||||
count,
|
||||
min,
|
||||
max,
|
||||
completed,
|
||||
total,
|
||||
entries: normalizedEntries,
|
||||
issues,
|
||||
allowNewModels: requirement.allowNewModels ?? true,
|
||||
status,
|
||||
}
|
||||
})
|
||||
|
||||
return { stats, usage }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const validateRequirementSelections = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): AnyRecord => {
|
||||
const errors: string[] = []
|
||||
const componentLinksPayload: AnyRecord[] = []
|
||||
const pieceLinksPayload: AnyRecord[] = []
|
||||
const productLinksPayload: AnyRecord[] = []
|
||||
|
||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.composantId) {
|
||||
errors.push(`Sélectionner un composant existant pour "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}".`)
|
||||
return
|
||||
}
|
||||
const component = deps.findComponentById(entry.composantId as string)
|
||||
if (!component) {
|
||||
errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typeComposantId || (requirement.typeComposant as AnyRecord)?.id || null) as string | null
|
||||
if (requiredTypeId && component.typeComposantId && component.typeComposantId !== requiredTypeId) {
|
||||
errors.push(`Le composant "${component.name || component.id}" n'appartient pas à la famille attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, composantId: entry.composantId }
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
componentLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.pieceId) {
|
||||
errors.push(`Sélectionner une pièce existante pour "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}".`)
|
||||
return
|
||||
}
|
||||
const piece = deps.findPieceById(entry.pieceId as string)
|
||||
if (!piece) {
|
||||
errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typePieceId || (requirement.typePiece as AnyRecord)?.id || null) as string | null
|
||||
if (requiredTypeId && piece.typePieceId && piece.typePieceId !== requiredTypeId) {
|
||||
errors.push(`La pièce "${piece.name || piece.id}" n'appartient pas à la famille attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, pieceId: entry.pieceId }
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
pieceLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
const { stats: productStats } = buildProductRequirementStats(type, deps)
|
||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.productId) {
|
||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
||||
return
|
||||
}
|
||||
const product = deps.findProductById(entry.productId as string)
|
||||
if (!product) {
|
||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null) as string | null
|
||||
const productTypeId = (product.typeProductId || (product.typeProduct as AnyRecord)?.id || entry.typeProductId || null) as string | null
|
||||
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
|
||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
productLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
productStats.forEach((stat) => {
|
||||
((stat.issues || []) as AnyRecord[])
|
||||
.filter((issue) => issue.kind === 'error')
|
||||
.forEach((issue) => errors.push(issue.message as string))
|
||||
})
|
||||
|
||||
if (errors.length > 0) return { valid: false, error: errors[0] }
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
componentLinks: componentLinksPayload,
|
||||
pieceLinks: pieceLinksPayload,
|
||||
productLinks: productLinksPayload,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main preview composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMachineCreatePreview(deps: MachineCreatePreviewDeps) {
|
||||
const machinePreview = computed(() => {
|
||||
const type = deps.selectedMachineType.value
|
||||
if (!type) return null
|
||||
|
||||
const trimmedName = (deps.newMachine.name || '').trim()
|
||||
const currentSite = deps.newMachine.siteId
|
||||
? deps.sites.value.find((site) => site.id === deps.newMachine.siteId) || null
|
||||
: null
|
||||
const trimmedReference = (deps.newMachine.reference || '').trim()
|
||||
|
||||
const baseFields = [
|
||||
{ key: 'name', label: 'Nom', display: trimmedName || 'À renseigner', status: trimmedName ? 'complete' : 'missing' },
|
||||
{ key: 'site', label: 'Site', display: (currentSite?.name || 'Sélectionner un site') as string, status: currentSite ? 'complete' : 'missing' },
|
||||
{ key: 'type', label: 'Type sélectionné', display: type.name as string, status: 'complete' },
|
||||
{ key: 'reference', label: 'Référence', display: trimmedReference || 'Non renseignée', status: trimmedReference ? 'complete' : 'optional' },
|
||||
]
|
||||
|
||||
const baseIssues: AnyRecord[] = []
|
||||
if (!trimmedName) baseIssues.push({ message: 'Renseigner un nom de machine.', kind: 'error', anchor: 'machine-field-name' })
|
||||
if (!currentSite) baseIssues.push({ message: "Sélectionner un site d'affectation.", kind: 'error', anchor: 'machine-field-site' })
|
||||
|
||||
const baseStatus = baseIssues.some((issue) => issue.kind === 'error') ? 'error' : 'ready'
|
||||
|
||||
// Component groups
|
||||
const componentGroups = ((type.componentRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = entries.map((entry, index) => {
|
||||
const selectedComponent = entry.composantId ? deps.findComponentById(entry.composantId as string) : null
|
||||
const displayName = (selectedComponent?.name || (requirement.typeComposant as AnyRecord)?.name || 'Composant') as string
|
||||
const subtitleParts: string[] = []
|
||||
if (selectedComponent?.reference) subtitleParts.push(`Réf. ${selectedComponent.reference}`)
|
||||
const constructeurName = (selectedComponent?.constructeur as AnyRecord)?.name || selectedComponent?.constructeurName
|
||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
||||
const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : []
|
||||
const assignmentLabel = formatAssignmentList(machineAssignments)
|
||||
if (assignmentLabel) subtitleParts.push(`Liée à ${assignmentLabel}`)
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: entry.composantId ? 'complete' : 'pending',
|
||||
title: displayName,
|
||||
subtitle: subtitleParts.join(' • ') || null,
|
||||
assignmentLabel,
|
||||
assignments: machineAssignments,
|
||||
}
|
||||
})
|
||||
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const issues: AnyRecord[] = []
|
||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
|
||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
||||
const hasWarnings = completed < entries.length
|
||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
label: (requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Famille de composants') as string,
|
||||
typeName: ((requirement.typeComposant as AnyRecord)?.name || 'Non défini') as string,
|
||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
||||
}
|
||||
})
|
||||
|
||||
// Piece groups
|
||||
const pieceGroups = ((type.pieceRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = entries.map((entry, index) => {
|
||||
const selectedPiece = entry.pieceId ? deps.findPieceById(entry.pieceId as string) : null
|
||||
const displayName = (selectedPiece?.name || (requirement.typePiece as AnyRecord)?.name || 'Pièce') as string
|
||||
const subtitleParts: string[] = []
|
||||
if (selectedPiece?.reference) subtitleParts.push(`Réf. ${selectedPiece.reference}`)
|
||||
const constructeurName = (selectedPiece?.constructeur as AnyRecord)?.name || selectedPiece?.constructeurName
|
||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
||||
const machineAssignments = selectedPiece ? getPieceMachineAssignments(selectedPiece) : []
|
||||
const machineAssignmentLabel = formatAssignmentList(machineAssignments)
|
||||
if (machineAssignmentLabel) subtitleParts.push(`Machines: ${machineAssignmentLabel}`)
|
||||
const componentAssignments = selectedPiece ? getPieceComponentAssignments(selectedPiece) : []
|
||||
const componentAssignmentLabel = formatAssignmentList(componentAssignments)
|
||||
if (componentAssignmentLabel) subtitleParts.push(`Composants: ${componentAssignmentLabel}`)
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: entry.pieceId ? 'complete' : 'pending',
|
||||
title: displayName,
|
||||
subtitle: subtitleParts.join(' • ') || null,
|
||||
machineAssignmentLabel, componentAssignmentLabel,
|
||||
machineAssignments, componentAssignments,
|
||||
}
|
||||
})
|
||||
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const issues: AnyRecord[] = []
|
||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner une pièce pour chaque entrée.', kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
|
||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
||||
const hasWarnings = completed < entries.length
|
||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
label: (requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Groupe de pièces') as string,
|
||||
typeName: ((requirement.typePiece as AnyRecord)?.name || 'Non défini') as string,
|
||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
||||
}
|
||||
})
|
||||
|
||||
// Product groups
|
||||
const { stats: productGroups } = buildProductRequirementStats(type, deps)
|
||||
|
||||
// Aggregate
|
||||
const aggregatedIssues = [
|
||||
...baseIssues.map((issue) => ({ ...issue, scope: 'Informations générales' })),
|
||||
...componentGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
||||
...pieceGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
||||
...productGroups.flatMap((group: AnyRecord) => ((group.issues || []) as AnyRecord[]).map((issue) => ({ ...issue, scope: group.label }))),
|
||||
]
|
||||
|
||||
const statuses = [
|
||||
baseStatus,
|
||||
...componentGroups.map((g) => g.status),
|
||||
...pieceGroups.map((g) => g.status),
|
||||
...productGroups.map((g: AnyRecord) => g.status as string),
|
||||
]
|
||||
|
||||
const overallStatus = statuses.includes('error') ? 'error' : statuses.includes('warning') ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
base: { fields: baseFields, issues: baseIssues, status: baseStatus },
|
||||
componentGroups,
|
||||
pieceGroups,
|
||||
productGroups,
|
||||
type: {
|
||||
name: type.name,
|
||||
category: type.category || null,
|
||||
hasStructuredDefinition:
|
||||
((type.componentRequirements as unknown[])?.length || 0) > 0 ||
|
||||
((type.pieceRequirements as unknown[])?.length || 0) > 0 ||
|
||||
((type.productRequirements as unknown[])?.length || 0) > 0,
|
||||
},
|
||||
status: overallStatus,
|
||||
ready: overallStatus === 'ready',
|
||||
issues: aggregatedIssues,
|
||||
}
|
||||
})
|
||||
|
||||
const blockingPreviewIssues = computed(() => {
|
||||
if (!machinePreview.value) return []
|
||||
return (machinePreview.value.issues as AnyRecord[]).filter((issue) => issue.kind === 'error')
|
||||
})
|
||||
|
||||
const canCreateMachine = computed(() => {
|
||||
if (!machinePreview.value) return false
|
||||
return blockingPreviewIssues.value.length === 0
|
||||
})
|
||||
|
||||
return {
|
||||
machinePreview,
|
||||
blockingPreviewIssues,
|
||||
canCreateMachine,
|
||||
}
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
/**
|
||||
* Machine creation – requirement selection state management.
|
||||
*
|
||||
* Extracted from pages/machines/new.vue. Manages the reactive selection state
|
||||
* for component / piece / product requirements when creating a new machine.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineCreateSelectionsDeps {
|
||||
findComponentById: (id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
pieces: { value: AnyRecord[] }
|
||||
get: (url: string) => Promise<AnyRecord>
|
||||
toast: { showError: (msg: string) => void }
|
||||
}
|
||||
|
||||
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
||||
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactive state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
|
||||
const pieceOptionsByKey = ref<Record<string, AnyRecord[]>>({})
|
||||
const pieceLoadingByKey = ref<Record<string, boolean>>({})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Piece option caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getPieceKey = (requirement: AnyRecord, entryIndex: number): string =>
|
||||
`${requirement?.id || 'req'}:${entryIndex}`
|
||||
|
||||
const findPieceInCachedOptions = (id: string): AnyRecord | null => {
|
||||
if (!id) return null
|
||||
const buckets = Object.values(pieceOptionsByKey.value || {})
|
||||
for (const bucket of buckets) {
|
||||
if (!Array.isArray(bucket)) continue
|
||||
const found = bucket.find((piece) => piece?.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const cachePieceIfMissing = (piece: AnyRecord): void => {
|
||||
if (!piece?.id) return
|
||||
const current = Array.isArray(pieces.value) ? pieces.value : []
|
||||
if (current.some((p: AnyRecord) => p?.id === piece.id)) return
|
||||
pieces.value = [...current, piece]
|
||||
}
|
||||
|
||||
const fetchPieceOptions = async (
|
||||
requirement: AnyRecord,
|
||||
entryIndex: number,
|
||||
term = '',
|
||||
): Promise<void> => {
|
||||
const key = getPieceKey(requirement, entryIndex)
|
||||
if (pieceLoadingByKey.value[key]) return
|
||||
|
||||
const requirementTypeId =
|
||||
(requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null) as string | null
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
if (term && term.trim()) params.set('name', term.trim())
|
||||
if (requirementTypeId) params.set('typePiece', `/api/model_types/${requirementTypeId}`)
|
||||
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
pieceOptionsByKey.value = {
|
||||
...pieceOptionsByKey.value,
|
||||
[key]: extractCollection(result.data) as AnyRecord[],
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry getters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
componentRequirementSelections[requirementId] || []
|
||||
|
||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
pieceRequirementSelections[requirementId] || []
|
||||
|
||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
productRequirementSelections[requirementId] || []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typeComposantId: requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
||||
composantId: source?.composantId || null,
|
||||
definition: {},
|
||||
})
|
||||
|
||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typePieceId: requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
||||
pieceId: source?.pieceId || null,
|
||||
definition: {},
|
||||
})
|
||||
|
||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typeProductId:
|
||||
source?.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null,
|
||||
productId: source?.productId || null,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selected piece IDs (for dedup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const selectedPieceIds = computed(() => {
|
||||
const ids: string[] = []
|
||||
Object.values(pieceRequirementSelections).forEach((entries) => {
|
||||
;(entries || []).forEach((entry) => {
|
||||
if (entry?.pieceId) ids.push(entry.pieceId as string)
|
||||
})
|
||||
})
|
||||
return ids
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const addComponentSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
componentRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createComponentSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeComponentSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getComponentRequirementEntries(requirementId)
|
||||
componentRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
const addPieceSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
pieceRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createPieceSelectionEntry(requirement),
|
||||
]
|
||||
fetchPieceOptions(requirement, entries.length).catch(() => {})
|
||||
}
|
||||
|
||||
const removePieceSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getPieceRequirementEntries(requirementId)
|
||||
pieceRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
const addProductSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
productRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createProductSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeProductSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getProductRequirementEntries(requirementId)
|
||||
productRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection setters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const setComponentRequirementComponent = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
componentId: string,
|
||||
): void => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
entry.composantId = componentId || null
|
||||
if (componentId) {
|
||||
const component = findComponentById(componentId)
|
||||
entry.typeComposantId = component?.typeComposantId || requirement?.typeComposantId || null
|
||||
} else {
|
||||
entry.typeComposantId = requirement?.typeComposantId || null
|
||||
}
|
||||
}
|
||||
|
||||
const setPieceRequirementPiece = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
pieceId: string,
|
||||
): void => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
entry.pieceId = pieceId || null
|
||||
if (pieceId) {
|
||||
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
|
||||
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
||||
if (piece) cachePieceIfMissing(piece as AnyRecord)
|
||||
} else {
|
||||
entry.typePieceId = requirement?.typePieceId || null
|
||||
}
|
||||
}
|
||||
|
||||
const setProductRequirementProduct = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
productId: string,
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
): void => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
|
||||
const normalizedProductId = productId || null
|
||||
entry.productId = normalizedProductId
|
||||
|
||||
if (normalizedProductId) {
|
||||
const product = findProductById(normalizedProductId)
|
||||
entry.typeProductId =
|
||||
product?.typeProductId ||
|
||||
(product?.typeProduct as AnyRecord)?.id ||
|
||||
entry.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
} else {
|
||||
entry.typeProductId =
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const clearRequirementSelections = (): void => {
|
||||
Object.keys(componentRequirementSelections).forEach((key) => {
|
||||
delete componentRequirementSelections[key]
|
||||
})
|
||||
Object.keys(pieceRequirementSelections).forEach((key) => {
|
||||
delete pieceRequirementSelections[key]
|
||||
})
|
||||
Object.keys(productRequirementSelections).forEach((key) => {
|
||||
delete productRequirementSelections[key]
|
||||
})
|
||||
}
|
||||
|
||||
const initializeRequirementSelections = (type: AnyRecord): void => {
|
||||
const componentRequirements = (type.componentRequirements || []) as AnyRecord[]
|
||||
const pieceRequirements = (type.pieceRequirements || []) as AnyRecord[]
|
||||
const productRequirements = (type.productRequirements || []) as AnyRecord[]
|
||||
|
||||
componentRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
componentRequirementSelections[requirement.id as string] = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createComponentSelectionEntry(requirement),
|
||||
)
|
||||
} else {
|
||||
componentRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
|
||||
pieceRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
const entries = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createPieceSelectionEntry(requirement),
|
||||
)
|
||||
pieceRequirementSelections[requirement.id as string] = entries
|
||||
entries.forEach((_: unknown, index: number) => {
|
||||
fetchPieceOptions(requirement, index).catch(() => {})
|
||||
})
|
||||
} else {
|
||||
pieceRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
|
||||
productRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
productRequirementSelections[requirement.id as string] = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createProductSelectionEntry(requirement),
|
||||
)
|
||||
} else {
|
||||
productRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
componentRequirementSelections,
|
||||
pieceRequirementSelections,
|
||||
productRequirementSelections,
|
||||
pieceOptionsByKey,
|
||||
pieceLoadingByKey,
|
||||
selectedPieceIds,
|
||||
getPieceKey,
|
||||
findPieceInCachedOptions,
|
||||
fetchPieceOptions,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setComponentRequirementComponent,
|
||||
setPieceRequirementPiece,
|
||||
setProductRequirementProduct,
|
||||
clearRequirementSelections,
|
||||
initializeRequirementSelections,
|
||||
}
|
||||
}
|
||||
327
app/composables/useMachineCustomFieldDefs.ts
Normal file
327
app/composables/useMachineCustomFieldDefs.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type MachineFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date'
|
||||
|
||||
export interface MachineCustomFieldEditorField {
|
||||
uid: string
|
||||
serverId?: string
|
||||
name: string
|
||||
type: MachineFieldType
|
||||
required: boolean
|
||||
optionsText: string
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
interface InitialDef {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options?: string[]
|
||||
orderIndex: number
|
||||
defaultValue?: unknown
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
machineId: string
|
||||
initialDefs: InitialDef[]
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
let uidCounter = 0
|
||||
const createUid = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
uidCounter += 1
|
||||
return `mcf-${Date.now().toString(36)}-${uidCounter}`
|
||||
}
|
||||
|
||||
const normalizeLineEndings = (value: string): string =>
|
||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const toEditorField = (def: InitialDef, index: number): MachineCustomFieldEditorField => ({
|
||||
uid: createUid(),
|
||||
serverId: def.id,
|
||||
name: def.name || '',
|
||||
type: (def.type || 'text') as MachineFieldType,
|
||||
required: Boolean(def.required),
|
||||
optionsText: normalizeLineEndings(
|
||||
Array.isArray(def.options) ? def.options.join('\n') : '',
|
||||
),
|
||||
orderIndex: typeof def.orderIndex === 'number' ? def.orderIndex : index,
|
||||
})
|
||||
|
||||
const hydrateFields = (defs: InitialDef[]): MachineCustomFieldEditorField[] =>
|
||||
defs
|
||||
.map((def, index) => toEditorField(def, index))
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||
|
||||
const buildSnapshot = (defs: InitialDef[]): Map<string, InitialDef> => {
|
||||
const map = new Map<string, InitialDef>()
|
||||
for (const def of defs) {
|
||||
map.set(def.id, def)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const applyOrderIndex = (
|
||||
list: MachineCustomFieldEditorField[],
|
||||
): MachineCustomFieldEditorField[] =>
|
||||
list.map((field, index) => ({ ...field, orderIndex: index }))
|
||||
|
||||
const parseOptions = (optionsText: string): string[] =>
|
||||
normalizeLineEndings(optionsText)
|
||||
.split('\n')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o.length > 0)
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function useMachineCustomFieldDefs(deps: Deps) {
|
||||
const { apiCall } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
// --- State ---
|
||||
|
||||
const fields = ref<MachineCustomFieldEditorField[]>(hydrateFields(deps.initialDefs))
|
||||
const initialSnapshot = ref<Map<string, InitialDef>>(buildSnapshot(deps.initialDefs))
|
||||
const saving = ref(false)
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
const addField = () => {
|
||||
const next = fields.value.slice()
|
||||
next.push({
|
||||
uid: createUid(),
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
orderIndex: next.length,
|
||||
})
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
const removeField = (index: number) => {
|
||||
const next = fields.value.filter((_, i) => i !== index)
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
// --- Drag & drop ---
|
||||
|
||||
const dragState = reactive({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
})
|
||||
|
||||
const resetDragState = () => {
|
||||
dragState.draggingIndex = null
|
||||
dragState.dropTargetIndex = null
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
dragState.draggingIndex = index
|
||||
dragState.dropTargetIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (dragState.draggingIndex === null) return
|
||||
dragState.dropTargetIndex = index
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
const from = dragState.draggingIndex
|
||||
if (from === null) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
if (from === index) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const list = fields.value.slice()
|
||||
if (from < 0 || index < 0 || from >= list.length || index >= list.length) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const [moved] = list.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
list.splice(index, 0, moved)
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number): string => {
|
||||
if (dragState.draggingIndex === index) {
|
||||
return 'border-dashed border-primary bg-primary/5'
|
||||
}
|
||||
if (
|
||||
dragState.draggingIndex !== null
|
||||
&& dragState.dropTargetIndex === index
|
||||
&& dragState.draggingIndex !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/10'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// --- Save ---
|
||||
|
||||
const saveDefinitions = async () => {
|
||||
if (saving.value) return
|
||||
|
||||
// Validate: remove empty-name fields before saving
|
||||
const emptyNameFields = fields.value.filter(f => !f.name.trim() && !f.serverId)
|
||||
if (emptyNameFields.length > 0) {
|
||||
fields.value = applyOrderIndex(fields.value.filter(f => f.name.trim() || f.serverId))
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const snapshot = initialSnapshot.value
|
||||
const currentServerIds = new Set(
|
||||
fields.value.filter(f => f.serverId).map(f => f.serverId!),
|
||||
)
|
||||
|
||||
// DELETE removed fields
|
||||
const deletedIds = [...snapshot.keys()].filter(id => !currentServerIds.has(id))
|
||||
for (const id of deletedIds) {
|
||||
const result = await apiCall(`/custom_fields/${id}`, { method: 'DELETE' })
|
||||
if (!result.success) {
|
||||
showError('Erreur lors de la suppression d\'un champ personnalisé')
|
||||
await deps.onSaved()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let hasNewFields = false
|
||||
|
||||
for (const field of fields.value) {
|
||||
const name = field.name.trim()
|
||||
if (!name) continue
|
||||
|
||||
const options = field.type === 'select' ? parseOptions(field.optionsText) : []
|
||||
|
||||
if (!field.serverId) {
|
||||
// POST new field
|
||||
hasNewFields = true
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options,
|
||||
orderIndex: field.orderIndex,
|
||||
machine: `/api/machines/${deps.machineId}`,
|
||||
}
|
||||
|
||||
const result = await apiCall('/custom_fields', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!result.success) {
|
||||
showError('Erreur lors de la création d\'un champ personnalisé')
|
||||
await deps.onSaved()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// PATCH modified field
|
||||
const original = snapshot.get(field.serverId)
|
||||
const originalOptions = Array.isArray(original?.options)
|
||||
? original.options.join('\n')
|
||||
: ''
|
||||
const currentOptions = field.type === 'select' ? field.optionsText : ''
|
||||
|
||||
const changed
|
||||
= original?.name !== name
|
||||
|| original?.type !== field.type
|
||||
|| original?.required !== field.required
|
||||
|| normalizeLineEndings(originalOptions) !== normalizeLineEndings(currentOptions)
|
||||
|| original?.orderIndex !== field.orderIndex
|
||||
|
||||
if (changed) {
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options,
|
||||
orderIndex: field.orderIndex,
|
||||
}
|
||||
|
||||
const result = await apiCall(`/custom_fields/${field.serverId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!result.success) {
|
||||
showError('Erreur lors de la mise à jour d\'un champ personnalisé')
|
||||
await deps.onSaved()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize missing custom field values if new fields were created
|
||||
if (hasNewFields) {
|
||||
await apiCall(`/machines/${deps.machineId}/add-custom-fields`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
showSuccess('Champs personnalisés sauvegardés avec succès')
|
||||
await deps.onSaved()
|
||||
} catch {
|
||||
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
|
||||
await deps.onSaved()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reinit ---
|
||||
|
||||
const reinit = (newDefs: InitialDef[]) => {
|
||||
fields.value = hydrateFields(newDefs)
|
||||
initialSnapshot.value = buildSnapshot(newDefs)
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
saving,
|
||||
dragState,
|
||||
addField,
|
||||
removeField,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
reorderClass,
|
||||
saveDefinitions,
|
||||
reinit,
|
||||
}
|
||||
}
|
||||
396
app/composables/useMachineDetailCustomFields.ts
Normal file
396
app/composables/useMachineDetailCustomFields.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Machine detail — custom field management sub-composable.
|
||||
*
|
||||
* Handles custom field resolution, display filtering, sync and updates
|
||||
* for machines, components and pieces.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
shouldDisplayCustomField,
|
||||
normalizeExistingCustomFieldDefinitions,
|
||||
normalizeCustomFieldValueEntry,
|
||||
mergeCustomFieldValuesWithDefinitions,
|
||||
dedupeCustomFieldEntries,
|
||||
} from '~/shared/utils/customFieldUtils'
|
||||
import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailCustomFieldsDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
isEditMode: Ref<boolean>
|
||||
constructeurs: Ref<unknown[]>
|
||||
resolveProductReference: (source: AnyRecord) => { product: unknown; productId: string | null }
|
||||
getProductDisplay: (source: AnyRecord) => unknown
|
||||
}
|
||||
|
||||
export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps) {
|
||||
const { machine, isEditMode, constructeurs, resolveProductReference, getProductDisplay } = deps
|
||||
const {
|
||||
upsertCustomFieldValue,
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
} = useCustomFields()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineCustomFields = ref<AnyRecord[]>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const visibleMachineCustomFields = computed(() => {
|
||||
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||
if (isEditMode.value) return fields
|
||||
return fields.filter((field) => shouldDisplayCustomField(field))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transform helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getStructureCustomFields = (structure: unknown): AnyRecord[] => {
|
||||
if (!structure || typeof structure !== 'object') return []
|
||||
const normalized = normalizeStructureForEditor(structure as any) as any
|
||||
return Array.isArray(normalized?.customFields)
|
||||
? (normalized.customFields as AnyRecord[])
|
||||
: []
|
||||
}
|
||||
|
||||
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
|
||||
return (piecesData || []).map((piece) => {
|
||||
const typePiece = (piece.typePiece as AnyRecord) || {}
|
||||
|
||||
const normalizeStructureDefs = (structure: unknown) =>
|
||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||
|
||||
const normalizedStructureDefs = [
|
||||
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
|
||||
normalizeStructureDefs(typePiece.structure),
|
||||
]
|
||||
|
||||
const valueEntries = [
|
||||
...(Array.isArray(piece.customFieldValues) ? piece.customFieldValues : []),
|
||||
...(Array.isArray(piece.customFields)
|
||||
? (piece.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
...(Array.isArray(typePiece.customFieldValues)
|
||||
? (typePiece.customFieldValues as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
]
|
||||
|
||||
const customFields = dedupeCustomFieldEntries(
|
||||
mergeCustomFieldValuesWithDefinitions(
|
||||
valueEntries,
|
||||
normalizeExistingCustomFieldDefinitions(piece.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
|
||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
||||
),
|
||||
)
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(
|
||||
piece.constructeurs,
|
||||
piece.constructeurIds,
|
||||
piece.constructeurId,
|
||||
piece.constructeur,
|
||||
(piece.originalPiece as AnyRecord)?.constructeurs,
|
||||
(piece.originalPiece as AnyRecord)?.constructeurIds,
|
||||
(piece.originalPiece as AnyRecord)?.constructeurId,
|
||||
(piece.originalPiece as AnyRecord)?.constructeur,
|
||||
)
|
||||
|
||||
const { product: resolvedProduct, productId: resolvedProductId } =
|
||||
resolveProductReference(piece)
|
||||
|
||||
const constructeursList = resolveConstructeurs(
|
||||
constructeurIds,
|
||||
Array.isArray(piece.constructeurs) ? (piece.constructeurs as any[]) : [],
|
||||
piece.constructeur ? [piece.constructeur as any] : [],
|
||||
Array.isArray((piece.originalPiece as AnyRecord)?.constructeurs)
|
||||
? ((piece.originalPiece as AnyRecord).constructeurs as any[])
|
||||
: [],
|
||||
(piece.originalPiece as AnyRecord)?.constructeur
|
||||
? [(piece.originalPiece as AnyRecord).constructeur as any]
|
||||
: [],
|
||||
constructeurs.value as any,
|
||||
) as any[]
|
||||
|
||||
const normalizedPiece = {
|
||||
...piece,
|
||||
product: resolvedProduct || piece.product || null,
|
||||
productId: resolvedProductId || piece.productId || (piece.product as AnyRecord)?.id || null,
|
||||
}
|
||||
const productDisplay = getProductDisplay(normalizedPiece)
|
||||
|
||||
return {
|
||||
...normalizedPiece,
|
||||
customFields,
|
||||
documents: piece.documents || [],
|
||||
constructeurs: constructeursList,
|
||||
constructeur: constructeursList[0] || piece.constructeur || null,
|
||||
constructeurIds,
|
||||
constructeurId: constructeurIds[0] || null,
|
||||
typePieceId:
|
||||
piece.typePieceId ||
|
||||
(piece.typePiece as AnyRecord)?.id ||
|
||||
null,
|
||||
__productDisplay: productDisplay,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
|
||||
const normalizeStructureDefs = (structure: unknown) =>
|
||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||
|
||||
return (componentsData || []).map((component) => {
|
||||
const type = (component.typeComposant as AnyRecord) || {}
|
||||
|
||||
const normalizedStructureDefs = [
|
||||
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
|
||||
normalizeStructureDefs(type.structure),
|
||||
]
|
||||
|
||||
const actualComponent = (component.originalComposant as AnyRecord) || component
|
||||
|
||||
const valueEntries = [
|
||||
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []),
|
||||
...(Array.isArray(component.customFields)
|
||||
? (component.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
...(Array.isArray(actualComponent?.customFields)
|
||||
? (actualComponent.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
]
|
||||
|
||||
const customFields = dedupeCustomFieldEntries(
|
||||
mergeCustomFieldValuesWithDefinitions(
|
||||
valueEntries,
|
||||
normalizeExistingCustomFieldDefinitions(component.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((component.definition as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions(type.customFields),
|
||||
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
|
||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
||||
),
|
||||
)
|
||||
|
||||
const piecesTransformed = component.pieces
|
||||
? transformCustomFields(component.pieces as AnyRecord[]).map((p) => ({
|
||||
...p,
|
||||
parentComponentName: component.name,
|
||||
}))
|
||||
: []
|
||||
|
||||
const subComponents = component.sousComposants
|
||||
? transformComponentCustomFields(component.sousComposants as AnyRecord[])
|
||||
: []
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(
|
||||
component.constructeurs,
|
||||
component.constructeurIds,
|
||||
component.constructeurId,
|
||||
component.constructeur,
|
||||
actualComponent?.constructeurs,
|
||||
actualComponent?.constructeurIds,
|
||||
actualComponent?.constructeurId,
|
||||
actualComponent?.constructeur,
|
||||
)
|
||||
|
||||
const constructeursList = resolveConstructeurs(
|
||||
constructeurIds,
|
||||
Array.isArray(component.constructeurs) ? (component.constructeurs as any[]) : [],
|
||||
component.constructeur ? [component.constructeur as any] : [],
|
||||
Array.isArray(actualComponent?.constructeurs)
|
||||
? (actualComponent.constructeurs as any[])
|
||||
: [],
|
||||
actualComponent?.constructeur ? [actualComponent.constructeur as any] : [],
|
||||
constructeurs.value as any,
|
||||
) as any[]
|
||||
|
||||
const { product: resolvedProduct, productId: resolvedProductId } =
|
||||
resolveProductReference(component)
|
||||
const normalizedComponent = {
|
||||
...component,
|
||||
product: resolvedProduct || component.product || null,
|
||||
productId:
|
||||
resolvedProductId || component.productId || (component.product as AnyRecord)?.id || null,
|
||||
}
|
||||
const productDisplay = getProductDisplay(normalizedComponent)
|
||||
|
||||
return {
|
||||
...normalizedComponent,
|
||||
customFields,
|
||||
pieces: piecesTransformed,
|
||||
subComponents,
|
||||
documents: component.documents || [],
|
||||
constructeurs: constructeursList,
|
||||
constructeur: constructeursList[0] || component.constructeur || null,
|
||||
constructeurIds,
|
||||
constructeurId: constructeurIds[0] || null,
|
||||
typeComposantId:
|
||||
component.typeComposantId ||
|
||||
(component.typeComposant as AnyRecord)?.id ||
|
||||
null,
|
||||
__productDisplay: productDisplay,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine custom field methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const syncMachineCustomFields = () => {
|
||||
if (!machine.value) {
|
||||
machineCustomFields.value = []
|
||||
return
|
||||
}
|
||||
const valueEntries = [
|
||||
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []),
|
||||
...(Array.isArray(machine.value.customFields)
|
||||
? (machine.value.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
]
|
||||
const merged = dedupeCustomFieldEntries(
|
||||
mergeCustomFieldValuesWithDefinitions(
|
||||
valueEntries,
|
||||
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
|
||||
),
|
||||
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
|
||||
machineCustomFields.value = merged
|
||||
}
|
||||
|
||||
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
|
||||
if (!field) return
|
||||
field.value = value
|
||||
if (field.customFieldValueId && (machine.value as AnyRecord)?.customFieldValues) {
|
||||
const stored = ((machine.value as AnyRecord).customFieldValues as AnyRecord[]).find(
|
||||
(fv) => fv.id === field.customFieldValueId,
|
||||
)
|
||||
if (stored) stored.value = value
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineCustomField = async (field: AnyRecord) => {
|
||||
if (!machine.value || !field) return
|
||||
|
||||
const { id: customFieldId, customFieldValueId } = field
|
||||
const fieldLabel = (field.name as string) || 'Champ personnalisé'
|
||||
|
||||
try {
|
||||
if (customFieldValueId) {
|
||||
const result: any = await updateCustomFieldValueApi(customFieldValueId as string, {
|
||||
value: field.value ?? '',
|
||||
} as any)
|
||||
if (result.success) {
|
||||
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
|
||||
syncMachineCustomFields()
|
||||
} else {
|
||||
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!customFieldId) {
|
||||
toast.showError(
|
||||
'Impossible de mettre à jour ce champ personnalisé (identifiant manquant).',
|
||||
)
|
||||
return
|
||||
}
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId as string,
|
||||
'machine',
|
||||
machine.value.id as string,
|
||||
field.value ?? '',
|
||||
)
|
||||
if (result.success) {
|
||||
const createdValue = result.data as AnyRecord
|
||||
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
|
||||
if (createdValue?.id) {
|
||||
if (!createdValue.customField) {
|
||||
createdValue.customField = {
|
||||
id: customFieldId,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
}
|
||||
}
|
||||
field.customFieldValueId = createdValue.id
|
||||
field.readOnly = false
|
||||
const existingValues = Array.isArray(machine.value.customFieldValues)
|
||||
? (machine.value.customFieldValues as AnyRecord[]).filter(
|
||||
(item) => item.id !== createdValue.id,
|
||||
)
|
||||
: []
|
||||
machine.value.customFieldValues = [...existingValues, createdValue]
|
||||
}
|
||||
syncMachineCustomFields()
|
||||
} else {
|
||||
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du champ personnalisé de la machine:', error)
|
||||
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceCustomField = async (fieldUpdate: AnyRecord) => {
|
||||
try {
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
fieldUpdate.fieldId as string,
|
||||
'piece',
|
||||
fieldUpdate.pieceId as string,
|
||||
fieldUpdate.value,
|
||||
)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Champ personnalisé mis à jour avec succès')
|
||||
} else {
|
||||
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
|
||||
console.error('Erreur lors de la mise à jour du champ personnalisé:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
machineCustomFields,
|
||||
|
||||
// Computed
|
||||
visibleMachineCustomFields,
|
||||
|
||||
// Transform functions
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
|
||||
// Methods
|
||||
syncMachineCustomFields,
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
146
app/composables/useMachineDetailDocuments.ts
Normal file
146
app/composables/useMachineDetailDocuments.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Machine detail — document management sub-composable.
|
||||
*
|
||||
* Handles document loading, upload, delete and preview state.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailDocumentsDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
}
|
||||
|
||||
export function useMachineDetailDocuments(deps: MachineDetailDocumentsDeps) {
|
||||
const { machine } = deps
|
||||
const {
|
||||
uploadDocuments,
|
||||
deleteDocument,
|
||||
loadDocumentsByMachine,
|
||||
loadDocumentsByProduct,
|
||||
} = useDocuments()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineDocumentFiles = ref<File[]>([])
|
||||
const machineDocumentsUploading = ref(false)
|
||||
const machineDocumentsLoaded = ref(false)
|
||||
const previewDocument = ref<AnyRecord | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineDocumentsList = computed(
|
||||
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const refreshMachineDocuments = async () => {
|
||||
if (!machine.value?.id) return
|
||||
const result: any = await loadDocumentsByMachine(machine.value.id as string, { updateStore: false })
|
||||
if (result.success && machine.value) {
|
||||
machine.value.documents = result.data || []
|
||||
machineDocumentsLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleMachineFilesAdded = async (files: File[]) => {
|
||||
if (!files.length || !machine.value?.id) return
|
||||
machineDocumentsUploading.value = true
|
||||
try {
|
||||
const result: any = await uploadDocuments(
|
||||
{ files, context: { machineId: machine.value.id } } as any,
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (result.success && machine.value) {
|
||||
const newDocs = (result.data as AnyRecord[]) || []
|
||||
machine.value.documents = [
|
||||
...newDocs,
|
||||
...((machine.value.documents as AnyRecord[]) || []),
|
||||
]
|
||||
machineDocumentFiles.value = []
|
||||
}
|
||||
} finally {
|
||||
machineDocumentsUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeMachineDocument = async (documentId: string) => {
|
||||
if (!documentId) return
|
||||
const result: any = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success && machine.value) {
|
||||
machine.value.documents = ((machine.value.documents as AnyRecord[]) || []).filter(
|
||||
(doc) => doc.id !== documentId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const openPreview = (doc: AnyRecord) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const loadProductDocuments = async (machineProductLinks: AnyRecord[]) => {
|
||||
const productIds = machineProductLinks
|
||||
.map((link) => {
|
||||
const p = link.product as AnyRecord | string | null
|
||||
if (typeof p === 'string') return p.split('/').pop() || null
|
||||
return (p as AnyRecord)?.id as string | null
|
||||
})
|
||||
.filter((id): id is string => !!id)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
productIds.map(async (id) => {
|
||||
const result: any = await loadDocumentsByProduct(id, { updateStore: false })
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
return { id, docs: result.data as AnyRecord[] }
|
||||
}
|
||||
return { id, docs: [] }
|
||||
}),
|
||||
)
|
||||
|
||||
const map = new Map<string, AnyRecord[]>()
|
||||
results.forEach((r) => {
|
||||
if (r.status === 'fulfilled' && r.value.docs.length) {
|
||||
map.set(r.value.id, r.value.docs)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
machineDocumentFiles,
|
||||
machineDocumentsUploading,
|
||||
machineDocumentsLoaded,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
|
||||
// Computed
|
||||
machineDocumentsList,
|
||||
|
||||
// Methods
|
||||
refreshMachineDocuments,
|
||||
handleMachineFilesAdded,
|
||||
removeMachineDocument,
|
||||
openPreview,
|
||||
closePreview,
|
||||
loadProductDocuments,
|
||||
}
|
||||
}
|
||||
306
app/composables/useMachineDetailHierarchy.ts
Normal file
306
app/composables/useMachineDetailHierarchy.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Machine detail — hierarchy & link management sub-composable.
|
||||
*
|
||||
* Handles machine hierarchy building, component/piece tree resolution,
|
||||
* flatten helpers, find-by-id utilities, and structure link CRUD.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import {
|
||||
resolveIdentifier,
|
||||
} from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
buildMachineHierarchyFromLinks,
|
||||
resolveLinkArray,
|
||||
} from '~/composables/useMachineHierarchy'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailHierarchyDeps {
|
||||
machineId: string
|
||||
machine: Ref<AnyRecord | null>
|
||||
constructeurs: Ref<unknown[]>
|
||||
findProductById: (id: string | null | undefined) => AnyRecord | null
|
||||
transformComponentCustomFields: (data: AnyRecord[]) => AnyRecord[]
|
||||
transformCustomFields: (data: AnyRecord[]) => AnyRecord[]
|
||||
syncMachineCustomFields: () => void
|
||||
}
|
||||
|
||||
export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
const {
|
||||
machineId,
|
||||
machine,
|
||||
constructeurs,
|
||||
findProductById,
|
||||
transformComponentCustomFields,
|
||||
transformCustomFields,
|
||||
syncMachineCustomFields,
|
||||
} = deps
|
||||
|
||||
const { get, post: apiPost, delete: apiDel } = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const components = ref<AnyRecord[]>([])
|
||||
const pieces = ref<AnyRecord[]>([])
|
||||
const machineComponentLinks = ref<AnyRecord[]>([])
|
||||
const machinePieceLinks = ref<AnyRecord[]>([])
|
||||
const machineProductLinks = ref<AnyRecord[]>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const flattenComponents = (list: AnyRecord[] = []): AnyRecord[] => {
|
||||
const result: AnyRecord[] = []
|
||||
const traverse = (items: AnyRecord[]) => {
|
||||
items.forEach((item) => {
|
||||
result.push(item)
|
||||
if (Array.isArray(item.subComponents) && item.subComponents.length) {
|
||||
traverse(item.subComponents as AnyRecord[])
|
||||
}
|
||||
})
|
||||
}
|
||||
traverse(list)
|
||||
return result
|
||||
}
|
||||
|
||||
const findComponentById = (items: AnyRecord[] | undefined, id: string): AnyRecord | null => {
|
||||
for (const item of items || []) {
|
||||
if (item.id === id) return item
|
||||
const found = findComponentById(item.subComponents as AnyRecord[] | undefined, id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findPieceById = (pieceId: string): AnyRecord | null => {
|
||||
const direct = pieces.value.find((p) => p.id === pieceId)
|
||||
if (direct) return direct
|
||||
|
||||
const searchInComponents = (items: AnyRecord[]): AnyRecord | null => {
|
||||
for (const item of items || []) {
|
||||
const match = ((item.pieces as AnyRecord[]) || []).find((p) => p.id === pieceId)
|
||||
if (match) return match
|
||||
const nested = searchInComponents((item.subComponents as AnyRecord[]) || [])
|
||||
if (nested) return nested
|
||||
}
|
||||
return null
|
||||
}
|
||||
return searchInComponents(components.value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hierarchy & links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const applyMachineLinks = (source: AnyRecord): boolean => {
|
||||
const container = (source?.machine as AnyRecord) ?? null
|
||||
const componentLinksData =
|
||||
resolveLinkArray(source, ['componentLinks', 'machineComponentLinks']) ??
|
||||
resolveLinkArray(container, ['componentLinks', 'machineComponentLinks'])
|
||||
const pieceLinksData =
|
||||
resolveLinkArray(source, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
resolveLinkArray(container, ['pieceLinks', 'machinePieceLinks'])
|
||||
const productLinksData =
|
||||
resolveLinkArray(source, ['productLinks', 'machineProductLinks']) ??
|
||||
resolveLinkArray(container, ['productLinks', 'machineProductLinks'])
|
||||
|
||||
if (componentLinksData === null && pieceLinksData === null && productLinksData === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedComponentLinks = (componentLinksData ?? []) as AnyRecord[]
|
||||
const normalizedPieceLinks = (pieceLinksData ?? []) as AnyRecord[]
|
||||
const normalizedProductLinks = (productLinksData ?? []) as AnyRecord[]
|
||||
|
||||
machineComponentLinks.value = normalizedComponentLinks
|
||||
machinePieceLinks.value = normalizedPieceLinks
|
||||
machineProductLinks.value = normalizedProductLinks
|
||||
|
||||
const { components: hierarchy, machinePieces: machineLevelPieces } =
|
||||
buildMachineHierarchyFromLinks(
|
||||
normalizedComponentLinks,
|
||||
normalizedPieceLinks,
|
||||
findProductById as any,
|
||||
constructeurs.value as any,
|
||||
)
|
||||
|
||||
components.value = transformComponentCustomFields(hierarchy as AnyRecord[])
|
||||
pieces.value = transformCustomFields(machineLevelPieces as AnyRecord[])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const flattenedComponents = computed(() => flattenComponents(components.value))
|
||||
|
||||
const machinePieces = computed(() => {
|
||||
return pieces.value.filter((piece) => {
|
||||
const parentLinkId = resolveIdentifier(
|
||||
piece.parentComponentLinkId,
|
||||
(piece.machinePieceLink as AnyRecord)?.parentComponentLinkId,
|
||||
piece.parentLinkId,
|
||||
)
|
||||
if (parentLinkId) return false
|
||||
return !piece.composantId
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const reloadMachineStructure = async () => {
|
||||
const result: any = await get(`/machines/${machineId}/structure`)
|
||||
if (result.success) {
|
||||
const machinePayload =
|
||||
result.data?.machine && typeof result.data.machine === 'object'
|
||||
? result.data.machine
|
||||
: result.data
|
||||
if (machinePayload && typeof machinePayload === 'object') {
|
||||
machine.value = {
|
||||
...machine.value,
|
||||
...machinePayload,
|
||||
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
|
||||
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
|
||||
}
|
||||
const linksApplied = applyMachineLinks(result.data)
|
||||
if (linksApplied && machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
syncMachineCustomFields()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure link CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const addComponentLink = async (composantId: string) => {
|
||||
const result: any = await apiPost('/machine_component_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
composant: `/api/composants/${composantId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Composant ajouté à la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout du composant')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const removeComponentLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_component_links/${linkId}`)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Composant retiré de la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de la suppression du composant')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
|
||||
const payload: any = {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
piece: `/api/pieces/${pieceId}`,
|
||||
}
|
||||
if (parentComponentLinkId) {
|
||||
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
||||
}
|
||||
const result: any = await apiPost('/machine_piece_links', payload)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Pièce ajoutée à la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout de la pièce')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const removePieceLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Pièce retirée de la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de la suppression de la pièce')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
|
||||
const payload: any = {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
product: `/api/products/${productId}`,
|
||||
}
|
||||
if (parentComponentLinkId) {
|
||||
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
||||
}
|
||||
if (parentPieceLinkId) {
|
||||
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
|
||||
}
|
||||
const result: any = await apiPost('/machine_product_links', payload)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Produit ajouté à la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout du produit')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const removeProductLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_product_links/${linkId}`)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Produit retiré de la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de la suppression du produit')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
components,
|
||||
pieces,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
machineProductLinks,
|
||||
|
||||
// Computed
|
||||
flattenedComponents,
|
||||
machinePieces,
|
||||
|
||||
// Helpers
|
||||
flattenComponents,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
|
||||
// Hierarchy
|
||||
applyMachineLinks,
|
||||
|
||||
// Structure link management
|
||||
reloadMachineStructure,
|
||||
addComponentLink,
|
||||
removeComponentLink,
|
||||
addPieceLink,
|
||||
removePieceLink,
|
||||
addProductLink,
|
||||
removeProductLink,
|
||||
}
|
||||
}
|
||||
132
app/composables/useMachineDetailProducts.ts
Normal file
132
app/composables/useMachineDetailProducts.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Machine detail — product display sub-composable.
|
||||
*
|
||||
* Handles product resolution, display helpers, supplier info,
|
||||
* and machine-level direct product links.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import {
|
||||
resolveProductReference as _resolveProductReference,
|
||||
getProductDisplay as _getProductDisplay,
|
||||
getProductSuppliersLabel,
|
||||
getProductPriceLabel,
|
||||
} from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailProductsDeps {
|
||||
machineProductLinks: Ref<AnyRecord[]>
|
||||
productDocumentsMap: Ref<Map<string, AnyRecord[]>>
|
||||
constructeurs: Ref<unknown[]>
|
||||
}
|
||||
|
||||
export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
|
||||
const { machineProductLinks, productDocumentsMap, constructeurs } = deps
|
||||
const { products, loadProducts } = useProducts()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const productInventory = computed(() => products.value || [])
|
||||
|
||||
const productById = computed(() => {
|
||||
const map = new Map<string, AnyRecord>()
|
||||
;(productInventory.value as AnyRecord[]).forEach((product: AnyRecord) => {
|
||||
if (product?.id) map.set(product.id as string, product)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const findProductById = (productId: string | null | undefined): AnyRecord | null => {
|
||||
if (!productId) return null
|
||||
return productById.value.get(productId) || null
|
||||
}
|
||||
|
||||
const resolveProductReference = (source: AnyRecord) =>
|
||||
_resolveProductReference(source, findProductById as any)
|
||||
const getProductDisplay = (source: AnyRecord) =>
|
||||
_getProductDisplay(source, findProductById as any)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine direct products
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineDirectProducts = computed(() => {
|
||||
return machineProductLinks.value.map((link) => {
|
||||
const productObj = link.product as AnyRecord | string | null
|
||||
let resolved: AnyRecord | null = null
|
||||
let productId: string | null = null
|
||||
|
||||
if (typeof productObj === 'string') {
|
||||
productId = productObj.split('/').pop() || null
|
||||
resolved = productId ? findProductById(productId) : null
|
||||
} else if (productObj && typeof productObj === 'object') {
|
||||
productId = (productObj as AnyRecord)?.id as string | null
|
||||
// Prefer the embedded product from the structure endpoint — it has richer
|
||||
// data (typeProduct as object, supplierPrice, constructeurs) than the
|
||||
// global products cache which may store typeProduct as an IRI string.
|
||||
const cached = productId ? findProductById(productId) : null
|
||||
resolved = productObj as AnyRecord
|
||||
if (cached) {
|
||||
// Merge: use embedded as base, overlay any non-null cached fields
|
||||
resolved = { ...resolved, ...Object.fromEntries(
|
||||
Object.entries(cached as AnyRecord).filter(([, v]) => v != null && v !== ''),
|
||||
) }
|
||||
// But always prefer the embedded typeProduct when it's an object
|
||||
if (productObj.typeProduct && typeof productObj.typeProduct === 'object') {
|
||||
resolved.typeProduct = productObj.typeProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cIds = uniqueConstructeurIds(
|
||||
resolved?.constructeurs,
|
||||
resolved?.constructeurIds,
|
||||
)
|
||||
const resolvedConstructeurs = resolveConstructeurs(
|
||||
cIds,
|
||||
resolved?.constructeurs as any[] || [],
|
||||
constructeurs.value as any,
|
||||
)
|
||||
|
||||
return {
|
||||
id: (resolved?.id as string) || productId || null,
|
||||
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
|
||||
name: (resolved?.name as string) || 'Produit inconnu',
|
||||
reference: (resolved?.reference as string) || null,
|
||||
supplierLabel: resolvedConstructeurs.length
|
||||
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
|
||||
: getProductSuppliersLabel(resolved),
|
||||
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
|
||||
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
|
||||
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
// Computed
|
||||
productInventory,
|
||||
productById,
|
||||
machineDirectProducts,
|
||||
|
||||
// Helpers
|
||||
findProductById,
|
||||
resolveProductReference,
|
||||
getProductDisplay,
|
||||
|
||||
// Loading
|
||||
loadProducts,
|
||||
}
|
||||
}
|
||||
226
app/composables/useMachineDetailUpdates.ts
Normal file
226
app/composables/useMachineDetailUpdates.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Machine detail page — update/mutation methods.
|
||||
*
|
||||
* Extracted from useMachineDetailData.ts to keep the orchestrator under 500 lines.
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface UseMachineDetailUpdatesDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
machineName: Ref<string>
|
||||
machineReference: Ref<string>
|
||||
machineSiteId: Ref<string>
|
||||
machineConstructeurIds: Ref<string[]>
|
||||
machineDocumentsLoaded: Ref<boolean>
|
||||
machineComponentLinks: Ref<AnyRecord[]>
|
||||
machinePieceLinks: Ref<AnyRecord[]>
|
||||
machineProductLinks: Ref<AnyRecord[]>
|
||||
applyMachineLinks: (data: AnyRecord) => boolean
|
||||
refreshMachineDocuments: () => Promise<void>
|
||||
transformComponentCustomFields: (items: AnyRecord[]) => AnyRecord[]
|
||||
transformCustomFields: (items: AnyRecord[]) => AnyRecord[]
|
||||
loadProductDocuments: () => Promise<void>
|
||||
upsertCustomFieldValue: (
|
||||
fieldId: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: unknown,
|
||||
) => Promise<unknown>
|
||||
updateMachineApi: (id: string, data: any) => Promise<unknown>
|
||||
updateComposantApi: (id: string, data: any) => Promise<unknown>
|
||||
updatePieceApi: (id: string, data: any) => Promise<unknown>
|
||||
apiPatch: (endpoint: string, data?: unknown) => Promise<any>
|
||||
toast: { showInfo: (msg: string) => void }
|
||||
}
|
||||
|
||||
export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
const {
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machineSiteId,
|
||||
machineConstructeurIds,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
applyMachineLinks,
|
||||
loadProductDocuments,
|
||||
transformComponentCustomFields,
|
||||
transformCustomFields,
|
||||
upsertCustomFieldValue,
|
||||
updateMachineApi,
|
||||
updateComposantApi,
|
||||
updatePieceApi,
|
||||
apiPatch,
|
||||
toast,
|
||||
} = deps
|
||||
|
||||
const updateMachineInfo = async () => {
|
||||
if (!machine.value) return
|
||||
try {
|
||||
const cIds = uniqueConstructeurIds(machineConstructeurIds.value)
|
||||
machineConstructeurIds.value = cIds
|
||||
|
||||
const result: any = await updateMachineApi(machine.value.id as string, {
|
||||
name: machineName.value,
|
||||
reference: machineReference.value,
|
||||
siteId: machineSiteId.value || undefined,
|
||||
constructeurIds: cIds,
|
||||
} as any)
|
||||
if (result.success) {
|
||||
const machinePayload =
|
||||
result.data?.machine && typeof result.data.machine === 'object'
|
||||
? result.data.machine
|
||||
: result.data
|
||||
if (machinePayload && typeof machinePayload === 'object') {
|
||||
machine.value = {
|
||||
...machine.value,
|
||||
...machinePayload,
|
||||
documents: machinePayload.documents || machine.value.documents || [],
|
||||
customFieldValues: machinePayload.customFieldValues || machine.value.customFieldValues || [],
|
||||
}
|
||||
machineConstructeurIds.value = uniqueConstructeurIds(
|
||||
machine.value!.constructeurIds,
|
||||
machine.value!.constructeurs,
|
||||
machine.value!.constructeur,
|
||||
)
|
||||
const linksApplied = applyMachineLinks(result.data)
|
||||
if (linksApplied && machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
}
|
||||
loadProductDocuments().catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la machine:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const updateComponent = async (updatedComponent: AnyRecord) => {
|
||||
try {
|
||||
const cIds = uniqueConstructeurIds(
|
||||
updatedComponent.constructeurIds,
|
||||
updatedComponent.constructeurId,
|
||||
updatedComponent.constructeur,
|
||||
)
|
||||
const productId = updatedComponent.productId
|
||||
? String(updatedComponent.productId)
|
||||
: null
|
||||
const prix =
|
||||
updatedComponent.prix !== null &&
|
||||
updatedComponent.prix !== undefined &&
|
||||
String(updatedComponent.prix).trim() !== ''
|
||||
? Number(updatedComponent.prix)
|
||||
: null
|
||||
|
||||
const result: any = await updateComposantApi(updatedComponent.id as string, {
|
||||
name: updatedComponent.name,
|
||||
reference: updatedComponent.reference,
|
||||
constructeurIds: cIds,
|
||||
prix: Number.isNaN(prix) ? null : prix,
|
||||
productId,
|
||||
} as any)
|
||||
if (result.success) {
|
||||
const transformed = transformComponentCustomFields([result.data])[0]
|
||||
Object.assign(updatedComponent, transformed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du composant:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const _buildAndUpdatePiece = async (updatedPiece: AnyRecord) => {
|
||||
const cIds = uniqueConstructeurIds(
|
||||
updatedPiece.constructeurIds,
|
||||
updatedPiece.constructeurId,
|
||||
updatedPiece.constructeur,
|
||||
)
|
||||
const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
|
||||
const prix =
|
||||
updatedPiece.prix !== null &&
|
||||
updatedPiece.prix !== undefined &&
|
||||
String(updatedPiece.prix).trim() !== ''
|
||||
? Number(updatedPiece.prix)
|
||||
: null
|
||||
|
||||
const result: any = await updatePieceApi(updatedPiece.id as string, {
|
||||
name: updatedPiece.name,
|
||||
reference: updatedPiece.reference || null,
|
||||
constructeurIds: cIds,
|
||||
prix: Number.isNaN(prix) ? null : prix,
|
||||
productId,
|
||||
} as any)
|
||||
if (result.success) {
|
||||
const transformed = transformCustomFields([result.data])[0]
|
||||
Object.assign(updatedPiece, transformed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const updatePieceFromComponent = async (updatedPiece: AnyRecord) => {
|
||||
try {
|
||||
const result = await _buildAndUpdatePiece(updatedPiece)
|
||||
if (result?.success && updatedPiece.customFields) {
|
||||
const fieldsToSave = (updatedPiece.customFields as AnyRecord[]).filter(
|
||||
(field) => field.value !== undefined,
|
||||
)
|
||||
if (fieldsToSave.length) {
|
||||
await Promise.allSettled(
|
||||
fieldsToSave.map((field) =>
|
||||
upsertCustomFieldValue(
|
||||
field.id as string,
|
||||
'piece',
|
||||
updatedPiece.id as string,
|
||||
field.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceInfo = async (updatedPiece: AnyRecord) => {
|
||||
try {
|
||||
await _buildAndUpdatePiece(updatedPiece)
|
||||
|
||||
// Update link quantity if this is a direct machine piece
|
||||
const linkId = updatedPiece.linkId || updatedPiece.machinePieceLinkId
|
||||
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
|
||||
if (linkId && quantity !== null) {
|
||||
await apiPatch(`/machine_piece_links/${linkId}`, { quantity })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMachineConstructeurChange = async (value: unknown) => {
|
||||
machineConstructeurIds.value = uniqueConstructeurIds(value)
|
||||
await updateMachineInfo()
|
||||
}
|
||||
|
||||
const editComponent = () => {
|
||||
toast.showInfo('La modification des composants sera bientôt disponible')
|
||||
}
|
||||
|
||||
const editPiece = () => {
|
||||
toast.showInfo('La modification des pièces sera bientôt disponible')
|
||||
}
|
||||
|
||||
return {
|
||||
updateMachineInfo,
|
||||
updateComponent,
|
||||
updatePieceFromComponent,
|
||||
updatePieceInfo,
|
||||
handleMachineConstructeurChange,
|
||||
editComponent,
|
||||
editPiece,
|
||||
}
|
||||
}
|
||||
@@ -153,8 +153,6 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
||||
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
||||
|
||||
const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
|
||||
|
||||
const machinePieceLinkId = normalizePieceLinkId(link)
|
||||
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
||||
|
||||
@@ -170,11 +168,8 @@ export const buildMachineHierarchyFromLinks = (
|
||||
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
||||
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
|
||||
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
|
||||
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
|
||||
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
||||
typeMachinePieceRequirement: requirement,
|
||||
typeMachinePieceRequirementId: requirement?.id || null,
|
||||
requirementId: requirement?.id || null,
|
||||
typePiece: appliedPiece.typePiece || null,
|
||||
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || null,
|
||||
overrides,
|
||||
originalPiece,
|
||||
machinePieceLink: link,
|
||||
@@ -186,11 +181,9 @@ export const buildMachineHierarchyFromLinks = (
|
||||
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
||||
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
||||
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
||||
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
|
||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||
customFields: appliedPiece.customFields || [],
|
||||
skeletonOnly: !pieceId,
|
||||
}
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
||||
@@ -215,8 +208,6 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
||||
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
||||
|
||||
const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
|
||||
|
||||
const machineComponentLinkId = normalizeComponentLinkId(link)
|
||||
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
||||
|
||||
@@ -224,10 +215,38 @@ export const buildMachineHierarchyFromLinks = (
|
||||
|
||||
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
|
||||
|
||||
const pieces = Array.isArray(link.pieceLinks)
|
||||
const linkedPieces = Array.isArray(link.pieceLinks)
|
||||
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
|
||||
// If no linked pieces exist, build read-only entries from the composant's structure
|
||||
const structurePieceDefs = (!linkedPieces.length && appliedComponent.structure && typeof appliedComponent.structure === 'object')
|
||||
? (Array.isArray((appliedComponent.structure as AnyRecord).pieces) ? (appliedComponent.structure as AnyRecord).pieces as AnyRecord[] : [])
|
||||
: []
|
||||
const structurePieces = structurePieceDefs.map((def, index) => {
|
||||
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
|
||||
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
|
||||
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
|
||||
return {
|
||||
...(resolved || {}),
|
||||
id: resolved?.id || `structure-piece-${composantId}-${index}`,
|
||||
pieceId: resolved?.id || null,
|
||||
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
|
||||
reference: resolved?.reference || definition.reference || def.reference || null,
|
||||
prix: resolved?.prix ?? null,
|
||||
constructeurs: resolved?.constructeurs || [],
|
||||
documents: [],
|
||||
quantity,
|
||||
typePieceId: resolved?.typePieceId || definition.typePieceId || def.typePieceId || null,
|
||||
typePiece: resolved?.typePiece || null,
|
||||
parentComponentLinkId: machineComponentLinkId,
|
||||
parentComponentName: componentName,
|
||||
_structurePiece: true,
|
||||
}
|
||||
}) as AnyRecord[]
|
||||
|
||||
const pieces = linkedPieces.length ? linkedPieces : structurePieces
|
||||
|
||||
const subComponents = Array.isArray(link.childLinks)
|
||||
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
@@ -245,11 +264,8 @@ export const buildMachineHierarchyFromLinks = (
|
||||
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
||||
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
|
||||
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
|
||||
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
|
||||
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
||||
typeMachineComponentRequirement: requirement,
|
||||
typeMachineComponentRequirementId: requirement?.id || null,
|
||||
requirementId: requirement?.id || null,
|
||||
typeComposant: appliedComponent.typeComposant || null,
|
||||
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || null,
|
||||
overrides: compOverrides,
|
||||
machineComponentLinkOverrides: compOverrides,
|
||||
definitionOverrides: compOverrides,
|
||||
@@ -259,16 +275,12 @@ export const buildMachineHierarchyFromLinks = (
|
||||
componentLinkId: machineComponentLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
||||
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||
customFields: appliedComponent.customFields || [],
|
||||
pieces,
|
||||
subComponents,
|
||||
subcomponents: subComponents,
|
||||
sousComposants: subComponents,
|
||||
skeletonOnly: !composantId,
|
||||
}
|
||||
|
||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)
|
||||
|
||||
@@ -1,838 +0,0 @@
|
||||
/**
|
||||
* Machine skeleton editor — selection state, validation & save logic.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue (F1.1).
|
||||
* Manages the reactive selection state for component / piece / product
|
||||
* skeleton requirements, validation, and reconfiguration API calls.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import {
|
||||
resolveIdentifier,
|
||||
extractParentLinkIdentifiers,
|
||||
} from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import { resolveLinkArray } from '~/composables/useMachineHierarchy'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineSkeletonEditorDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
components: Ref<AnyRecord[]>
|
||||
pieces: Ref<AnyRecord[]>
|
||||
machineComponentLinks: Ref<AnyRecord[]>
|
||||
machinePieceLinks: Ref<AnyRecord[]>
|
||||
machineProductLinks: Ref<AnyRecord[]>
|
||||
machineType: ComputedRef<AnyRecord | null>
|
||||
machineHasSkeletonRequirements: ComputedRef<boolean>
|
||||
componentRequirements: ComputedRef<AnyRecord[]>
|
||||
pieceRequirements: ComputedRef<AnyRecord[]>
|
||||
productRequirements: ComputedRef<AnyRecord[]>
|
||||
componentTypeLabelMap: ComputedRef<Map<string, string>>
|
||||
pieceTypeLabelMap: ComputedRef<Map<string, string>>
|
||||
productInventory: ComputedRef<AnyRecord[]>
|
||||
flattenedComponents: ComputedRef<AnyRecord[]>
|
||||
machinePieces: ComputedRef<AnyRecord[]>
|
||||
machineDocumentsLoaded: Ref<boolean>
|
||||
findProductById: (id: string | null | undefined) => AnyRecord | null
|
||||
findComponentById: (items: AnyRecord[] | undefined, id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
transformCustomFields: (pieces: AnyRecord[]) => AnyRecord[]
|
||||
transformComponentCustomFields: (components: AnyRecord[]) => AnyRecord[]
|
||||
applyMachineLinks: (source: AnyRecord) => boolean
|
||||
collapseAllComponents: () => void
|
||||
initMachineFields: () => void
|
||||
collectPiecesForSkeleton: () => AnyRecord[]
|
||||
constructeurs: Ref<AnyRecord[]>
|
||||
loadProducts: () => Promise<void>
|
||||
reconfigureMachineSkeleton: (id: string, payload: AnyRecord) => Promise<AnyRecord>
|
||||
toast: { showError: (msg: string) => void; showInfo: (msg: string) => void }
|
||||
}
|
||||
|
||||
export function useMachineSkeletonEditor(deps: MachineSkeletonEditorDeps) {
|
||||
const {
|
||||
machine,
|
||||
components,
|
||||
pieces,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
machineProductLinks,
|
||||
machineType,
|
||||
machineHasSkeletonRequirements,
|
||||
productRequirements,
|
||||
componentTypeLabelMap,
|
||||
pieceTypeLabelMap,
|
||||
productInventory,
|
||||
flattenedComponents,
|
||||
machineDocumentsLoaded,
|
||||
findProductById,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
applyMachineLinks,
|
||||
collapseAllComponents,
|
||||
initMachineFields,
|
||||
collectPiecesForSkeleton,
|
||||
loadProducts,
|
||||
reconfigureMachineSkeleton,
|
||||
toast,
|
||||
} = deps
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activeMachineView = ref<'details' | 'skeleton'>('details')
|
||||
const isDetailsView = computed(() => activeMachineView.value === 'details')
|
||||
const isSkeletonView = computed(() => activeMachineView.value === 'skeleton')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const skeletonEditor = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
})
|
||||
|
||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isPlainObject = (value: unknown): boolean =>
|
||||
Object.prototype.toString.call(value) === '[object Object]'
|
||||
|
||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
componentRequirementSelections[requirementId] || []
|
||||
|
||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
pieceRequirementSelections[requirementId] || []
|
||||
|
||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
productRequirementSelections[requirementId] || []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveComponentRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId = (entry?.typeComposantId || requirement?.typeComposantId || null) as string | null
|
||||
if (!typeId) return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
return componentTypeLabelMap.value.get(typeId) || ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
const resolvePieceRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId = (entry?.typePieceId || requirement?.typePieceId || null) as string | null
|
||||
if (!typeId) return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
return pieceTypeLabelMap.value.get(typeId) || ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
const resolveProductRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId =
|
||||
(entry?.typeProductId as string) ||
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
if (typeId) {
|
||||
const typeMatch = productRequirements.value.find(
|
||||
(req: AnyRecord) =>
|
||||
req.typeProductId === typeId || (req.typeProduct as AnyRecord)?.id === typeId,
|
||||
)
|
||||
if (typeMatch && (typeMatch.typeProduct as AnyRecord)?.name) {
|
||||
return (typeMatch.typeProduct as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typeProduct as AnyRecord)?.name as string) || 'Catégorie non définie'
|
||||
}
|
||||
|
||||
const getProductOptionsForRequirement = (requirement: AnyRecord): AnyRecord[] => {
|
||||
const requirementTypeId =
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
return (productInventory.value as AnyRecord[]).filter((product) => {
|
||||
if (!product?.id) return false
|
||||
if (!requirementTypeId) return true
|
||||
const productTypeId =
|
||||
(product.typeProductId as string) ||
|
||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
return productTypeId === requirementTypeId
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection entry factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machineComponentLink as AnyRecord) || null
|
||||
|
||||
const entry: AnyRecord = {
|
||||
linkId: resolveIdentifier(link?.id, source?.machineComponentLinkId, source?.linkId),
|
||||
composantId: resolveIdentifier(source?.composantId, source?.componentId, source?.id),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, link?.parentComponentLinkId, source?.parentComponentLinkId, source?.parentLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId),
|
||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||
typeComposantId:
|
||||
(source?.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
|
||||
source?.typeComposantId ||
|
||||
(source?.typeComposant as AnyRecord)?.id ||
|
||||
requirement?.typeComposantId ||
|
||||
null,
|
||||
definition: {
|
||||
name: source?.name || source?.nom || (requirement?.typeComposant as AnyRecord)?.name || '',
|
||||
reference: source?.reference || '',
|
||||
constructeurIds: [] as string[],
|
||||
constructeurId: null as string | null,
|
||||
prix: source?.prix ?? source?.price ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
const defConstructeurIds = uniqueConstructeurIds(
|
||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||
(link?.overrides as AnyRecord)?.constructeurId,
|
||||
source?.constructeurIds,
|
||||
source?.constructeurId,
|
||||
source?.constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||
|
||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||
}
|
||||
|
||||
const finalConstructeurIds = uniqueConstructeurIds(
|
||||
(entry.definition as AnyRecord).constructeurIds,
|
||||
(entry.definition as AnyRecord).constructeurId,
|
||||
(entry.definition as AnyRecord).constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machinePieceLink as AnyRecord) || null
|
||||
|
||||
const entry: AnyRecord = {
|
||||
linkId: resolveIdentifier(link?.id, source?.machinePieceLinkId, source?.linkId),
|
||||
pieceId: resolveIdentifier(source?.pieceId, source?.id),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId, source?.machineComponentLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId, source?.composantId),
|
||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||
composantId: resolveIdentifier(source?.composantId, link?.composantId, link?.componentId),
|
||||
typePieceId:
|
||||
(source?.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
|
||||
source?.typePieceId ||
|
||||
(source?.typePiece as AnyRecord)?.id ||
|
||||
requirement?.typePieceId ||
|
||||
null,
|
||||
definition: {
|
||||
name: source?.name || source?.nom || (requirement?.typePiece as AnyRecord)?.name || '',
|
||||
reference: source?.reference || '',
|
||||
constructeurIds: [] as string[],
|
||||
constructeurId: null as string | null,
|
||||
prix: source?.prix ?? source?.price ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
const defConstructeurIds = uniqueConstructeurIds(
|
||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||
(link?.overrides as AnyRecord)?.constructeurId,
|
||||
source?.constructeurIds,
|
||||
source?.constructeurId,
|
||||
source?.constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||
|
||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||
}
|
||||
|
||||
const finalConstructeurIds = uniqueConstructeurIds(
|
||||
(entry.definition as AnyRecord).constructeurIds,
|
||||
(entry.definition as AnyRecord).constructeurId,
|
||||
(entry.definition as AnyRecord).constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machineProductLink as AnyRecord) || source || null
|
||||
|
||||
return {
|
||||
linkId: resolveIdentifier(link?.id, source?.machineProductLinkId, source?.linkId),
|
||||
productId: resolveIdentifier(source?.productId, link?.productId),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId),
|
||||
parentPieceLinkId: resolveIdentifier(link?.parentPieceLinkId, source?.parentPieceLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentComponentRequirementId: resolveIdentifier(link?.parentComponentRequirementId, source?.parentComponentRequirementId, requirement?.parentComponentRequirementId),
|
||||
parentPieceRequirementId: resolveIdentifier(link?.parentPieceRequirementId, source?.parentPieceRequirementId, requirement?.parentPieceRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
typeProductId: resolveIdentifier(link?.typeProductId, source?.typeProductId, requirement?.typeProductId, (requirement?.typeProduct as AnyRecord)?.id),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resetSkeletonRequirementSelections = () => {
|
||||
Object.keys(componentRequirementSelections).forEach((k) => delete componentRequirementSelections[k])
|
||||
Object.keys(pieceRequirementSelections).forEach((k) => delete pieceRequirementSelections[k])
|
||||
Object.keys(productRequirementSelections).forEach((k) => delete productRequirementSelections[k])
|
||||
}
|
||||
|
||||
const addComponentSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
componentRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createComponentSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeComponentSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getComponentRequirementEntries(requirementId)
|
||||
componentRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setComponentRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typeComposantId = value || null
|
||||
}
|
||||
|
||||
const setComponentRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||
}
|
||||
|
||||
const addPieceSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
pieceRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createPieceSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removePieceSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getPieceRequirementEntries(requirementId)
|
||||
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setPieceRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typePieceId = value || null
|
||||
}
|
||||
|
||||
const setPieceRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||
}
|
||||
|
||||
const addProductSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
productRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createProductSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeProductSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getProductRequirementEntries(requirementId)
|
||||
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setProductRequirementProduct = (requirementId: string, index: number, productId: string | null) => {
|
||||
const entry = getProductRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const normalizedProductId = productId || null
|
||||
entry.productId = normalizedProductId
|
||||
if (normalizedProductId) {
|
||||
const product = findProductById(normalizedProductId)
|
||||
entry.typeProductId =
|
||||
(product?.typeProductId as string) ||
|
||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry.typeProductId as string) ||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
const setProductRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getProductRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typeProductId = value || entry.typeProductId || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initializeSkeletonRequirementSelections = async () => {
|
||||
skeletonEditor.loading = true
|
||||
try {
|
||||
resetSkeletonRequirementSelections()
|
||||
const type = machineType.value as AnyRecord
|
||||
if (!type) return
|
||||
|
||||
try {
|
||||
await loadProducts()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des produits pour le squelette:', error)
|
||||
}
|
||||
|
||||
;((type.componentRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const existing = flattenedComponents.value.filter(
|
||||
(c) => c.typeMachineComponentRequirementId === requirement.id,
|
||||
)
|
||||
const entries = existing.map((c) => createComponentSelectionEntry(requirement, c))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createComponentSelectionEntry(requirement))
|
||||
if (entries.length) componentRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
|
||||
const allPieces = collectPiecesForSkeleton()
|
||||
;((type.pieceRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const existing = allPieces.filter(
|
||||
(p) => p.typeMachinePieceRequirementId === requirement.id,
|
||||
)
|
||||
const entries = existing.map((p) => createPieceSelectionEntry(requirement, p))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createPieceSelectionEntry(requirement))
|
||||
if (entries.length) pieceRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
|
||||
const existingProductLinks = Array.isArray(machineProductLinks.value)
|
||||
? machineProductLinks.value
|
||||
: Array.isArray(machine.value?.productLinks)
|
||||
? (machine.value.productLinks as AnyRecord[])
|
||||
: []
|
||||
|
||||
;((type.productRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const matches = existingProductLinks.filter((link) => {
|
||||
const reqId = resolveIdentifier(link?.typeMachineProductRequirementId, link?.requirementId)
|
||||
return reqId === requirement.id
|
||||
})
|
||||
const entries = matches.map((link) => createProductSelectionEntry(requirement, link))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createProductSelectionEntry(requirement))
|
||||
if (entries.length) productRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
} finally {
|
||||
skeletonEditor.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor open/close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openSkeletonEditor = async () => {
|
||||
if (skeletonEditor.open) return
|
||||
skeletonEditor.open = true
|
||||
await initializeSkeletonRequirementSelections()
|
||||
}
|
||||
|
||||
const closeSkeletonEditor = () => {
|
||||
if (!skeletonEditor.open) return
|
||||
if (skeletonEditor.submitting) return
|
||||
skeletonEditor.open = false
|
||||
skeletonEditor.loading = false
|
||||
skeletonEditor.submitting = false
|
||||
resetSkeletonRequirementSelections()
|
||||
}
|
||||
|
||||
const changeMachineView = async (view: 'details' | 'skeleton') => {
|
||||
if (view === activeMachineView.value) return
|
||||
|
||||
if (view === 'skeleton') {
|
||||
if (!machineHasSkeletonRequirements.value) {
|
||||
toast.showInfo('Aucun squelette configuré pour cette machine.')
|
||||
return
|
||||
}
|
||||
activeMachineView.value = 'skeleton'
|
||||
if (!skeletonEditor.open) {
|
||||
try {
|
||||
await openSkeletonEditor()
|
||||
} catch (error) {
|
||||
console.error("Impossible d'ouvrir l'éditeur de squelette:", error)
|
||||
toast.showError('Impossible de charger les éléments du squelette.')
|
||||
activeMachineView.value = 'details'
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
closeSkeletonEditor()
|
||||
activeMachineView.value = 'details'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation & save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const computeSkeletonProductUsage = (type: AnyRecord): Map<string, number> => {
|
||||
const usage = new Map<string, number>()
|
||||
|
||||
const increment = (typeProductId: string | null) => {
|
||||
if (!typeProductId) return
|
||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||
getComponentRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.composantId) return
|
||||
const component = findComponentById(components.value, entry.composantId as string)
|
||||
const typeProductId =
|
||||
((component?.product as AnyRecord)?.typeProductId as string) ||
|
||||
(((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||
getPieceRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.pieceId) return
|
||||
const piece = findPieceById(entry.pieceId as string)
|
||||
const typeProductId =
|
||||
((piece?.product as AnyRecord)?.typeProductId as string) ||
|
||||
(((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||
getProductRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.productId) return
|
||||
const product = findProductById(entry.productId as string)
|
||||
const typeProductId =
|
||||
((product?.typeProductId as string) ||
|
||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry?.typeProductId as string) ||
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null)
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
const validateSkeletonSelections = (type: AnyRecord) => {
|
||||
const errors: string[] = []
|
||||
const componentLinksPayload: AnyRecord[] = []
|
||||
const pieceLinksPayload: AnyRecord[] = []
|
||||
const productLinksPayload: AnyRecord[] = []
|
||||
|
||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const resolvedTypeId = (entry.typeComposantId || requirement.typeComposantId || null) as string | null
|
||||
if (!resolvedTypeId) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite un type de composant.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, typeComposantId: resolvedTypeId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.composantId) payload.composantId = entry.composantId
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
componentLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const resolvedTypeId = (entry.typePieceId || requirement.typePieceId || null) as string | null
|
||||
if (!resolvedTypeId) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite un type de pièce.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, typePieceId: resolvedTypeId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.pieceId) payload.pieceId = entry.pieceId
|
||||
if (entry.composantId) payload.composantId = entry.composantId
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
pieceLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
const productUsage = computeSkeletonProductUsage(type)
|
||||
|
||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s) directe(s).`)
|
||||
}
|
||||
|
||||
const typeProductId = (requirement.typeProductId as string) || ((requirement.typeProduct as AnyRecord)?.id as string) || null
|
||||
const count = typeProductId ? productUsage.get(typeProductId) ?? 0 : 0
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
|
||||
if (count < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" nécessite au moins ${min} sélection(s).`)
|
||||
}
|
||||
if (max !== null && count > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.productId) {
|
||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
||||
return
|
||||
}
|
||||
const product = findProductById(entry.productId as string)
|
||||
if (!product) {
|
||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||
return
|
||||
}
|
||||
const productTypeId =
|
||||
(product.typeProductId as string) ||
|
||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry.typeProductId as string) ||
|
||||
null
|
||||
if (typeProductId && productTypeId && productTypeId !== typeProductId) {
|
||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.typeProductId) payload.typeProductId = entry.typeProductId
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
productLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { valid: false as const, error: errors[0] }
|
||||
return {
|
||||
valid: true as const,
|
||||
componentLinks: componentLinksPayload,
|
||||
pieceLinks: pieceLinksPayload,
|
||||
productLinks: productLinksPayload,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply reconfiguration result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const applySkeletonReconfigurationResult = async (data: AnyRecord) => {
|
||||
if (!data) return
|
||||
|
||||
const updatedMachine = (data.machine as AnyRecord) || data
|
||||
if (updatedMachine) {
|
||||
machine.value = {
|
||||
...machine.value,
|
||||
...updatedMachine,
|
||||
documents: (updatedMachine.documents as AnyRecord[]) || (machine.value?.documents as AnyRecord[]) || [],
|
||||
}
|
||||
initMachineFields()
|
||||
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
|
||||
}
|
||||
|
||||
const linksApplied = applyMachineLinks(data) || applyMachineLinks(updatedMachine)
|
||||
if (linksApplied) {
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
collapseAllComponents()
|
||||
return
|
||||
}
|
||||
|
||||
const newComponents = (data.components ?? updatedMachine?.components ?? null) as AnyRecord[] | null
|
||||
if (Array.isArray(newComponents)) {
|
||||
components.value = transformComponentCustomFields(newComponents)
|
||||
collapseAllComponents()
|
||||
}
|
||||
|
||||
const newPieces = (data.pieces ?? updatedMachine?.pieces ?? null) as AnyRecord[] | null
|
||||
if (Array.isArray(newPieces)) {
|
||||
pieces.value = transformCustomFields(newPieces)
|
||||
}
|
||||
|
||||
const prodLinks =
|
||||
resolveLinkArray(data, ['productLinks', 'machineProductLinks']) ??
|
||||
resolveLinkArray(updatedMachine, ['productLinks', 'machineProductLinks'])
|
||||
if (Array.isArray(prodLinks)) {
|
||||
machineProductLinks.value = prodLinks as AnyRecord[]
|
||||
if (machine.value) machine.value.productLinks = prodLinks
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const saveSkeletonConfiguration = async () => {
|
||||
if (!machine.value?.id) return
|
||||
|
||||
const type = machineType.value as AnyRecord
|
||||
let payload: AnyRecord = { componentLinks: [], pieceLinks: [], productLinks: [] }
|
||||
|
||||
if (type && machineHasSkeletonRequirements.value) {
|
||||
const validation = validateSkeletonSelections(type)
|
||||
if (!validation.valid) {
|
||||
toast.showError((validation as AnyRecord).error as string)
|
||||
return
|
||||
}
|
||||
payload = {
|
||||
componentLinks: (validation as AnyRecord).componentLinks,
|
||||
pieceLinks: (validation as AnyRecord).pieceLinks,
|
||||
productLinks: (validation as AnyRecord).productLinks,
|
||||
}
|
||||
}
|
||||
|
||||
skeletonEditor.submitting = true
|
||||
try {
|
||||
const result = await reconfigureMachineSkeleton(machine.value.id as string, payload)
|
||||
if ((result as AnyRecord).success) {
|
||||
await applySkeletonReconfigurationResult((result as AnyRecord).data as AnyRecord)
|
||||
await changeMachineView('details')
|
||||
} else if ((result as AnyRecord).error) {
|
||||
toast.showError((result as AnyRecord).error as string)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
||||
toast.showError('Erreur lors de la mise à jour des éléments du squelette')
|
||||
} finally {
|
||||
skeletonEditor.submitting = false
|
||||
skeletonEditor.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// View state
|
||||
activeMachineView,
|
||||
isDetailsView,
|
||||
isSkeletonView,
|
||||
|
||||
// Editor state
|
||||
skeletonEditor,
|
||||
componentRequirementSelections,
|
||||
pieceRequirementSelections,
|
||||
productRequirementSelections,
|
||||
|
||||
// Entry getters
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
|
||||
// Label resolvers
|
||||
resolveComponentRequirementTypeLabel,
|
||||
resolvePieceRequirementTypeLabel,
|
||||
resolveProductRequirementTypeLabel,
|
||||
getProductOptionsForRequirement,
|
||||
|
||||
// Selection CRUD
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
setComponentRequirementType,
|
||||
setComponentRequirementConstructeur,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
setPieceRequirementType,
|
||||
setPieceRequirementConstructeur,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setProductRequirementProduct,
|
||||
setProductRequirementType,
|
||||
|
||||
// Editor lifecycle
|
||||
openSkeletonEditor,
|
||||
closeSkeletonEditor,
|
||||
changeMachineView,
|
||||
initializeSkeletonRequirementSelections,
|
||||
|
||||
// Validation & save
|
||||
validateSkeletonSelections,
|
||||
saveSkeletonConfiguration,
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface MachineTypeRequirement {
|
||||
id?: string
|
||||
label?: string
|
||||
minCount?: number
|
||||
maxCount?: number
|
||||
required?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MachineType {
|
||||
id: string
|
||||
name: string
|
||||
componentRequirements: MachineTypeRequirement[]
|
||||
pieceRequirements: MachineTypeRequirement[]
|
||||
productRequirements: MachineTypeRequirement[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const machineTypes = ref<MachineType[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.map((entry: Record<string, unknown>, _index: number) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return entry
|
||||
}
|
||||
const normalized = { ...entry }
|
||||
const relationField = relationKey.replace('Id', '')
|
||||
const relationValue = normalized[relationField]
|
||||
if (relationKey && !normalized[relationKey]) {
|
||||
const relationId = extractRelationId(relationValue)
|
||||
if (relationId) {
|
||||
normalized[relationKey] = relationId
|
||||
}
|
||||
}
|
||||
return normalized as MachineTypeRequirement
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
|
||||
if (!type || typeof type !== 'object') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...type,
|
||||
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
|
||||
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
|
||||
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
|
||||
} as MachineType
|
||||
}
|
||||
|
||||
export function useMachineTypesApi() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, put, delete: del } = useApi()
|
||||
|
||||
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/type_machines')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
machineTypes.value = items
|
||||
.map((item) => normalizeMachineType(item as Record<string, unknown>))
|
||||
.filter((item): item is MachineType => item !== null)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des types de machines:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/type_machines', typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
if (normalized) machineTypes.value.push(normalized)
|
||||
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await put(`/type_machines/${id}`, typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachineType = async (id: string): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedType = machineTypes.value.find((type) => type.id === id)
|
||||
machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
|
||||
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypeById = async (id: string, forceRefresh = false): Promise<ApiResponse> => {
|
||||
// D'abord chercher dans le cache local (sauf si forceRefresh)
|
||||
if (!forceRefresh) {
|
||||
const localType = machineTypes.value.find((type) => type.id === id)
|
||||
if (localType) {
|
||||
return { success: true, data: localType }
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer depuis l'API
|
||||
try {
|
||||
const result = await get(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
// Mettre à jour le cache local
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
} else if (normalized) {
|
||||
machineTypes.value.push(normalized)
|
||||
}
|
||||
return { success: true, data: normalized }
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypes = (): MachineType[] => machineTypes.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machineTypes,
|
||||
loading,
|
||||
loadMachineTypes,
|
||||
createMachineType,
|
||||
updateMachineType,
|
||||
deleteMachineType,
|
||||
getMachineTypeById,
|
||||
getMachineTypes,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export interface Machine {
|
||||
id: string
|
||||
name?: string
|
||||
siteId?: string | null
|
||||
typeMachineId?: string | null
|
||||
componentLinks?: unknown[]
|
||||
pieceLinks?: unknown[]
|
||||
[key: string]: unknown
|
||||
@@ -53,13 +52,6 @@ const normalizeMachineResponse = (payload: unknown): Machine | null => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalized.typeMachineId) {
|
||||
const typeMachineId = extractRelationId(container.typeMachine)
|
||||
if (typeMachineId) {
|
||||
normalized.typeMachineId = typeMachineId
|
||||
}
|
||||
}
|
||||
|
||||
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
|
||||
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
||||
[]
|
||||
@@ -121,15 +113,6 @@ export function useMachines() {
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
|
||||
const machineWithStructure = {
|
||||
...machineData,
|
||||
typeMachineId: typeMachine.id,
|
||||
}
|
||||
|
||||
return await createMachine(machineWithStructure)
|
||||
}
|
||||
|
||||
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -157,14 +140,14 @@ export function useMachines() {
|
||||
}
|
||||
}
|
||||
|
||||
const reconfigureSkeleton = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
||||
const updateStructure = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
return { success: false, error: 'Identifiant de machine manquant' }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/machines/${machineId}/skeleton`, payload)
|
||||
const result = await patch(`/machines/${machineId}/structure`, payload)
|
||||
if (result.success) {
|
||||
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
||||
if (index !== -1) {
|
||||
@@ -180,7 +163,29 @@ export function useMachines() {
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
||||
console.error('Erreur lors de la mise à jour de la structure de la machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post(`/machines/${sourceId}/clone`, data)
|
||||
if (result.success) {
|
||||
const clonedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
if (clonedMachine) {
|
||||
machines.value.push(clonedMachine)
|
||||
}
|
||||
showSuccess(`Machine "${clonedMachine?.name || data.name}" clonée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du clonage de la machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -241,10 +246,6 @@ export function useMachines() {
|
||||
return machines.value.filter((machine) => machine.siteId === siteId)
|
||||
}
|
||||
|
||||
const getMachinesByType = (typeMachineId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
|
||||
}
|
||||
|
||||
const getMachines = (): Machine[] => machines.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
@@ -253,13 +254,12 @@ export function useMachines() {
|
||||
loading,
|
||||
loadMachines,
|
||||
createMachine,
|
||||
createMachineFromType,
|
||||
updateMachine: updateMachineData,
|
||||
reconfigureSkeleton,
|
||||
updateStructure,
|
||||
cloneMachine,
|
||||
deleteMachine,
|
||||
getMachineById,
|
||||
getMachinesBySite,
|
||||
getMachinesByType,
|
||||
getMachines,
|
||||
isLoading,
|
||||
addMissingCustomFields,
|
||||
|
||||
472
app/composables/usePieceEdit.ts
Normal file
472
app/composables/usePieceEdit.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from '#imports'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { usePieceHistory } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
getStructureProducts,
|
||||
buildProductRequirementDescriptions,
|
||||
buildProductRequirementEntries,
|
||||
resizeProductSelections,
|
||||
areProductSelectionsFilled,
|
||||
applyProductSelection,
|
||||
collectNormalizedProductIds,
|
||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
export function usePieceEdit(pieceId: string) {
|
||||
const { canEdit } = usePermissions()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { updatePiece } = usePieces()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = usePieceHistory()
|
||||
|
||||
const piece = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const pieceDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
typePiece: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
productIds: 'Produits liés',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const pieceTypeDetails = ref<any | null>(null)
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
description: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
||||
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
||||
)
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: PieceModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? resolvedStructure.value ?? null
|
||||
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
return
|
||||
}
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||
if (!documentId) {
|
||||
return
|
||||
}
|
||||
const result = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success) {
|
||||
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files: File[]) => {
|
||||
if (!files?.length || !piece.value?.id) {
|
||||
return
|
||||
}
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const result = await uploadDocuments(
|
||||
{
|
||||
files,
|
||||
context: { pieceId: piece.value.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (result.success) {
|
||||
selectedFiles.value = []
|
||||
await refreshDocuments()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
if (!piece.value?.id) {
|
||||
pieceDocuments.value = []
|
||||
return
|
||||
}
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
||||
|
||||
const selectedType = computed(() => {
|
||||
if (!selectedTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const structureProducts = computed(() =>
|
||||
getStructureProducts(resolvedStructure.value),
|
||||
)
|
||||
|
||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||
|
||||
const productRequirementDescriptions = computed(() =>
|
||||
buildProductRequirementDescriptions(structureProducts.value),
|
||||
)
|
||||
|
||||
const ensureProductSelections = (count: number) => {
|
||||
productSelections.value = resizeProductSelections(productSelections.value, count)
|
||||
}
|
||||
|
||||
let pendingProductIds: string[] = []
|
||||
|
||||
const productRequirementEntries = computed(() =>
|
||||
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
areProductSelectionsFilled(
|
||||
requiresProductSelection.value,
|
||||
productRequirementEntries.value,
|
||||
productSelections.value,
|
||||
),
|
||||
)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||
}
|
||||
|
||||
watch(structureProducts, (products) => {
|
||||
ensureProductSelections(products.length)
|
||||
if (!pendingProductIds.length || products.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = Array.from(
|
||||
{ length: products.length },
|
||||
(_, index) => pendingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(
|
||||
canEdit.value
|
||||
&& piece.value
|
||||
&& editionForm.name
|
||||
&& requiredCustomFieldsFilled.value
|
||||
&& productSelectionsFilled.value
|
||||
&& !saving.value,
|
||||
),
|
||||
)
|
||||
|
||||
const fetchPiece = async () => {
|
||||
if (!pieceId || typeof pieceId !== 'string') {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
return
|
||||
}
|
||||
const result = await get(`/pieces/${pieceId}`)
|
||||
if (result.success) {
|
||||
piece.value = result.data
|
||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||
loadPieceTypeDetailsFromCache(result.data)
|
||||
|
||||
// History is non-blocking — template handles its own loading state
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
}
|
||||
else {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
||||
const typeId = currentPiece?.typePieceId
|
||||
|| extractRelationId(currentPiece?.typePiece)
|
||||
|| ''
|
||||
if (!typeId) {
|
||||
pieceTypeDetails.value = null
|
||||
return
|
||||
}
|
||||
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
||||
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||
if (cachedType) {
|
||||
pieceTypeDetails.value = cachedType
|
||||
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
return
|
||||
}
|
||||
// Fallback: fetch if not in cache (edge case)
|
||||
getModelType(typeId).then((type) => {
|
||||
if (type && typeof type === 'object') {
|
||||
pieceTypeDetails.value = type
|
||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
}
|
||||
}).catch(() => {
|
||||
pieceTypeDetails.value = null
|
||||
})
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
watch(
|
||||
[piece, selectedType],
|
||||
([currentPiece, _currentType]) => {
|
||||
if (!currentPiece || initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedTypeId = currentPiece.typePieceId
|
||||
|| extractRelationId(currentPiece.typePiece)
|
||||
|| ''
|
||||
if (resolvedTypeId && !currentPiece.typePieceId) {
|
||||
currentPiece.typePieceId = resolvedTypeId
|
||||
}
|
||||
selectedTypeId.value = resolvedTypeId
|
||||
|
||||
editionForm.name = currentPiece.name || ''
|
||||
editionForm.description = currentPiece.description || ''
|
||||
editionForm.reference = currentPiece.reference || ''
|
||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||
currentPiece,
|
||||
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
|
||||
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||
)
|
||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
|
||||
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
|
||||
? currentPiece.productIds.map((id: unknown) => String(id))
|
||||
: currentPiece.product?.id || currentPiece.productId
|
||||
? [String(currentPiece.product?.id || currentPiece.productId)]
|
||||
: []
|
||||
pendingProductIds = existingProductIds
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
if (existingProductIds.length && structureProducts.value.length) {
|
||||
const next = Array.from(
|
||||
{ length: structureProducts.value.length },
|
||||
(_, index) => existingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
}
|
||||
|
||||
// After setting selectedTypeId, read selectedType.value (now updated) instead of
|
||||
// the stale destructured currentType which was captured before the ID change.
|
||||
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
|
||||
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
|
||||
|
||||
initialized = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selectedType, (currentType) => {
|
||||
if (!piece.value || !currentType) {
|
||||
return
|
||||
}
|
||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||
})
|
||||
|
||||
watch(resolvedStructure, (currentStructure) => {
|
||||
if (!piece.value) {
|
||||
return
|
||||
}
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
||||
})
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!piece.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.prix === 'string'
|
||||
? editionForm.prix.trim()
|
||||
: editionForm.prix === null || editionForm.prix === undefined
|
||||
? ''
|
||||
: String(editionForm.prix).trim()
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
description: editionForm.description.trim() || null,
|
||||
constructeurIds,
|
||||
}
|
||||
|
||||
const reference = editionForm.reference.trim()
|
||||
payload.reference = reference ? reference : null
|
||||
|
||||
const normalizedProductIds = collectNormalizedProductIds(
|
||||
productRequirementEntries.value,
|
||||
productSelections.value,
|
||||
)
|
||||
|
||||
payload.productIds = normalizedProductIds
|
||||
payload.productId = normalizedProductIds[0] || null
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
else {
|
||||
payload.prix = null
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updatePiece(piece.value.id, payload)
|
||||
if (result.success && result.data) {
|
||||
const updatedPiece = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'piece',
|
||||
updatedPiece.id,
|
||||
[
|
||||
updatedPiece?.typePiece?.pieceCustomFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/pieces-catalog')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la mise à jour de la pièce')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
piece,
|
||||
loading,
|
||||
saving,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
pieceDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
productSelections,
|
||||
customFieldInputs,
|
||||
canEdit,
|
||||
|
||||
// Computed
|
||||
pieceTypeList,
|
||||
selectedType,
|
||||
resolvedStructure,
|
||||
structureProducts,
|
||||
productRequirementDescriptions,
|
||||
productRequirementEntries,
|
||||
canSubmit,
|
||||
historyFieldLabels,
|
||||
|
||||
// History
|
||||
history,
|
||||
historyLoading,
|
||||
historyError,
|
||||
|
||||
// Methods
|
||||
openPreview,
|
||||
closePreview,
|
||||
removeDocument,
|
||||
handleFilesAdded,
|
||||
setProductSelection,
|
||||
submitEdition,
|
||||
formatPieceStructurePreview,
|
||||
}
|
||||
}
|
||||
444
app/composables/usePieceStructureEditorLogic.ts
Normal file
444
app/composables/usePieceStructureEditorLogic.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import type {
|
||||
PieceModelCustomField,
|
||||
PieceModelCustomFieldType,
|
||||
PieceModelProduct,
|
||||
PieceModelStructure,
|
||||
PieceModelStructureEditorField,
|
||||
} from '~/shared/types/inventory'
|
||||
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
|
||||
export type EditorField = PieceModelStructureEditorField & { uid: string }
|
||||
export type EditorProduct = {
|
||||
uid: string
|
||||
typeProductId: string
|
||||
typeProductLabel: string
|
||||
familyCode: string
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
props: {
|
||||
modelValue?: PieceModelStructure | null
|
||||
restrictedMode?: boolean
|
||||
}
|
||||
emit: (event: 'update:modelValue', value: PieceModelStructure) => void
|
||||
}
|
||||
|
||||
// --- Pure helpers ---
|
||||
|
||||
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
|
||||
Array.isArray(value) ? value : []
|
||||
|
||||
const normalizeLineEndings = (value: string): string =>
|
||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const safeClone = <T,>(value: T, fallback: T): T => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
||||
}
|
||||
catch {
|
||||
return JSON.parse(JSON.stringify(fallback)) as T
|
||||
}
|
||||
}
|
||||
|
||||
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const entries = Object.entries(structure).filter(
|
||||
([key]) => key !== 'customFields' && key !== 'products',
|
||||
)
|
||||
return safeClone(Object.fromEntries(entries), {})
|
||||
}
|
||||
|
||||
let uidCounter = 0
|
||||
const createUid = (scope: 'field' | 'product'): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
uidCounter += 1
|
||||
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
|
||||
}
|
||||
|
||||
// --- Hydration ---
|
||||
|
||||
const toEditorField = (
|
||||
input: Partial<PieceModelStructureEditorField> | null | undefined,
|
||||
index: number,
|
||||
): EditorField => {
|
||||
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
|
||||
const optionsText = normalizeLineEndings(
|
||||
typeof input?.optionsText === 'string'
|
||||
? input.optionsText
|
||||
: Array.isArray(input?.options)
|
||||
? input.options.join('\n')
|
||||
: '',
|
||||
)
|
||||
|
||||
return {
|
||||
uid: createUid('field'),
|
||||
name: typeof input?.name === 'string' ? input.name : '',
|
||||
type: baseType as PieceModelCustomFieldType,
|
||||
required: Boolean(input?.required),
|
||||
optionsText,
|
||||
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
|
||||
const source = ensureArray(structure?.customFields)
|
||||
return source
|
||||
.map((field, index) => toEditorField(field, index))
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||
}
|
||||
|
||||
const toEditorProduct = (
|
||||
input: Partial<PieceModelProduct> | null | undefined,
|
||||
): EditorProduct => ({
|
||||
uid: createUid('product'),
|
||||
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
|
||||
typeProductLabel:
|
||||
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
|
||||
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
|
||||
})
|
||||
|
||||
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
|
||||
const source = Array.isArray(structure?.products) ? structure?.products : []
|
||||
return source.map(product => toEditorProduct(product))
|
||||
}
|
||||
|
||||
// --- Payload ---
|
||||
|
||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
list.map((field, index) => ({
|
||||
...field,
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
|
||||
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
|
||||
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
|
||||
|
||||
if (!typeProductId && !familyCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: PieceModelProduct = {}
|
||||
if (typeProductId) {
|
||||
payload.typeProductId = typeProductId
|
||||
}
|
||||
if (familyCode) {
|
||||
payload.familyCode = familyCode
|
||||
}
|
||||
if (product.typeProductLabel) {
|
||||
payload.typeProductLabel = product.typeProductLabel
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const buildPayload = (
|
||||
fieldsSource: EditorField[],
|
||||
productsSource: EditorProduct[],
|
||||
restSource: Record<string, unknown>,
|
||||
): PieceModelStructure => {
|
||||
const normalizedFields = fieldsSource
|
||||
.map<PieceModelCustomField | null>((field, index) => {
|
||||
const name = field.name.trim()
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = (field.type || 'text') as PieceModelCustomFieldType
|
||||
const required = Boolean(field.required)
|
||||
const payload: PieceModelCustomField = {
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
orderIndex: index,
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
const options = normalizeLineEndings(field.optionsText)
|
||||
.split('\n')
|
||||
.map(option => option.trim())
|
||||
.filter(option => option.length > 0)
|
||||
if (options.length > 0) {
|
||||
payload.options = options
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
})
|
||||
.filter((field): field is PieceModelCustomField => Boolean(field))
|
||||
|
||||
const normalizedProducts = productsSource
|
||||
.map(product => normalizeProductEntry(product))
|
||||
.filter((product): product is PieceModelProduct => Boolean(product))
|
||||
|
||||
const draft: PieceModelStructure = {
|
||||
...safeClone(restSource, {}),
|
||||
products: normalizedProducts,
|
||||
customFields: normalizedFields,
|
||||
}
|
||||
|
||||
return normalizePieceStructureForSave(draft)
|
||||
}
|
||||
|
||||
const serializeStructure = (structure?: PieceModelStructure | null): string => {
|
||||
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
|
||||
}
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function usePieceStructureEditorLogic(deps: Deps) {
|
||||
const { props, emit } = deps
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
|
||||
// --- State ---
|
||||
|
||||
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||
|
||||
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
|
||||
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
|
||||
|
||||
// --- Product types ---
|
||||
|
||||
const productTypeOptions = computed(() => productTypes.value ?? [])
|
||||
|
||||
const productTypeMap = computed(() => {
|
||||
const map = new Map<string, any>()
|
||||
productTypeOptions.value.forEach((type: any) => {
|
||||
if (type?.id) {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const formatProductTypeOption = (type: any) => {
|
||||
if (!type) {
|
||||
return ''
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (type.code) {
|
||||
parts.push(type.code)
|
||||
}
|
||||
if (type.name) {
|
||||
parts.push(type.name)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : type.id || ''
|
||||
}
|
||||
|
||||
const updateProductTypeMetadata = (product: EditorProduct) => {
|
||||
const option = product.typeProductId
|
||||
? productTypeMap.value.get(product.typeProductId)
|
||||
: null
|
||||
product.typeProductLabel = option?.name ?? ''
|
||||
}
|
||||
|
||||
const handleProductTypeSelect = (product: EditorProduct) => {
|
||||
const option = product.typeProductId
|
||||
? productTypeMap.value.get(product.typeProductId)
|
||||
: null
|
||||
product.typeProductLabel = option?.name ?? ''
|
||||
if (option?.code) {
|
||||
product.familyCode = option.code
|
||||
}
|
||||
}
|
||||
|
||||
// --- Locked state ---
|
||||
|
||||
const isFieldLocked = (field: EditorField): boolean => {
|
||||
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
|
||||
}
|
||||
|
||||
const isProductLocked = (product: EditorProduct): boolean => {
|
||||
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
|
||||
}
|
||||
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
const createEmptyProduct = (): EditorProduct => ({
|
||||
uid: createUid('product'),
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
})
|
||||
|
||||
const addProduct = () => {
|
||||
products.value.push(createEmptyProduct())
|
||||
}
|
||||
|
||||
const removeProduct = (index: number) => {
|
||||
products.value = products.value.filter((_, idx) => idx !== index)
|
||||
}
|
||||
|
||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||
uid: createUid('field'),
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
orderIndex,
|
||||
})
|
||||
|
||||
const addField = () => {
|
||||
const next = fields.value.slice()
|
||||
next.push(createEmptyField(next.length))
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
const removeField = (index: number) => {
|
||||
const next = fields.value.filter((_, i) => i !== index)
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
// --- Drag & drop ---
|
||||
|
||||
const dragState = reactive({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
})
|
||||
|
||||
const resetDragState = () => {
|
||||
dragState.draggingIndex = null
|
||||
dragState.dropTargetIndex = null
|
||||
}
|
||||
|
||||
const reorderFields = (from: number, to: number) => {
|
||||
if (from === to) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const list = fields.value.slice()
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const [moved] = list.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
list.splice(to, 0, moved)
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
dragState.draggingIndex = index
|
||||
dragState.dropTargetIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (dragState.draggingIndex === null) {
|
||||
return
|
||||
}
|
||||
dragState.dropTargetIndex = index
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
if (dragState.draggingIndex === null) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
reorderFields(dragState.draggingIndex, index)
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number) => {
|
||||
if (dragState.draggingIndex === index) {
|
||||
return 'border-dashed border-primary bg-primary/5'
|
||||
}
|
||||
if (
|
||||
dragState.draggingIndex !== null
|
||||
&& dragState.dropTargetIndex === index
|
||||
&& dragState.draggingIndex !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/10'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// --- Emit ---
|
||||
|
||||
let lastEmitted = serializeStructure(props.modelValue)
|
||||
|
||||
const emitUpdate = () => {
|
||||
const payload = buildPayload(fields.value, products.value, restState.value)
|
||||
const serialized = JSON.stringify(payload)
|
||||
if (serialized !== lastEmitted) {
|
||||
lastEmitted = serialized
|
||||
emit('update:modelValue', payload)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
|
||||
watch(fields, emitUpdate, { deep: true })
|
||||
watch(products, emitUpdate, { deep: true })
|
||||
watch(productTypeOptions, () => {
|
||||
products.value.forEach(product => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const incomingSerialized = serializeStructure(value)
|
||||
if (incomingSerialized === lastEmitted) {
|
||||
return
|
||||
}
|
||||
restState.value = extractRest(value)
|
||||
fields.value = hydrateFields(value)
|
||||
products.value = hydrateProducts(value)
|
||||
products.value.forEach(product => updateProductTypeMetadata(product))
|
||||
lastEmitted = incomingSerialized
|
||||
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
|
||||
initialProductUids.value = new Set(products.value.map(p => p.uid))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMounted(async () => {
|
||||
if (!productTypeOptions.value.length) {
|
||||
await loadProductTypes()
|
||||
}
|
||||
products.value.forEach(product => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
return {
|
||||
fields,
|
||||
products,
|
||||
productTypeOptions,
|
||||
restrictedMode,
|
||||
isFieldLocked,
|
||||
isProductLocked,
|
||||
formatProductTypeOption,
|
||||
handleProductTypeSelect,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addField,
|
||||
removeField,
|
||||
reorderClass,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface Piece {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
description?: string | null
|
||||
typePieceId?: string | null
|
||||
typePiece?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
@@ -41,6 +42,7 @@ interface LoadPiecesOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -116,10 +118,11 @@ export function usePieces() {
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
@@ -144,6 +147,10 @@ export function usePieces() {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typePiece.name', typeName.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
@@ -39,6 +40,7 @@ interface LoadProductsOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -115,10 +117,11 @@ export function useProducts() {
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
@@ -143,6 +146,10 @@ export function useProducts() {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeProduct.name', typeName.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
@@ -168,9 +175,9 @@ export function useProducts() {
|
||||
return result as ProductListResult
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement des produits:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(`Impossible de charger les produits: ${message}`)
|
||||
showError(`Impossible de charger les produits.`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -197,9 +204,9 @@ export function useProducts() {
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la création du produit:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(message)
|
||||
showError('Impossible de créer le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -223,9 +230,9 @@ export function useProducts() {
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(message)
|
||||
showError('Impossible de mettre à jour le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -248,9 +255,9 @@ export function useProducts() {
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la suppression du produit:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(message)
|
||||
showError('Impossible de supprimer le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { navigateTo, useRoute } from '#imports'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
@@ -9,6 +10,7 @@ import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
type SiteForm = {
|
||||
name: string
|
||||
color: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactAddress: string
|
||||
@@ -23,11 +25,14 @@ type SiteDocument = {
|
||||
mimeType?: string
|
||||
size?: number
|
||||
path?: string
|
||||
fileUrl?: string
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
type SiteWithDocuments = {
|
||||
id: string
|
||||
name?: string
|
||||
color?: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
contactAddress?: string
|
||||
@@ -51,6 +56,7 @@ export function useSiteManagement() {
|
||||
|
||||
const newSite = reactive<SiteForm>({
|
||||
name: '',
|
||||
color: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactAddress: '',
|
||||
@@ -60,6 +66,7 @@ export function useSiteManagement() {
|
||||
|
||||
const editSiteForm = reactive<SiteForm>({
|
||||
name: '',
|
||||
color: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactAddress: '',
|
||||
@@ -78,6 +85,7 @@ export function useSiteManagement() {
|
||||
|
||||
const resetNewSite = () => {
|
||||
newSite.name = ''
|
||||
newSite.color = ''
|
||||
newSite.contactName = ''
|
||||
newSite.contactPhone = ''
|
||||
newSite.contactAddress = ''
|
||||
@@ -98,6 +106,7 @@ export function useSiteManagement() {
|
||||
const handleCreateSite = async () => {
|
||||
const result = await createSite({
|
||||
name: newSite.name,
|
||||
color: newSite.color,
|
||||
contactName: newSite.contactName,
|
||||
contactPhone: newSite.contactPhone,
|
||||
contactAddress: newSite.contactAddress,
|
||||
@@ -113,6 +122,7 @@ export function useSiteManagement() {
|
||||
const editSite = (site: SiteWithDocuments) => {
|
||||
siteBeingEdited.value = site
|
||||
editSiteForm.name = site.name || ''
|
||||
editSiteForm.color = site.color || ''
|
||||
editSiteForm.contactName = site.contactName || ''
|
||||
editSiteForm.contactPhone = site.contactPhone || ''
|
||||
editSiteForm.contactAddress = site.contactAddress || ''
|
||||
@@ -145,6 +155,7 @@ export function useSiteManagement() {
|
||||
|
||||
const baseUpdate = {
|
||||
name: editSiteForm.name,
|
||||
color: editSiteForm.color,
|
||||
contactName: editSiteForm.contactName,
|
||||
contactPhone: editSiteForm.contactPhone,
|
||||
contactAddress: editSiteForm.contactAddress,
|
||||
@@ -209,17 +220,23 @@ export function useSiteManagement() {
|
||||
}
|
||||
|
||||
const downloadDocument = (doc: SiteDocument) => {
|
||||
if (!doc?.path) return
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
if (doc.path.startsWith('data:')) {
|
||||
const url = doc?.fileUrl || doc?.path
|
||||
if (!url) return
|
||||
|
||||
if (url.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = doc.path
|
||||
link.href = url
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(doc.path, '_blank')
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc: SiteDocument) => {
|
||||
@@ -266,10 +283,10 @@ export function useSiteManagement() {
|
||||
if (result.success) {
|
||||
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
||||
showError(`Impossible de supprimer le site : ${humanizeError(error.message)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
export interface Site {
|
||||
id: string
|
||||
name?: string
|
||||
color?: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
contactAddress?: string
|
||||
|
||||
366
app/composables/useStructureAssignmentFetch.ts
Normal file
366
app/composables/useStructureAssignmentFetch.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import {
|
||||
componentOptionDescription,
|
||||
componentOptionLabel,
|
||||
describePieceRequirement as _describePieceRequirement,
|
||||
describeProductRequirement as _describeProductRequirement,
|
||||
pieceOptionDescription,
|
||||
pieceOptionLabel,
|
||||
productOptionDescription,
|
||||
productOptionLabel,
|
||||
} from '~/shared/utils/structureAssignmentLabels'
|
||||
import type {
|
||||
ComponentOption,
|
||||
PieceOption,
|
||||
ProductOption,
|
||||
StructureAssignmentNode,
|
||||
StructurePieceAssignment,
|
||||
StructureProductAssignment,
|
||||
} from '~/shared/utils/structureAssignmentLabels'
|
||||
|
||||
export type {
|
||||
ComponentOption,
|
||||
PieceOption,
|
||||
ProductOption,
|
||||
StructureAssignmentNode,
|
||||
StructurePieceAssignment,
|
||||
StructureProductAssignment,
|
||||
} from '~/shared/utils/structureAssignmentLabels'
|
||||
|
||||
export interface StructureAssignmentFetchDeps {
|
||||
assignment: StructureAssignmentNode
|
||||
pieces: PieceOption[] | null
|
||||
products: ProductOption[] | null
|
||||
components: ComponentOption[] | null
|
||||
isRoot: () => boolean
|
||||
pieceTypeLabelMap: Record<string, string>
|
||||
productTypeLabelMap: Record<string, string>
|
||||
componentTypeLabelMap: Record<string, string>
|
||||
}
|
||||
|
||||
export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps) {
|
||||
const { get } = useApi()
|
||||
|
||||
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({})
|
||||
const productOptionsByPath = ref<Record<string, ProductOption[]>>({})
|
||||
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({})
|
||||
const pieceLoadingByPath = ref<Record<string, boolean>>({})
|
||||
const productLoadingByPath = ref<Record<string, boolean>>({})
|
||||
const componentLoadingByPath = ref<Record<string, boolean>>({})
|
||||
|
||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
||||
target[key] = value
|
||||
}
|
||||
|
||||
const typeIri = (id: string) => `/api/model_types/${id}`
|
||||
const primedPiecePaths = new Set<string>()
|
||||
const primedProductPaths = new Set<string>()
|
||||
const primedComponentPaths = new Set<string>()
|
||||
|
||||
// --- Component options ---
|
||||
|
||||
const componentOptions = computed(() => {
|
||||
if (deps.isRoot()) {
|
||||
return []
|
||||
}
|
||||
const cached = componentOptionsByPath.value[deps.assignment.path]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const definition = deps.assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || null
|
||||
const requiredFamilyCode = definition.familyCode || null
|
||||
|
||||
return (deps.components || []).filter((component) => {
|
||||
if (!component || typeof component !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
return component.typeComposantId === requiredTypeId
|
||||
}
|
||||
if (requiredFamilyCode) {
|
||||
return (
|
||||
component.typeComposant?.code === requiredFamilyCode
|
||||
|| component.typeComposantId === requiredFamilyCode
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const fetchComponentOptions = async (term = '') => {
|
||||
if (deps.isRoot()) {
|
||||
return
|
||||
}
|
||||
const key = deps.assignment.path
|
||||
if (componentLoadingByPath.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = deps.assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeComposant', typeIri(requiredTypeId))
|
||||
}
|
||||
|
||||
setLoading(componentLoadingByPath.value, key, true)
|
||||
try {
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
if (result.success) {
|
||||
componentOptionsByPath.value[key] = extractCollection(result.data)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(componentLoadingByPath.value, key, false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Piece options ---
|
||||
|
||||
const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
const cached = pieceOptionsByPath.value[assignment.path]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const definition = assignment.definition
|
||||
const requiredTypeId =
|
||||
definition.typePieceId
|
||||
|| definition.typePiece?.id
|
||||
|| definition.familyCode
|
||||
|| null
|
||||
|
||||
return (deps.pieces || []).filter((piece) => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (!requiredTypeId) {
|
||||
return true
|
||||
}
|
||||
if (definition.typePieceId || definition.typePiece?.id) {
|
||||
return (
|
||||
piece.typePieceId === requiredTypeId
|
||||
|| piece.typePiece?.id === requiredTypeId
|
||||
)
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return (
|
||||
piece.typePiece?.code === requiredTypeId
|
||||
|| piece.typePieceId === requiredTypeId
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
|
||||
const key = assignment.path
|
||||
if (pieceLoadingByPath.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typePieceId || definition.typePiece?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typePiece', typeIri(requiredTypeId))
|
||||
}
|
||||
|
||||
setLoading(pieceLoadingByPath.value, key, true)
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
pieceOptionsByPath.value[key] = extractCollection(result.data)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(pieceLoadingByPath.value, key, false)
|
||||
}
|
||||
}
|
||||
|
||||
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||
const options = getPieceOptions(assignment)
|
||||
return _describePieceRequirement(assignment, options, deps.pieceTypeLabelMap)
|
||||
}
|
||||
|
||||
// --- Product options ---
|
||||
|
||||
const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
const cached = productOptionsByPath.value[assignment.path]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const definition = assignment.definition
|
||||
const requiredTypeId =
|
||||
definition.typeProductId
|
||||
|| definition.typeProduct?.id
|
||||
|| definition.familyCode
|
||||
|| null
|
||||
|
||||
return (deps.products || []).filter((product) => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (!requiredTypeId) {
|
||||
return true
|
||||
}
|
||||
if (definition.typeProductId || definition.typeProduct?.id) {
|
||||
return (
|
||||
product.typeProductId === requiredTypeId
|
||||
|| product.typeProduct?.id === requiredTypeId
|
||||
)
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return (
|
||||
product.typeProduct?.code === requiredTypeId
|
||||
|| product.typeProductId === requiredTypeId
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
|
||||
const key = assignment.path
|
||||
if (productLoadingByPath.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typeProductId || definition.typeProduct?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeProduct', typeIri(requiredTypeId))
|
||||
}
|
||||
|
||||
setLoading(productLoadingByPath.value, key, true)
|
||||
try {
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
if (result.success) {
|
||||
productOptionsByPath.value[key] = extractCollection(result.data)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(productLoadingByPath.value, key, false)
|
||||
}
|
||||
}
|
||||
|
||||
const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||
const options = getProductOptions(assignment)
|
||||
return _describeProductRequirement(assignment, options, deps.productTypeLabelMap)
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
|
||||
watch(
|
||||
componentOptions,
|
||||
(options) => {
|
||||
if (deps.isRoot()) {
|
||||
return
|
||||
}
|
||||
const hasMatch = options.some(
|
||||
(component) => component.id === deps.assignment.selectedComponentId,
|
||||
)
|
||||
if (!hasMatch) {
|
||||
deps.assignment.selectedComponentId = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [deps.pieces, deps.assignment.pieces],
|
||||
() => {
|
||||
for (const pieceAssignment of deps.assignment.pieces) {
|
||||
const options = getPieceOptions(pieceAssignment)
|
||||
if (
|
||||
pieceAssignment.selectedPieceId
|
||||
&& !options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
||||
) {
|
||||
pieceAssignment.selectedPieceId = ''
|
||||
}
|
||||
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
|
||||
primedPiecePaths.add(pieceAssignment.path)
|
||||
fetchPieceOptions(pieceAssignment).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [deps.products, deps.assignment.products],
|
||||
() => {
|
||||
for (const productAssignment of deps.assignment.products) {
|
||||
const options = getProductOptions(productAssignment)
|
||||
if (
|
||||
productAssignment.selectedProductId
|
||||
&& !options.some((product) => product.id === productAssignment.selectedProductId)
|
||||
) {
|
||||
productAssignment.selectedProductId = ''
|
||||
}
|
||||
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
|
||||
primedProductPaths.add(productAssignment.path)
|
||||
fetchProductOptions(productAssignment).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => deps.assignment.definition,
|
||||
() => {
|
||||
if (deps.isRoot()) {
|
||||
return
|
||||
}
|
||||
const key = deps.assignment.path
|
||||
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
|
||||
primedComponentPaths.add(key)
|
||||
fetchComponentOptions().catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
pieceLoadingByPath,
|
||||
productLoadingByPath,
|
||||
componentLoadingByPath,
|
||||
componentOptions,
|
||||
componentOptionLabel,
|
||||
componentOptionDescription,
|
||||
fetchComponentOptions,
|
||||
getPieceOptions,
|
||||
pieceOptionLabel,
|
||||
pieceOptionDescription,
|
||||
fetchPieceOptions,
|
||||
describePieceRequirement,
|
||||
getProductOptions,
|
||||
productOptionLabel,
|
||||
productOptionDescription,
|
||||
fetchProductOptions,
|
||||
describeProductRequirement,
|
||||
}
|
||||
}
|
||||
206
app/composables/useStructureNodeCrud.ts
Normal file
206
app/composables/useStructureNodeCrud.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { ref } from 'vue'
|
||||
import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
|
||||
|
||||
export interface StructureNodeCrudDeps {
|
||||
node: EditableStructureNode
|
||||
restrictedMode: boolean
|
||||
canManageSubcomponents: () => boolean
|
||||
}
|
||||
|
||||
export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
|
||||
// --- Lock state ---
|
||||
const initialCustomFieldIndices = ref<Set<number>>(new Set())
|
||||
const initialPieceIndices = ref<Set<number>>(new Set())
|
||||
const initialProductIndices = ref<Set<number>>(new Set())
|
||||
const initialSubcomponentIndices = ref<Set<number>>(new Set())
|
||||
|
||||
const initializeLockedIndices = () => {
|
||||
if (props.restrictedMode) {
|
||||
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
|
||||
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
|
||||
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
|
||||
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
|
||||
|
||||
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
|
||||
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
|
||||
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
|
||||
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
|
||||
}
|
||||
}
|
||||
|
||||
initializeLockedIndices()
|
||||
|
||||
const isCustomFieldLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isPieceLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialPieceIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isProductLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialProductIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isSubcomponentLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
|
||||
if (!Array.isArray((props.node as any)[key])) {
|
||||
if (key === 'subcomponents') {
|
||||
props.node.subcomponents = []
|
||||
} else if (key === 'products') {
|
||||
props.node.products = []
|
||||
} else {
|
||||
(props.node as any)[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom field reindex ---
|
||||
const reindexCustomFields = () => {
|
||||
if (!Array.isArray(props.node.customFields)) {
|
||||
return
|
||||
}
|
||||
props.node.customFields.forEach((field: any, index: number) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
field.orderIndex = index
|
||||
})
|
||||
}
|
||||
|
||||
// --- Drag reorder ---
|
||||
const customFieldDrag = useDragReorder(
|
||||
() => props.node.customFields,
|
||||
{ onReorder: reindexCustomFields },
|
||||
)
|
||||
|
||||
const pieceDrag = useDragReorder(() => props.node.pieces)
|
||||
const productDrag = useDragReorder(() => props.node.products)
|
||||
const subcomponentDrag = useDragReorder(
|
||||
() => props.node.subcomponents,
|
||||
{ draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
|
||||
)
|
||||
|
||||
// --- CRUD functions ---
|
||||
const addCustomField = () => {
|
||||
ensureArray('customFields')
|
||||
const fields = props.node.customFields!
|
||||
const nextIndex = fields.length
|
||||
fields.push({
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
options: [],
|
||||
orderIndex: nextIndex,
|
||||
})
|
||||
reindexCustomFields()
|
||||
}
|
||||
|
||||
const removeCustomField = (index: number) => {
|
||||
if (!Array.isArray(props.node.customFields)) return
|
||||
props.node.customFields.splice(index, 1)
|
||||
reindexCustomFields()
|
||||
}
|
||||
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
familyCode: '',
|
||||
role: '',
|
||||
quantity: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const removePiece = (index: number) => {
|
||||
if (!Array.isArray(props.node.pieces)) return
|
||||
props.node.pieces.splice(index, 1)
|
||||
}
|
||||
|
||||
const addProduct = () => {
|
||||
ensureArray('products')
|
||||
props.node.products!.push({
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
})
|
||||
}
|
||||
|
||||
const removeProduct = (index: number) => {
|
||||
if (!Array.isArray(props.node.products)) return
|
||||
props.node.products.splice(index, 1)
|
||||
}
|
||||
|
||||
const addSubComponent = () => {
|
||||
if (!props.canManageSubcomponents()) {
|
||||
return
|
||||
}
|
||||
ensureArray('subcomponents')
|
||||
props.node.subcomponents.push({
|
||||
typeComposantId: '',
|
||||
typeComposantLabel: '',
|
||||
modelId: '',
|
||||
familyCode: '',
|
||||
alias: '',
|
||||
subcomponents: [],
|
||||
})
|
||||
}
|
||||
|
||||
const removeSubComponent = (index: number) => {
|
||||
if (!Array.isArray(props.node.subcomponents)) return
|
||||
props.node.subcomponents.splice(index, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
// Lock checks
|
||||
isCustomFieldLocked,
|
||||
isPieceLocked,
|
||||
isProductLocked,
|
||||
isSubcomponentLocked,
|
||||
// Helpers exposed for watchers
|
||||
reindexCustomFields,
|
||||
// CRUD
|
||||
addCustomField,
|
||||
removeCustomField,
|
||||
addPiece,
|
||||
removePiece,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addSubComponent,
|
||||
removeSubComponent,
|
||||
// Drag reorder — custom fields
|
||||
onCustomFieldDragStart: customFieldDrag.onDragStart,
|
||||
onCustomFieldDragEnter: customFieldDrag.onDragEnter,
|
||||
onCustomFieldDrop: customFieldDrag.onDrop,
|
||||
onCustomFieldDragEnd: customFieldDrag.onDragEnd,
|
||||
customFieldReorderClass: customFieldDrag.reorderClass,
|
||||
// Drag reorder — pieces
|
||||
onPieceDragStart: pieceDrag.onDragStart,
|
||||
onPieceDragEnter: pieceDrag.onDragEnter,
|
||||
onPieceDragOver: pieceDrag.onDragOver,
|
||||
onPieceDrop: pieceDrag.onDrop,
|
||||
onPieceDragEnd: pieceDrag.onDragEnd,
|
||||
pieceReorderClass: pieceDrag.reorderClass,
|
||||
// Drag reorder — products
|
||||
onProductDragStart: productDrag.onDragStart,
|
||||
onProductDragEnter: productDrag.onDragEnter,
|
||||
onProductDragOver: productDrag.onDragOver,
|
||||
onProductDrop: productDrag.onDrop,
|
||||
onProductDragEnd: productDrag.onDragEnd,
|
||||
productReorderClass: productDrag.reorderClass,
|
||||
// Drag reorder — subcomponents
|
||||
onSubcomponentDragStart: subcomponentDrag.onDragStart,
|
||||
onSubcomponentDragEnter: subcomponentDrag.onDragEnter,
|
||||
onSubcomponentDragOver: subcomponentDrag.onDragOver,
|
||||
onSubcomponentDrop: subcomponentDrag.onDrop,
|
||||
onSubcomponentDragEnd: subcomponentDrag.onDragEnd,
|
||||
subcomponentReorderClass: subcomponentDrag.reorderClass,
|
||||
}
|
||||
}
|
||||
462
app/composables/useStructureNodeLogic.ts
Normal file
462
app/composables/useStructureNodeLogic.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
|
||||
import { useStructureNodeCrud } from '~/composables/useStructureNodeCrud'
|
||||
|
||||
export type ModelTypeOption = {
|
||||
id: string
|
||||
name: string
|
||||
code?: string | null
|
||||
}
|
||||
|
||||
export type EditableStructureNode = ComponentModelStructureNode & {
|
||||
customFields?: any[]
|
||||
pieces?: ComponentModelPiece[]
|
||||
products?: ComponentModelProduct[]
|
||||
}
|
||||
|
||||
export interface StructureNodeLogicDeps {
|
||||
node: EditableStructureNode
|
||||
depth: number
|
||||
componentTypes: ModelTypeOption[]
|
||||
pieceTypes: ModelTypeOption[]
|
||||
productTypes: ModelTypeOption[]
|
||||
isRoot: boolean
|
||||
lockType: boolean
|
||||
lockedTypeLabel: string
|
||||
allowSubcomponents: boolean
|
||||
maxSubcomponentDepth: number
|
||||
restrictedMode: boolean
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
|
||||
// --- Computed props ---
|
||||
const isLocked = computed(() => props.isLocked === true)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const productTypes = computed(() => props.productTypes ?? [])
|
||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||
const maxSubcomponentDepth = computed(() =>
|
||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||
)
|
||||
const currentDepth = computed(() => Math.max(0, props.depth ?? 0))
|
||||
const canManageSubcomponents = computed(
|
||||
() => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value,
|
||||
)
|
||||
const childAllowSubcomponents = computed(
|
||||
() => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value,
|
||||
)
|
||||
const hasSubcomponents = computed(
|
||||
() => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0,
|
||||
)
|
||||
|
||||
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
|
||||
const containerClass = computed(() => {
|
||||
const level = currentDepth.value
|
||||
const index = Math.min(level, depthClasses.length - 1)
|
||||
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
|
||||
})
|
||||
|
||||
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
|
||||
|
||||
// --- Type maps ---
|
||||
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
type?.name ?? ''
|
||||
|
||||
const componentTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
componentTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const componentTypeCodeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
componentTypes.value.forEach((type) => {
|
||||
const code = typeof type?.code === 'string' ? type.code.trim() : ''
|
||||
if (code) {
|
||||
map.set(code, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
pieceTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const productTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
productTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// --- Label getters ---
|
||||
const getComponentTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(componentTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const getPieceTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(pieceTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const lockedTypeDisplay = computed(() => {
|
||||
if (props.lockedTypeLabel) {
|
||||
return props.lockedTypeLabel
|
||||
}
|
||||
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
|
||||
})
|
||||
|
||||
// --- Sync functions ---
|
||||
const syncComponentType = (component: EditableStructureNode) => {
|
||||
if (!component) {
|
||||
return
|
||||
}
|
||||
if (props.isRoot) {
|
||||
component.typeComposantId = ''
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
if (component.alias) {
|
||||
component.alias = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
const id = typeof component.typeComposantId === 'string'
|
||||
? component.typeComposantId
|
||||
: ''
|
||||
|
||||
if (!id) {
|
||||
const code =
|
||||
typeof component.familyCode === 'string' && component.familyCode
|
||||
? component.familyCode
|
||||
: ''
|
||||
if (code) {
|
||||
const codeMatch = componentTypeCodeMap.value.get(code)
|
||||
if (codeMatch?.id) {
|
||||
component.typeComposantId = codeMatch.id
|
||||
component.typeComposantLabel = formatModelTypeOption(codeMatch)
|
||||
component.familyCode = codeMatch.code ?? component.familyCode
|
||||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||||
component.alias = codeMatch.name || component.typeComposantLabel
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
return
|
||||
}
|
||||
|
||||
const option = componentTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
return
|
||||
}
|
||||
|
||||
component.typeComposantLabel = formatModelTypeOption(option)
|
||||
component.familyCode = option.code ?? component.familyCode
|
||||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||||
component.alias = option.name || component.typeComposantLabel
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
|
||||
if (!piece) return
|
||||
|
||||
if (piece.typePieceId) {
|
||||
const option = pieceTypeMap.value.get(piece.typePieceId)
|
||||
if (option) {
|
||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (piece.typePieceLabel) {
|
||||
const normalized = piece.typePieceLabel.trim().toLowerCase()
|
||||
if (normalized) {
|
||||
const match = pieceTypes.value.find((type) => {
|
||||
const formatted = formatPieceTypeOption(type).toLowerCase()
|
||||
const name = (type?.name ?? '').toLowerCase()
|
||||
const code = (type?.code ?? '').toLowerCase()
|
||||
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||
})
|
||||
if (match) {
|
||||
piece.typePieceId = match.id
|
||||
piece.typePieceLabel = formatPieceTypeOption(match)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
|
||||
if (!product) return
|
||||
|
||||
if (product.typeProductId) {
|
||||
const option = productTypeMap.value.get(product.typeProductId)
|
||||
if (option) {
|
||||
product.typeProductLabel = formatProductTypeOption(option)
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (product.typeProductLabel) {
|
||||
const normalized = product.typeProductLabel.trim().toLowerCase()
|
||||
if (normalized) {
|
||||
const match = productTypes.value.find((type) => {
|
||||
const formatted = formatProductTypeOption(type).toLowerCase()
|
||||
const name = (type?.name ?? '').toLowerCase()
|
||||
const code = (type?.code ?? '').toLowerCase()
|
||||
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||
})
|
||||
if (match) {
|
||||
product.typeProductId = match.id
|
||||
product.typeProductLabel = formatProductTypeOption(match)
|
||||
product.familyCode = match.code ?? product.familyCode ?? ''
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncPieceLabels = (pieces?: any[]) => {
|
||||
if (!Array.isArray(pieces)) {
|
||||
return
|
||||
}
|
||||
pieces.forEach((piece) => {
|
||||
updatePieceTypeLabel(piece)
|
||||
})
|
||||
}
|
||||
|
||||
const syncProductLabels = (products?: any[]) => {
|
||||
if (!Array.isArray(products)) {
|
||||
return
|
||||
}
|
||||
products.forEach((product) => {
|
||||
updateProductTypeLabel(product)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Handler functions ---
|
||||
const handleComponentTypeSelect = (component: any) => {
|
||||
syncComponentType(component)
|
||||
}
|
||||
|
||||
const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
|
||||
if (!piece) {
|
||||
return
|
||||
}
|
||||
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
|
||||
if (!id) {
|
||||
piece.typePieceLabel = ''
|
||||
return
|
||||
}
|
||||
const option = pieceTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
piece.typePieceId = ''
|
||||
piece.typePieceLabel = ''
|
||||
return
|
||||
}
|
||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||
}
|
||||
|
||||
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
|
||||
if (!product) {
|
||||
return
|
||||
}
|
||||
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
|
||||
if (!id) {
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
const option = productTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
product.typeProductId = ''
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
product.typeProductLabel = formatProductTypeOption(option)
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
}
|
||||
|
||||
// --- CRUD & Lock (delegated to useStructureNodeCrud) ---
|
||||
const crud = useStructureNodeCrud({
|
||||
node: props.node,
|
||||
restrictedMode: props.restrictedMode,
|
||||
canManageSubcomponents: () => canManageSubcomponents.value,
|
||||
})
|
||||
|
||||
// --- Watchers ---
|
||||
watch(
|
||||
canManageSubcomponents,
|
||||
(allowed) => {
|
||||
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
|
||||
props.node.subcomponents.splice(0, props.node.subcomponents.length)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(componentTypes, () => {
|
||||
syncComponentType(props.node)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.typeComposantId,
|
||||
() => {
|
||||
syncComponentType(props.node)
|
||||
},
|
||||
)
|
||||
|
||||
watch(pieceTypes, () => {
|
||||
syncPieceLabels(props.node?.pieces)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.pieces,
|
||||
(value) => {
|
||||
syncPieceLabels(value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(productTypes, () => {
|
||||
syncProductLabels(props.node?.products)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.products,
|
||||
(value) => {
|
||||
syncProductLabels(value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.node.customFields,
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
value.sort((a: any, b: any) => {
|
||||
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||||
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||||
return left - right
|
||||
})
|
||||
crud.reindexCustomFields()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.lockedTypeLabel, props.lockType],
|
||||
() => {
|
||||
if (props.lockType && props.isRoot) {
|
||||
const label = props.lockedTypeLabel || lockedTypeDisplay.value
|
||||
props.node.typeComposantLabel = label
|
||||
if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
|
||||
props.node.alias = label
|
||||
}
|
||||
if (props.node.typeComposantId) {
|
||||
const option = componentTypeMap.value.get(props.node.typeComposantId)
|
||||
props.node.familyCode = option?.code ?? props.node.familyCode
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
// Lock checks
|
||||
isCustomFieldLocked: crud.isCustomFieldLocked,
|
||||
isPieceLocked: crud.isPieceLocked,
|
||||
isProductLocked: crud.isProductLocked,
|
||||
isSubcomponentLocked: crud.isSubcomponentLocked,
|
||||
// Computed state
|
||||
isLocked,
|
||||
restrictedMode,
|
||||
componentTypes,
|
||||
pieceTypes,
|
||||
productTypes,
|
||||
canManageSubcomponents,
|
||||
childAllowSubcomponents,
|
||||
hasSubcomponents,
|
||||
containerClass,
|
||||
headingClass,
|
||||
lockedTypeDisplay,
|
||||
// Label getters & formatters
|
||||
getComponentTypeLabel,
|
||||
getPieceTypeLabel,
|
||||
formatComponentTypeOption,
|
||||
formatPieceTypeOption,
|
||||
formatProductTypeOption,
|
||||
// Handlers
|
||||
handleComponentTypeSelect,
|
||||
handlePieceTypeSelect,
|
||||
handleProductTypeSelect,
|
||||
// CRUD
|
||||
addCustomField: crud.addCustomField,
|
||||
removeCustomField: crud.removeCustomField,
|
||||
addPiece: crud.addPiece,
|
||||
removePiece: crud.removePiece,
|
||||
addProduct: crud.addProduct,
|
||||
removeProduct: crud.removeProduct,
|
||||
addSubComponent: crud.addSubComponent,
|
||||
removeSubComponent: crud.removeSubComponent,
|
||||
// Drag reorder — custom fields
|
||||
onCustomFieldDragStart: crud.onCustomFieldDragStart,
|
||||
onCustomFieldDragEnter: crud.onCustomFieldDragEnter,
|
||||
onCustomFieldDrop: crud.onCustomFieldDrop,
|
||||
onCustomFieldDragEnd: crud.onCustomFieldDragEnd,
|
||||
customFieldReorderClass: crud.customFieldReorderClass,
|
||||
// Drag reorder — pieces
|
||||
onPieceDragStart: crud.onPieceDragStart,
|
||||
onPieceDragEnter: crud.onPieceDragEnter,
|
||||
onPieceDragOver: crud.onPieceDragOver,
|
||||
onPieceDrop: crud.onPieceDrop,
|
||||
onPieceDragEnd: crud.onPieceDragEnd,
|
||||
pieceReorderClass: crud.pieceReorderClass,
|
||||
// Drag reorder — products
|
||||
onProductDragStart: crud.onProductDragStart,
|
||||
onProductDragEnter: crud.onProductDragEnter,
|
||||
onProductDragOver: crud.onProductDragOver,
|
||||
onProductDrop: crud.onProductDrop,
|
||||
onProductDragEnd: crud.onProductDragEnd,
|
||||
productReorderClass: crud.productReorderClass,
|
||||
// Drag reorder — subcomponents
|
||||
onSubcomponentDragStart: crud.onSubcomponentDragStart,
|
||||
onSubcomponentDragEnter: crud.onSubcomponentDragEnter,
|
||||
onSubcomponentDragOver: crud.onSubcomponentDragOver,
|
||||
onSubcomponentDrop: crud.onSubcomponentDrop,
|
||||
onSubcomponentDragEnd: crud.onSubcomponentDragEnd,
|
||||
subcomponentReorderClass: crud.subcomponentReorderClass,
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,19 @@ const toasts = ref<Toast[]>([])
|
||||
const MAX_TOASTS = 3
|
||||
let nextId = 1
|
||||
|
||||
// Anti-doublon : ignore un toast identique affiché dans les 2 dernières secondes
|
||||
const recentMessages = new Map<string, number>()
|
||||
const DEDUP_WINDOW = 2000
|
||||
|
||||
export function useToast() {
|
||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
||||
const dedupKey = `${type}::${message}`
|
||||
const lastShown = recentMessages.get(dedupKey)
|
||||
if (lastShown && Date.now() - lastShown < DEDUP_WINDOW) {
|
||||
return -1
|
||||
}
|
||||
recentMessages.set(dedupKey, Date.now())
|
||||
|
||||
const id = nextId++
|
||||
const toast: Toast = {
|
||||
id,
|
||||
@@ -65,6 +76,7 @@ export function useToast() {
|
||||
|
||||
const clearAll = (): void => {
|
||||
toasts.value = []
|
||||
recentMessages.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,8 +9,24 @@
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="entries"
|
||||
:loading="loading"
|
||||
:pagination="paginationState"
|
||||
:show-per-page="true"
|
||||
:show-counter="true"
|
||||
:expandable="true"
|
||||
:expanded-keys="expandedIds"
|
||||
:can-expand="canExpandRow"
|
||||
row-key="id"
|
||||
empty-message="Aucune activité enregistrée."
|
||||
no-results-message="Aucune activité ne correspond à vos filtres."
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@toggle-expand="toggleExpanded"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
@@ -22,7 +38,7 @@
|
||||
id="activity-entity-type"
|
||||
v-model="entityTypeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="piece">Pièce</option>
|
||||
@@ -42,7 +58,7 @@
|
||||
id="activity-action"
|
||||
v-model="actionFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
<option value="create">Création</option>
|
||||
@@ -50,157 +66,111 @@
|
||||
<option value="delete">Suppression</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-per-page"
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-action="{ row }">
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="actionBadgeClass(row.action)"
|
||||
>
|
||||
{{ historyActionLabel(row.action) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-entityType="{ row }">
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
{{ entityTypeLabel(row.entityType) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-entity="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.action !== 'delete'"
|
||||
:to="entityEditLink(row)"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ row.entityName || 'Sans nom' }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-base-content/50 line-through">
|
||||
{{ row.entityName || 'Sans nom' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.entityRef"
|
||||
class="text-xs text-base-content/50 ml-1"
|
||||
>
|
||||
({{ row.entityRef }})
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-author="{ row }">
|
||||
{{ row.actor?.label || '—' }}
|
||||
</template>
|
||||
|
||||
<template #row-expanded="{ row }">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div
|
||||
v-for="diffEntry in historyDiffEntries(row, globalFieldLabels)"
|
||||
:key="diffEntry.field"
|
||||
class="flex gap-2"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="activity-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||
<span>→</span>
|
||||
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!total" class="text-sm text-base-content/70">
|
||||
Aucune activité enregistrée.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
|
||||
Aucune activité ne correspond à vos filtres.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>Type</th>
|
||||
<th>Entité</th>
|
||||
<th>Auteur</th>
|
||||
<th>Détails</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="entry in entries" :key="entry.id">
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="actionBadgeClass(entry.action)"
|
||||
>
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
{{ entityTypeLabel(entry.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="entry.action !== 'delete'"
|
||||
:to="entityEditLink(entry)"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-base-content/50 line-through">
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.entityRef"
|
||||
class="text-xs text-base-content/50 ml-1"
|
||||
>
|
||||
({{ entry.entityRef }})
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.actor?.label || '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="hasDiff(entry)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="toggleExpanded(entry.id)"
|
||||
>
|
||||
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="expandedIds.has(entry.id)">
|
||||
<td colspan="6" class="bg-base-200/50 p-4">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div
|
||||
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
|
||||
:key="diffEntry.field"
|
||||
class="flex gap-2"
|
||||
>
|
||||
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||
<span>→</span>
|
||||
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, type Ref } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useActivityLog } from '~/composables/useActivityLog'
|
||||
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
|
||||
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(50)
|
||||
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchLog },
|
||||
{
|
||||
defaultSort: 'createdAt',
|
||||
defaultDirection: 'desc',
|
||||
defaultPerPage: 50,
|
||||
persistToUrl: true,
|
||||
extraParams: {
|
||||
entityType: { default: '' },
|
||||
action: { default: '' },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const entityTypeFilter = ref('')
|
||||
const actionFilter = ref('')
|
||||
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||
const actionFilter = table.filters.action as Ref<string>
|
||||
|
||||
const entriesOnPage = computed(() => entries.value.length)
|
||||
const paginationState = table.pagination(total, entriesOnPage)
|
||||
|
||||
const columns = [
|
||||
{ key: 'createdAt', label: 'Date' },
|
||||
{ key: 'action', label: 'Action' },
|
||||
{ key: 'entityType', label: 'Type' },
|
||||
{ key: 'entity', label: 'Entité' },
|
||||
{ key: 'author', label: 'Auteur' },
|
||||
]
|
||||
|
||||
const expandedIds = reactive(new Set<string>())
|
||||
|
||||
@@ -209,28 +179,18 @@ const toggleExpanded = (id: string) => {
|
||||
else expandedIds.add(id)
|
||||
}
|
||||
|
||||
const hasDiff = (entry: ActivityLogEntry) =>
|
||||
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
|
||||
const canExpandRow = (row: any) =>
|
||||
row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
|
||||
|
||||
const fetchLog = () => {
|
||||
function fetchLog() {
|
||||
loadActivityLog({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
entityType: entityTypeFilter.value || undefined,
|
||||
action: actionFilter.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
|
||||
@@ -9,82 +9,66 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="sortedProfiles"
|
||||
:loading="isLoading"
|
||||
:sort="sortState"
|
||||
:show-counter="false"
|
||||
table-class="table-zebra"
|
||||
empty-message="Aucun profil."
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
<span class="font-medium">{{ row.firstName }} {{ row.lastName }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else-if="profiles.length" class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Mot de passe</th>
|
||||
<th>Statut</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in profiles" :key="profile.id">
|
||||
<td class="font-medium">
|
||||
{{ profile.firstName }} {{ profile.lastName }}
|
||||
</td>
|
||||
<td class="text-sm text-base-content/70">
|
||||
{{ profile.email || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
class="select select-bordered select-xs"
|
||||
:value="primaryRole(profile)"
|
||||
@change="handleRoleChange(profile.id, $event.target.value)"
|
||||
>
|
||||
<option value="ROLE_ADMIN">
|
||||
Admin
|
||||
</option>
|
||||
<option value="ROLE_GESTIONNAIRE">
|
||||
Gestionnaire
|
||||
</option>
|
||||
<option value="ROLE_VIEWER">
|
||||
Viewer
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs ml-1"
|
||||
@click="openPasswordDialog(profile.id)"
|
||||
>
|
||||
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="profile.isActive ? 'badge-success' : 'badge-error'"
|
||||
>
|
||||
{{ profile.isActive ? 'Actif' : 'Inactif' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="profile.isActive"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="handleDeactivate(profile.id)"
|
||||
>
|
||||
Desactiver
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #cell-email="{ row }">
|
||||
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-center py-12 text-base-content/60">
|
||||
Aucun profil.
|
||||
</div>
|
||||
<template #cell-role="{ row }">
|
||||
<select
|
||||
class="select select-bordered select-xs"
|
||||
:value="primaryRole(row)"
|
||||
@change="handleRoleChange(row.id, $event.target.value)"
|
||||
>
|
||||
<option value="ROLE_ADMIN">Admin</option>
|
||||
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||
<option value="ROLE_VIEWER">Viewer</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-password="{ row }">
|
||||
<span v-if="row.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs ml-1"
|
||||
@click="openPasswordDialog(row.id)"
|
||||
>
|
||||
{{ row.hasPassword ? 'Changer' : 'Definir' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="row.isActive ? 'badge-success' : 'badge-error'"
|
||||
>
|
||||
{{ row.isActive ? 'Actif' : 'Inactif' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<button
|
||||
v-if="row.isActive"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="handleDeactivate(row.id)"
|
||||
>
|
||||
Desactiver
|
||||
</button>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<!-- Create Profile Dialog -->
|
||||
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||
@@ -112,15 +96,9 @@
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Role</span></label>
|
||||
<select v-model="createForm.role" class="select select-bordered">
|
||||
<option value="ROLE_ADMIN">
|
||||
Admin
|
||||
</option>
|
||||
<option value="ROLE_GESTIONNAIRE">
|
||||
Gestionnaire
|
||||
</option>
|
||||
<option value="ROLE_VIEWER">
|
||||
Viewer
|
||||
</option>
|
||||
<option value="ROLE_ADMIN">Admin</option>
|
||||
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||
<option value="ROLE_VIEWER">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
@@ -173,11 +151,55 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useAdminProfiles } from '#imports'
|
||||
|
||||
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||
|
||||
const loaded = ref(false)
|
||||
const isLoading = computed(() => loading.value || !loaded.value)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'role', label: 'Role', sortable: true },
|
||||
{ key: 'password', label: 'Mot de passe' },
|
||||
{ key: 'status', label: 'Statut', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
const sortState = ref({ field: 'name', direction: 'asc' })
|
||||
|
||||
const handleSort = (sort) => {
|
||||
sortState.value = sort
|
||||
}
|
||||
|
||||
const sortedProfiles = computed(() => {
|
||||
const { field, direction } = sortState.value
|
||||
const dir = direction === 'desc' ? -1 : 1
|
||||
return [...profiles.value].sort((a, b) => {
|
||||
let valA, valB
|
||||
if (field === 'name') {
|
||||
valA = `${a.firstName} ${a.lastName}`.toLowerCase()
|
||||
valB = `${b.firstName} ${b.lastName}`.toLowerCase()
|
||||
}
|
||||
else if (field === 'role') {
|
||||
valA = primaryRole(a)
|
||||
valB = primaryRole(b)
|
||||
}
|
||||
else if (field === 'status') {
|
||||
valA = a.isActive ? '1' : '0'
|
||||
valB = b.isActive ? '1' : '0'
|
||||
}
|
||||
else {
|
||||
valA = (a[field] || '').toLowerCase()
|
||||
valB = (b[field] || '').toLowerCase()
|
||||
}
|
||||
return dir * valA.localeCompare(valB)
|
||||
})
|
||||
})
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const showPasswordDialog = ref(false)
|
||||
const creating = ref(false)
|
||||
@@ -209,7 +231,8 @@ const handleCreate = async () => {
|
||||
await createProfile(data)
|
||||
showCreateDialog.value = false
|
||||
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
@@ -230,7 +253,8 @@ const handleSetPassword = async () => {
|
||||
try {
|
||||
await setPassword(passwordProfileId.value, newPassword.value)
|
||||
showPasswordDialog.value = false
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
settingPassword.value = false
|
||||
}
|
||||
}
|
||||
@@ -239,7 +263,8 @@ const handleDeactivate = async (profileId) => {
|
||||
await deactivateProfile(profileId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -69,6 +69,69 @@ const badgeClass = (type: ChangeType) => {
|
||||
}
|
||||
|
||||
const releases: Release[] = [
|
||||
{
|
||||
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',
|
||||
date: '2026-03-05',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Composant DataTable générique avec tri, recherche, pagination et filtres server-side — toutes les pages catalogue migrées vers ce composant partagé' },
|
||||
{ type: 'feat', text: 'Messages d\'erreur humanisés : les erreurs backend sont traduites en messages compréhensibles pour l\'utilisateur final' },
|
||||
{ type: 'feat', text: 'Modal d\'ajout d\'entités aux machines : ajout direct de composants, pièces et produits depuis la fiche machine' },
|
||||
{ type: 'feat', text: 'Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API' },
|
||||
{ type: 'feat', text: 'Suppression du système TypeMachine (squelettes machines) : les champs personnalisés sont désormais liés directement à chaque machine' },
|
||||
{ type: 'feat', text: 'Simplification de la création de machines : plus besoin de sélectionner un squelette, ajout direct des entités' },
|
||||
{ type: 'fix', text: 'Suppression catalogue pièces/composants : confirmation avec liste des éléments supprimés en cascade (documents, liaisons machine, champs personnalisés) au lieu de bloquer la suppression' },
|
||||
{ type: 'fix', text: 'Affichage des catégories sur les pages d\'édition (produit, composant, pièce) : correction de « Catégorie inconnue » causée par un import obsolète dans ModelType' },
|
||||
{ type: 'fix', text: 'Recherche insensible à la casse sur les commentaires et documents (partial → ipartial)' },
|
||||
{ type: 'chore', text: 'Suppression des pages squelettes machines (/machine-skeleton, /type) et composants associés' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.8.0',
|
||||
date: '2026-03-03',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
|
||||
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
|
||||
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
|
||||
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
|
||||
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
|
||||
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
|
||||
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
|
||||
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
|
||||
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.7.0',
|
||||
date: '2026-03-02',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
|
||||
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
|
||||
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
|
||||
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
|
||||
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
|
||||
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
|
||||
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
|
||||
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
|
||||
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
|
||||
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.6.1',
|
||||
date: '2026-02-12',
|
||||
@@ -171,7 +234,7 @@ const releases: Release[] = [
|
||||
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
||||
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
||||
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
||||
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
|
||||
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
|
||||
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
||||
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
||||
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
||||
|
||||
@@ -4,16 +4,29 @@
|
||||
<h1 class="text-3xl font-semibold text-base-content">
|
||||
Commentaires
|
||||
</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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="comments"
|
||||
:loading="loadingList"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun commentaire trouvé."
|
||||
no-results-message="Aucun commentaire trouvé."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
@@ -25,17 +38,11 @@
|
||||
id="comment-status"
|
||||
v-model="statusFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="open">
|
||||
Ouverts
|
||||
</option>
|
||||
<option value="resolved">
|
||||
Résolus
|
||||
</option>
|
||||
<option value="">
|
||||
Tous
|
||||
</option>
|
||||
<option value="open">Ouverts</option>
|
||||
<option value="resolved">Résolus</option>
|
||||
<option value="">Tous</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -50,186 +57,84 @@
|
||||
id="comment-entity-type"
|
||||
v-model="entityTypeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="">
|
||||
Tous
|
||||
</option>
|
||||
<option value="machine">
|
||||
Machine
|
||||
</option>
|
||||
<option value="piece">
|
||||
Pièce
|
||||
</option>
|
||||
<option value="composant">
|
||||
Composant
|
||||
</option>
|
||||
<option value="product">
|
||||
Produit
|
||||
</option>
|
||||
<option value="piece_category">
|
||||
Catégorie pièce
|
||||
</option>
|
||||
<option value="component_category">
|
||||
Catégorie composant
|
||||
</option>
|
||||
<option value="product_category">
|
||||
Catégorie produit
|
||||
</option>
|
||||
<option value="machine_skeleton">
|
||||
Squelette machine
|
||||
</option>
|
||||
<option value="">Tous</option>
|
||||
<option value="machine">Machine</option>
|
||||
<option value="piece">Pièce</option>
|
||||
<option value="composant">Composant</option>
|
||||
<option value="product">Produit</option>
|
||||
<option value="piece_category">Catégorie pièce</option>
|
||||
<option value="component_category">Catégorie composant</option>
|
||||
<option value="product_category">Catégorie produit</option>
|
||||
<option value="machine_skeleton">Squelette machine</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="comment-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="comment-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option :value="20">
|
||||
20
|
||||
</option>
|
||||
<option :value="50">
|
||||
50
|
||||
</option>
|
||||
<option :value="100">
|
||||
100
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template #cell-content="{ row }">
|
||||
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
|
||||
</template>
|
||||
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loadingList" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Empty states -->
|
||||
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
|
||||
Aucun commentaire trouvé.
|
||||
</p>
|
||||
|
||||
<!-- Table -->
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contenu</th>
|
||||
<th>Type</th>
|
||||
<th>Item</th>
|
||||
<th>Auteur</th>
|
||||
<th>Date</th>
|
||||
<th>Statut</th>
|
||||
<th v-if="canEdit">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="hover"
|
||||
>
|
||||
<td class="max-w-xs">
|
||||
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{{ entityTypeLabel(comment.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="getEntityRoute(comment)"
|
||||
:to="getEntityRoute(comment)!"
|
||||
class="link link-primary text-sm font-medium"
|
||||
>
|
||||
{{ comment.entityName || comment.entityId }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-sm">
|
||||
{{ comment.entityName || comment.entityId }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{{ comment.authorName }}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ formatCommentDate(comment.createdAt) }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||
>
|
||||
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="canEdit" @click.stop>
|
||||
<button
|
||||
v-if="comment.status === 'open'"
|
||||
type="button"
|
||||
class="btn btn-success btn-xs gap-1"
|
||||
:disabled="loading"
|
||||
@click="handleResolve(comment.id)"
|
||||
>
|
||||
<IconLucideCheck class="w-3 h-3" />
|
||||
Résoudre
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">
|
||||
{{ comment.resolvedByName }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:disabled="page <= 1"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span class="flex items-center text-sm text-base-content/70">
|
||||
Page {{ page }} / {{ totalPages }}
|
||||
<template #cell-entityType="{ row }">
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{{ entityTypeLabel(row.entityType) }}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:disabled="page >= totalPages"
|
||||
@click="goToPage(page + 1)"
|
||||
</template>
|
||||
|
||||
<template #cell-entity="{ row }">
|
||||
<NuxtLink
|
||||
v-if="getEntityRoute(row)"
|
||||
:to="getEntityRoute(row)!"
|
||||
class="link link-primary text-sm font-medium"
|
||||
>
|
||||
Suivant
|
||||
{{ row.entityName || row.entityId }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-sm">
|
||||
{{ row.entityName || row.entityId }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatCommentDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="row.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||
>
|
||||
{{ row.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="canEdit" #cell-actions="{ row }">
|
||||
<button
|
||||
v-if="row.status === 'open'"
|
||||
type="button"
|
||||
class="btn btn-success btn-xs gap-1"
|
||||
:disabled="loading"
|
||||
@click="handleResolve(row.id)"
|
||||
>
|
||||
<IconLucideCheck class="w-3 h-3" />
|
||||
Résoudre
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="text-xs text-base-content/50">
|
||||
{{ row.resolvedByName }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, type Ref } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useComments, type Comment } from '~/composables/useComments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
@@ -241,16 +146,43 @@ const {
|
||||
|
||||
const comments = ref<Comment[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(20)
|
||||
const statusFilter = ref('open')
|
||||
const entityTypeFilter = ref('')
|
||||
const loadingList = ref(false)
|
||||
const loadingList = ref(true)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
||||
const table = useDataTable(
|
||||
{ fetchData: loadComments },
|
||||
{
|
||||
defaultSort: 'createdAt',
|
||||
defaultDirection: 'desc',
|
||||
defaultPerPage: 20,
|
||||
persistToUrl: true,
|
||||
extraParams: {
|
||||
status: { default: 'open' },
|
||||
entityType: { default: '' },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const statusFilter = table.filters.status as Ref<string>
|
||||
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||
|
||||
const commentsOnPage = computed(() => comments.value.length)
|
||||
const paginationState = table.pagination(total, commentsOnPage)
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = [
|
||||
{ key: 'content', label: 'Contenu', class: 'max-w-xs' },
|
||||
{ key: 'entityType', label: 'Type' },
|
||||
{ key: 'entity', label: 'Item', filterable: true, filterPlaceholder: 'Rechercher…' },
|
||||
{ key: 'authorName', label: 'Auteur', sortable: true, sortKey: 'authorName' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||
{ key: 'status', label: 'Statut', sortable: true, sortKey: 'status' },
|
||||
]
|
||||
if (canEdit.value) {
|
||||
cols.push({ key: 'actions', label: 'Actions' })
|
||||
}
|
||||
return cols
|
||||
})
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
machine: 'Machine',
|
||||
piece: 'Pièce',
|
||||
@@ -277,13 +209,16 @@ const formatCommentDate = (dateStr: string): string => {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const loadComments = async () => {
|
||||
async function loadComments() {
|
||||
loadingList.value = true
|
||||
const result = await fetchAllComments({
|
||||
status: statusFilter.value || undefined,
|
||||
entityType: entityTypeFilter.value || undefined,
|
||||
page: page.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
entityName: table.columnFilters.value.entity || undefined,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value,
|
||||
})
|
||||
if (result.success) {
|
||||
comments.value = result.data ?? []
|
||||
@@ -292,16 +227,6 @@ const loadComments = async () => {
|
||||
loadingList.value = false
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
page.value = 1
|
||||
loadComments()
|
||||
}
|
||||
|
||||
const goToPage = (p: number) => {
|
||||
page.value = p
|
||||
loadComments()
|
||||
}
|
||||
|
||||
const handleResolve = async (commentId: string) => {
|
||||
const result = await resolveComment(commentId)
|
||||
if (result.success) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<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>
|
||||
<h1 class="text-3xl font-semibold text-base-content">Catalogue des composants</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
<h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
|
||||
<p class="text-sm text-base-content/50 mt-1">
|
||||
Consultez et gérez tous les composants existants.
|
||||
</p>
|
||||
</div>
|
||||
@@ -11,356 +11,194 @@
|
||||
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
|
||||
Ajouter un composant
|
||||
</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
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</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">
|
||||
<header class="flex flex-col gap-2">
|
||||
<h2 class="text-xl font-semibold text-base-content">Composants créés</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
|
||||
<p class="text-sm text-base-content/50">
|
||||
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="componentRows"
|
||||
:loading="loadingComposants"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun composant n'a encore été créé."
|
||||
no-results-message="Aucun composant ne correspond à votre recherche."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
v-model="table.searchTerm.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="debouncedSearch"
|
||||
@input="table.debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-sort"
|
||||
>
|
||||
Trier par
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-preview="{ row }">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.component)"
|
||||
:alt="resolvePreviewAlt(row.component)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
{{ row.component.name || 'Composant sans nom' }}
|
||||
</template>
|
||||
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.component.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.component.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.component.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">
|
||||
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-dir"
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-typeComposant="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.component.typeComposant?.id"
|
||||
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolveComponentType(row.component) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${row.component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Ordre
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(row.component)"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingComposants" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!composantsTotal" class="text-sm text-base-content/70">
|
||||
Aucun composant n'a encore été créé.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
|
||||
Aucun composant ne correspond à votre recherche.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de composant</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="component in composantsList" :key="component.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(component)"
|
||||
:alt="resolvePreviewAlt(component)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="component.typeComposant?.id"
|
||||
:to="`/component-category/${component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolveComponentType(component) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolveComponentType(component) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const { showError } = useToast()
|
||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const {
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
q: searchTerm,
|
||||
sort: sortField,
|
||||
dir: sortDirection,
|
||||
} = useUrlState({
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: 20, type: 'number' },
|
||||
q: { default: '', debounce: 300 },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
}, {
|
||||
onRestore: () => fetchComposants(),
|
||||
})
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchComposants },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
)
|
||||
|
||||
const composantsTotal = computed(() => total.value)
|
||||
const composantsOnPage = computed(() => composants.value.length)
|
||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
// Search debounce for API calls
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const composantsOnPage = computed(() => componentRows.value.length)
|
||||
const paginationState = table.pagination(total, composantsOnPage)
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Enrichir les composants avec les types de composants complets
|
||||
// Enrich composants with full type data
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||
return {
|
||||
...composant,
|
||||
typeComposant: typeComposant || composant.typeComposant || null
|
||||
}
|
||||
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||||
})
|
||||
})
|
||||
|
||||
const fetchComposants = async () => {
|
||||
const componentRows = computed(() =>
|
||||
composantsList.value.map(component => ({
|
||||
id: component.id,
|
||||
component,
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchComposants() {
|
||||
await loadComposants({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
force: true
|
||||
search: table.searchTerm.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
typeName: table.columnFilters.value.typeComposant || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||||
if (!documents.length) {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.path)
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
return pdf
|
||||
}
|
||||
const image = withPath.find((doc) => isImageDocument(doc))
|
||||
if (image) {
|
||||
return image
|
||||
}
|
||||
return withPath[0] ?? normalized[0] ?? null
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (component: Record<string, any>) => {
|
||||
const parts = [component?.name, component?.reference].filter(Boolean)
|
||||
if (parts.length) {
|
||||
return `Aperçu du document de ${parts.join(' – ')}`
|
||||
}
|
||||
return 'Aperçu du document'
|
||||
}
|
||||
|
||||
const resolveComponentType = (component: Record<string, any>) => {
|
||||
const type = component?.typeComposant
|
||||
if (type?.name) {
|
||||
return type.name
|
||||
}
|
||||
if (component?.typeComposantLabel) {
|
||||
return component.typeComposantLabel
|
||||
}
|
||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||
return '—'
|
||||
}
|
||||
|
||||
const resolveDeleteGuard = (component: Record<string, any>) => {
|
||||
const blockingReasons: string[] = []
|
||||
const machineLinks = Array.isArray(component?.machineLinks)
|
||||
? component.machineLinks.length
|
||||
: component?.machineLinksCount ?? 0
|
||||
const documents = Array.isArray(component?.documents)
|
||||
? component.documents.length
|
||||
: component?.documentsCount ?? 0
|
||||
const customFields = Array.isArray(component?.customFieldValues)
|
||||
? component.customFieldValues.length
|
||||
: component?.customFieldValuesCount ?? 0
|
||||
|
||||
if (machineLinks > 0) {
|
||||
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||
}
|
||||
if (documents > 0) {
|
||||
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||
}
|
||||
return {
|
||||
blockingReasons,
|
||||
hasCustomFields: customFields > 0,
|
||||
}
|
||||
}
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
||||
|
||||
if (blockingReasons.length) {
|
||||
showError(
|
||||
`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(
|
||||
', ',
|
||||
)}. Supprimez ou détachez ces éléments avant de réessayer.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const componentName = component?.name || 'ce composant'
|
||||
const confirmLines = [
|
||||
`Voulez-vous vraiment supprimer ${componentName} ?`,
|
||||
]
|
||||
if (hasCustomFields) {
|
||||
confirmLines.push(
|
||||
'Les valeurs de champs personnalisés associées seront également supprimées.'
|
||||
)
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
|
||||
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
|
||||
if (!confirmed) return
|
||||
await deleteComposant(component.id)
|
||||
// Reload current page after deletion
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchComposants(),
|
||||
loadComponentTypes()
|
||||
])
|
||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||
})
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user