Compare commits
53 Commits
v1.7.0
...
5912216a89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5912216a89 | ||
|
|
139ba183de | ||
|
|
9fef009610 | ||
|
|
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
|
```bash
|
||||||
# npm
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Server
|
## Développement
|
||||||
|
|
||||||
Start the development server on `http://localhost:3000`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# npm
|
|
||||||
npm run dev
|
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
|
| Commande | Description |
|
||||||
# npm
|
|----------|-------------|
|
||||||
npm run build
|
| `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
|
## Fonctionnalités
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# yarn
|
### Gestion du parc
|
||||||
yarn build
|
|
||||||
|
|
||||||
# bun
|
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
|
||||||
bun run build
|
- **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
|
### Composables
|
||||||
# npm
|
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# pnpm
|
Pattern avec injection de dépendances explicite :
|
||||||
pnpm preview
|
|
||||||
|
|
||||||
# yarn
|
```typescript
|
||||||
yarn preview
|
interface Deps {
|
||||||
|
machineId: Ref<string>
|
||||||
|
onSave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
# bun
|
export function useMachineDetail(deps: Deps) {
|
||||||
bun run preview
|
// ...
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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>
|
<template>
|
||||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div class="min-h-screen flex flex-col bg-base-200/40">
|
||||||
<LayoutAppNavbar
|
<!-- 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"
|
@open-settings="displaySettingsOpen = true"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NuxtPage />
|
<main class="flex-1">
|
||||||
|
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
|
||||||
|
</main>
|
||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
<CommonConfirmModal />
|
<ConfirmModal />
|
||||||
|
|
||||||
<DisplaySettings
|
<DisplaySettings
|
||||||
:is-open="displaySettingsOpen"
|
:is-open="displaySettingsOpen"
|
||||||
@@ -17,11 +22,17 @@
|
|||||||
@update-settings="handleSettingsUpdate"
|
@update-settings="handleSettingsUpdate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
<footer class="border-t border-base-300/50 bg-base-100/60 backdrop-blur-sm">
|
||||||
<div class="items-center grid-flow-col">
|
<div class="container mx-auto flex items-center justify-between px-6 py-3">
|
||||||
<p>
|
<p class="text-xs text-base-content/40 font-medium tracking-wide">
|
||||||
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
|
© Malio {{ new Date().getFullYear() }}
|
||||||
</p>
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
to="/changelog"
|
||||||
|
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
|
||||||
|
>
|
||||||
|
v{{ appVersion }}
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,55 +1,136 @@
|
|||||||
|
/* ─── Fonts ─── */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap');
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
|
/* ─── Theme ─── */
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "mytheme";
|
name: "mytheme";
|
||||||
default: true; /* set as default */
|
default: true;
|
||||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
prefersdark: false;
|
||||||
color-scheme: light; /* color of browser-provided UI */
|
color-scheme: light;
|
||||||
|
|
||||||
/* #FBFAFA — gris clair */
|
/* Surfaces — warm gray with a hint of blue */
|
||||||
--color-base-100: oklch(98% 0.003 0);
|
--color-base-100: oklch(98.5% 0.004 260);
|
||||||
--color-base-200: oklch(94% 0.01 262);
|
--color-base-200: oklch(95% 0.008 260);
|
||||||
--color-base-300: oklch(90% 0.02 262);
|
--color-base-300: oklch(91% 0.015 260);
|
||||||
--color-base-content: oklch(20% 0.03 262);
|
--color-base-content: oklch(22% 0.025 260);
|
||||||
/* #304998 — bleu Malio */
|
|
||||||
--color-primary: oklch(37% 0.15 262);
|
/* Primary — Malio blue, slightly richer */
|
||||||
|
--color-primary: oklch(40% 0.16 262);
|
||||||
--color-primary-content: oklch(98% 0.005 262);
|
--color-primary-content: oklch(98% 0.005 262);
|
||||||
/* #A5ACD0 — lavande */
|
|
||||||
--color-secondary: oklch(75% 0.055 270);
|
/* Secondary — refined lavender */
|
||||||
--color-secondary-content: oklch(20% 0.03 270);
|
--color-secondary: oklch(72% 0.06 275);
|
||||||
/* #ED8521 — orange */
|
--color-secondary-content: oklch(22% 0.03 275);
|
||||||
--color-accent: oklch(71% 0.17 58);
|
|
||||||
--color-accent-content: oklch(98% 0.005 58);
|
/* Accent — warm amber-orange */
|
||||||
/* neutral dérivé du bleu Malio */
|
--color-accent: oklch(72% 0.17 55);
|
||||||
--color-neutral: oklch(37% 0.08 262);
|
--color-accent-content: oklch(20% 0.04 55);
|
||||||
--color-neutral-content: oklch(98% 0.005 262);
|
|
||||||
--color-info: oklch(55% 0.12 262);
|
/* Neutral — deep slate */
|
||||||
--color-info-content: oklch(98% 0.005 262);
|
--color-neutral: oklch(28% 0.04 260);
|
||||||
--color-success: oklch(65% 0.2 145);
|
--color-neutral-content: oklch(95% 0.005 260);
|
||||||
--color-success-content: oklch(98% 0.005 145);
|
|
||||||
|
/* Semantic */
|
||||||
|
--color-info: oklch(58% 0.14 255);
|
||||||
|
--color-info-content: oklch(98% 0.005 255);
|
||||||
|
--color-success: oklch(62% 0.19 150);
|
||||||
|
--color-success-content: oklch(98% 0.005 150);
|
||||||
--color-warning: oklch(78% 0.15 70);
|
--color-warning: oklch(78% 0.15 70);
|
||||||
--color-warning-content: oklch(20% 0.05 70);
|
--color-warning-content: oklch(22% 0.05 70);
|
||||||
--color-error: oklch(60% 0.25 25);
|
--color-error: oklch(58% 0.22 25);
|
||||||
--color-error-content: oklch(98% 0.005 25);
|
--color-error-content: oklch(98% 0.005 25);
|
||||||
|
|
||||||
/* border radius */
|
/* Geometry */
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 0.75rem;
|
||||||
--radius-field: 0.25rem;
|
--radius-field: 0.375rem;
|
||||||
--radius-box: 0.5rem;
|
--radius-box: 0.625rem;
|
||||||
|
|
||||||
/* base sizes */
|
|
||||||
--size-selector: 0.25rem;
|
--size-selector: 0.25rem;
|
||||||
--size-field: 0.25rem;
|
--size-field: 0.25rem;
|
||||||
|
|
||||||
/* border size */
|
|
||||||
--border: 1px;
|
--border: 1px;
|
||||||
|
|
||||||
/* effects */
|
|
||||||
--depth: 1;
|
--depth: 1;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles pour l'accessibilité et les paramètres d'affichage */
|
@plugin "daisyui/theme" {
|
||||||
|
name: "mytheme-dark";
|
||||||
|
default: false;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* Surfaces — dark blue-gray */
|
||||||
|
--color-base-100: oklch(22% 0.015 260);
|
||||||
|
--color-base-200: oklch(18% 0.012 260);
|
||||||
|
--color-base-300: oklch(28% 0.018 260);
|
||||||
|
--color-base-content: oklch(92% 0.005 260);
|
||||||
|
|
||||||
|
/* Primary — Malio blue, brighter for dark */
|
||||||
|
--color-primary: oklch(55% 0.18 262);
|
||||||
|
--color-primary-content: oklch(98% 0.005 262);
|
||||||
|
|
||||||
|
/* Secondary — refined lavender */
|
||||||
|
--color-secondary: oklch(72% 0.06 275);
|
||||||
|
--color-secondary-content: oklch(22% 0.03 275);
|
||||||
|
|
||||||
|
/* Accent — warm amber-orange */
|
||||||
|
--color-accent: oklch(72% 0.17 55);
|
||||||
|
--color-accent-content: oklch(20% 0.04 55);
|
||||||
|
|
||||||
|
/* Neutral — lighter slate for dark mode */
|
||||||
|
--color-neutral: oklch(75% 0.02 260);
|
||||||
|
--color-neutral-content: oklch(18% 0.01 260);
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--color-info: oklch(62% 0.14 255);
|
||||||
|
--color-info-content: oklch(98% 0.005 255);
|
||||||
|
--color-success: oklch(65% 0.19 150);
|
||||||
|
--color-success-content: oklch(98% 0.005 150);
|
||||||
|
--color-warning: oklch(78% 0.15 70);
|
||||||
|
--color-warning-content: oklch(22% 0.05 70);
|
||||||
|
--color-error: oklch(62% 0.22 25);
|
||||||
|
--color-error-content: oklch(98% 0.005 25);
|
||||||
|
|
||||||
|
/* Geometry — same as light */
|
||||||
|
--radius-selector: 0.75rem;
|
||||||
|
--radius-field: 0.375rem;
|
||||||
|
--radius-box: 0.625rem;
|
||||||
|
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Typography ─── */
|
||||||
|
:root {
|
||||||
|
--font-heading: 'Outfit', system-ui, sans-serif;
|
||||||
|
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6,
|
||||||
|
.card-title,
|
||||||
|
.stat-value,
|
||||||
|
.text-2xl,
|
||||||
|
.text-3xl,
|
||||||
|
.text-4xl {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Density variables ─── */
|
||||||
:root {
|
:root {
|
||||||
--spacing-xs: 0.5rem;
|
--spacing-xs: 0.5rem;
|
||||||
--spacing-sm: 0.75rem;
|
--spacing-sm: 0.75rem;
|
||||||
@@ -58,7 +139,6 @@
|
|||||||
--spacing-xl: 2rem;
|
--spacing-xl: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Densité compacte */
|
|
||||||
.density-compact {
|
.density-compact {
|
||||||
--spacing-xs: 0.25rem;
|
--spacing-xs: 0.25rem;
|
||||||
--spacing-sm: 0.5rem;
|
--spacing-sm: 0.5rem;
|
||||||
@@ -67,7 +147,6 @@
|
|||||||
--spacing-xl: 1.25rem;
|
--spacing-xl: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Densité confortable (défaut) */
|
|
||||||
.density-comfortable {
|
.density-comfortable {
|
||||||
--spacing-xs: 0.5rem;
|
--spacing-xs: 0.5rem;
|
||||||
--spacing-sm: 0.75rem;
|
--spacing-sm: 0.75rem;
|
||||||
@@ -76,7 +155,6 @@
|
|||||||
--spacing-xl: 2rem;
|
--spacing-xl: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Densité espacée */
|
|
||||||
.density-spacious {
|
.density-spacious {
|
||||||
--spacing-xs: 0.75rem;
|
--spacing-xs: 0.75rem;
|
||||||
--spacing-sm: 1rem;
|
--spacing-sm: 1rem;
|
||||||
@@ -85,251 +163,200 @@
|
|||||||
--spacing-xl: 3rem;
|
--spacing-xl: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Contraste élevé avec DaisyUI */
|
/* ─── High contrast mode ─── */
|
||||||
.contrast-high .btn {
|
.contrast-high .btn { @apply border-2; }
|
||||||
@apply border-2;
|
.contrast-high .input { @apply border-2; }
|
||||||
}
|
.contrast-high .select { @apply border-2; }
|
||||||
|
.contrast-high .textarea { @apply border-2; }
|
||||||
|
.contrast-high .modal-box { @apply border-2 border-base-content; }
|
||||||
|
|
||||||
.contrast-high .input {
|
/* ─── Accessibility ─── */
|
||||||
@apply border-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contrast-high .select {
|
|
||||||
@apply border-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contrast-high .textarea {
|
|
||||||
@apply border-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contrast-high .modal-box {
|
|
||||||
@apply border-2 border-base-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Amélioration de l'accessibilité */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*, *::before, *::after {
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus visible pour l'accessibilité */
|
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid #304998;
|
outline: 2px solid oklch(40% 0.16 262);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles pour les boutons de paramètres */
|
/* ─── Cards ─── */
|
||||||
|
.card {
|
||||||
|
border: 1px solid oklch(91% 0.015 260 / 0.6);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card {
|
||||||
|
background-color: oklch(100% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="mytheme-dark"] .site-card {
|
||||||
|
background-color: oklch(24% 0.015 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="mytheme-dark"] .card {
|
||||||
|
border-color: oklch(30% 0.02 260 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px oklch(22% 0.025 260 / 0.06),
|
||||||
|
0 2px 4px -2px oklch(22% 0.025 260 / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Navbar glass effect ─── */
|
||||||
|
.navbar-glass {
|
||||||
|
background: oklch(98.5% 0.004 260 / 0.82);
|
||||||
|
backdrop-filter: blur(12px) saturate(1.5);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(1.5);
|
||||||
|
border-bottom: 1px solid oklch(91% 0.015 260 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="mytheme-dark"] .navbar-glass {
|
||||||
|
background: oklch(22% 0.015 260 / 0.85);
|
||||||
|
border-bottom-color: oklch(30% 0.02 260 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Buttons ─── */
|
||||||
|
.btn {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-circle {
|
.btn-circle {
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-circle:hover {
|
.btn-circle:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-circle:active {
|
.btn-circle:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animation pour le modal */
|
/* ─── Inputs ─── */
|
||||||
|
.input, .select, .textarea {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus, .select:focus, .textarea:focus {
|
||||||
|
box-shadow: 0 0 0 3px oklch(40% 0.16 262 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tables ─── */
|
||||||
|
.table thead th {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: oklch(45% 0.03 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr {
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Badges ─── */
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Stats ─── */
|
||||||
|
.stat-title {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Modals ─── */
|
||||||
.modal {
|
.modal {
|
||||||
transition: opacity 0.3s ease-in-out;
|
transition: opacity 0.25s ease;
|
||||||
font-size: 100% !important; /* Force la taille normale pour le modal */
|
|
||||||
transform: none !important; /* Empêche les transformations */
|
|
||||||
scale: 1 !important; /* Force l'échelle à 1 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.modal-open {
|
|
||||||
animation: modalFadeIn 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* S'assurer que le contenu du modal garde une taille normale */
|
|
||||||
.modal-box {
|
.modal-box {
|
||||||
font-size: 100% !important;
|
font-family: var(--font-body);
|
||||||
transform: none !important;
|
border-radius: 0.75rem;
|
||||||
scale: 1 !important;
|
border: 1px solid oklch(91% 0.015 260 / 0.5);
|
||||||
width: auto !important;
|
|
||||||
max-width: 500px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal .form-control {
|
@keyframes modalSlideUp {
|
||||||
font-size: 100% !important;
|
from { opacity: 0; transform: translateY(0.5rem); }
|
||||||
transform: none !important;
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal .btn {
|
.modal.modal-open .modal-box {
|
||||||
font-size: 100% !important;
|
animation: modalSlideUp 0.25s ease-out;
|
||||||
transform: none !important;
|
|
||||||
padding: 0.5rem 1rem !important;
|
|
||||||
height: auto !important;
|
|
||||||
min-height: 2.5rem !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal .input {
|
/* ─── Page transitions ─── */
|
||||||
font-size: 100% !important;
|
.page-enter-active {
|
||||||
transform: none !important;
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
height: auto !important;
|
}
|
||||||
min-height: 2.5rem !important;
|
.page-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.page-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal .select {
|
/* ─── Scrollbar styling ─── */
|
||||||
font-size: 100% !important;
|
::-webkit-scrollbar {
|
||||||
transform: none !important;
|
width: 6px;
|
||||||
height: auto !important;
|
height: 6px;
|
||||||
min-height: 2.5rem !important;
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(75% 0.02 260);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(60% 0.03 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal .textarea {
|
/* ─── Readability ─── */
|
||||||
font-size: 100% !important;
|
.text-sm { line-height: 1.5; }
|
||||||
transform: none !important;
|
.text-xs { line-height: 1.4; }
|
||||||
min-height: 4rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal .range {
|
/* ─── Adaptive spacing ─── */
|
||||||
font-size: 100% !important;
|
.p-1 { padding: var(--spacing-xs); }
|
||||||
transform: none !important;
|
.p-2 { padding: var(--spacing-sm); }
|
||||||
height: auto !important;
|
.p-3 { padding: var(--spacing-md); }
|
||||||
min-height: 1.5rem !important;
|
.p-4 { padding: var(--spacing-lg); }
|
||||||
}
|
.p-5 { padding: var(--spacing-xl); }
|
||||||
|
|
||||||
.modal .label {
|
.m-1 { margin: var(--spacing-xs); }
|
||||||
font-size: 100% !important;
|
.m-2 { margin: var(--spacing-sm); }
|
||||||
transform: none !important;
|
.m-3 { margin: var(--spacing-md); }
|
||||||
}
|
.m-4 { margin: var(--spacing-lg); }
|
||||||
|
.m-5 { margin: var(--spacing-xl); }
|
||||||
|
|
||||||
.modal .label-text {
|
.gap-1 { gap: var(--spacing-xs); }
|
||||||
font-size: 100% !important;
|
.gap-2 { gap: var(--spacing-sm); }
|
||||||
transform: none !important;
|
.gap-3 { gap: var(--spacing-md); }
|
||||||
}
|
.gap-4 { gap: var(--spacing-lg); }
|
||||||
|
.gap-5 { gap: var(--spacing-xl); }
|
||||||
.modal .label-text-alt {
|
|
||||||
font-size: 100% !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal .checkbox {
|
|
||||||
font-size: 100% !important;
|
|
||||||
transform: none !important;
|
|
||||||
width: 1rem !important;
|
|
||||||
height: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal .text-xs {
|
|
||||||
font-size: 0.75rem !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal .text-sm {
|
|
||||||
font-size: 0.875rem !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal .text-lg {
|
|
||||||
font-size: 1.125rem !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal .font-bold {
|
|
||||||
font-weight: 700 !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal .font-medium {
|
|
||||||
font-weight: 500 !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empêcher les héritages de taille */
|
|
||||||
.modal * {
|
|
||||||
font-size: inherit !important;
|
|
||||||
transform: none !important;
|
|
||||||
scale: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styles pour les contrôles de zoom */
|
|
||||||
.range {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-webkit-slider-thumb {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Amélioration de la lisibilité */
|
|
||||||
.text-sm {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-xs {
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Espacement adaptatif */
|
|
||||||
.p-1 {
|
|
||||||
padding: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.p-2 {
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.p-3 {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
.p-4 {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
.p-5 {
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-1 {
|
|
||||||
margin: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.m-2 {
|
|
||||||
margin: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.m-3 {
|
|
||||||
margin: var(--spacing-md);
|
|
||||||
}
|
|
||||||
.m-4 {
|
|
||||||
margin: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
.m-5 {
|
|
||||||
margin: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap-1 {
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.gap-2 {
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.gap-3 {
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
.gap-4 {
|
|
||||||
gap: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
.gap-5 {
|
|
||||||
gap: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.form-control .label {
|
.form-control .label {
|
||||||
@@ -337,7 +364,6 @@
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control .label + * {
|
.form-control .label + * {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
<ComponentItem
|
<ComponentItem
|
||||||
:component="component"
|
:component="component"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
|
:show-delete="showDelete"
|
||||||
:collapse-all="collapseAll"
|
:collapse-all="collapseAll"
|
||||||
:toggle-token="toggleToken"
|
:toggle-token="toggleToken"
|
||||||
@update="$emit('update', $event)"
|
@update="$emit('update', $event)"
|
||||||
@edit-piece="$emit('edit-piece', $event)"
|
@edit-piece="$emit('edit-piece', $event)"
|
||||||
@custom-field-update="$emit('custom-field-update', $event)"
|
@custom-field-update="$emit('custom-field-update', $event)"
|
||||||
|
@delete="$emit('delete')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,6 +29,10 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
showDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
collapseAll: {
|
collapseAll: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
@@ -37,5 +43,5 @@ defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['update', 'edit-piece', 'custom-field-update'])
|
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,315 +1,200 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="componentDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Component Header -->
|
<!-- Component Header -->
|
||||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||||
<div class="flex items-start gap-3 w-full">
|
<IconLucideChevronRight
|
||||||
<button
|
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
||||||
type="button"
|
:class="{ 'rotate-90': !isCollapsed }"
|
||||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
aria-hidden="true"
|
||||||
:class="{ 'rotate-90': !isCollapsed }"
|
/>
|
||||||
:aria-expanded="!isCollapsed"
|
<div class="flex-1 min-w-0">
|
||||||
:title="isCollapsed ? 'Déplier les détails du composant' : 'Replier les détails du composant'"
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
@click="toggleCollapse"
|
<h3 class="text-sm font-semibold text-base-content truncate">
|
||||||
>
|
|
||||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
|
||||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} le composant</span>
|
|
||||||
</button>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
{{ component.name }}
|
{{ component.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
||||||
<span
|
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</span>
|
||||||
v-if="component.skeletonOnly"
|
</div>
|
||||||
class="badge badge-warning badge-sm"
|
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
|
||||||
>
|
<span
|
||||||
Défini dans le catalogue
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
</span>
|
:key="constructeur.id"
|
||||||
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
class="text-xs text-base-content/50"
|
||||||
<template v-if="componentConstructeursDisplay.length">
|
>
|
||||||
<span
|
{{ constructeur.name }}
|
||||||
v-for="constructeur in componentConstructeursDisplay"
|
</span>
|
||||||
:key="constructeur.id"
|
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
||||||
class="badge badge-outline badge-sm"
|
{{ displayProductName }}
|
||||||
>
|
</span>
|
||||||
{{ 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="showDelete"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||||
|
title="Supprimer ce composant"
|
||||||
|
@click.stop="$emit('delete')"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="!isCollapsed" class="space-y-4">
|
<!-- Expanded content -->
|
||||||
<!-- Component Info Display - Editable or Read-only -->
|
<div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
|
||||||
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg">
|
<!-- Info fields -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-medium">Nom</span></label>
|
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
|
||||||
<input
|
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
v-if="isEditMode"
|
</div>
|
||||||
v-model="component.name"
|
<div class="form-control">
|
||||||
type="text"
|
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
|
||||||
class="input input-bordered input-sm"
|
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
@blur="updateComponent"
|
</div>
|
||||||
>
|
<div class="form-control">
|
||||||
<div v-else class="input input-bordered input-sm bg-base-200">
|
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
|
||||||
{{ component.name }}
|
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-control">
|
||||||
<div class="form-control">
|
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
|
||||||
<label class="label"><span class="label-text font-medium">Référence</span></label>
|
<ConstructeurSelect
|
||||||
<input
|
class="w-full"
|
||||||
v-if="isEditMode"
|
:model-value="componentConstructeurIds"
|
||||||
v-model="component.reference"
|
:initial-options="componentConstructeursDisplay"
|
||||||
type="text"
|
@update:model-value="handleConstructeurChange"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Fields Display - Editable or Read-only -->
|
<!-- Read-only info -->
|
||||||
<div v-if="displayedCustomFields.length" class="mt-4 pt-4 border-t border-gray-200">
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
|
||||||
<h4 class="font-semibold text-sm text-gray-700 mb-3">
|
<div>
|
||||||
Champs personnalisés
|
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
|
||||||
</h4>
|
<p class="text-base-content">{{ component.name }}</p>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
</div>
|
||||||
<div
|
<div>
|
||||||
v-for="(field, index) in displayedCustomFields"
|
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
|
||||||
:key="resolveFieldKey(field, index)"
|
<p class="text-base-content">{{ component.reference || '—' }}</p>
|
||||||
class="form-control"
|
</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">
|
Voir le produit
|
||||||
<span class="label-text text-sm">{{ resolveFieldName(field) }}</span>
|
</NuxtLink>
|
||||||
<span v-if="resolveFieldRequired(field)" class="label-text-alt text-error">*</span>
|
</div>
|
||||||
</label>
|
<!-- Product documents -->
|
||||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
|
||||||
<input
|
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
|
||||||
v-if="resolveFieldType(field) === 'text'"
|
<div
|
||||||
v-model="field.value"
|
v-for="document in productDocuments"
|
||||||
type="text"
|
:key="document.id || document.path || document.name"
|
||||||
class="input input-bordered input-sm"
|
class="flex items-center justify-between gap-3 text-xs"
|
||||||
:required="resolveFieldRequired(field)"
|
>
|
||||||
@blur="updateComponentCustomField(field)"
|
<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">
|
||||||
<input
|
<img
|
||||||
v-else-if="resolveFieldType(field) === 'number'"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
v-model="field.value"
|
:src="document.fileUrl || document.path"
|
||||||
type="number"
|
class="h-full w-full object-cover"
|
||||||
class="input input-bordered input-sm"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
: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)"
|
|
||||||
>
|
>
|
||||||
<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>
|
</div>
|
||||||
<input
|
<span class="truncate text-base-content">{{ document.name }}</span>
|
||||||
v-else-if="resolveFieldType(field) === 'date'"
|
</div>
|
||||||
v-model="field.value"
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
type="date"
|
<button
|
||||||
class="input input-bordered input-sm"
|
type="button"
|
||||||
:required="resolveFieldRequired(field)"
|
class="btn btn-ghost btn-xs"
|
||||||
@blur="updateComponentCustomField(field)"
|
:disabled="!canPreviewDocument(document)"
|
||||||
|
@click="openPreview(document)"
|
||||||
>
|
>
|
||||||
</template>
|
Consulter
|
||||||
<template v-else>
|
</button>
|
||||||
<div class="input input-bordered input-sm bg-base-200">
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||||
{{ formatFieldDisplayValue(field) }}
|
Télécharger
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center justify-between">
|
||||||
<h4 class="font-semibold text-sm text-gray-700">
|
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
|
||||||
Documents
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||||
</h4>
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
|
||||||
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-gray-500">
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
||||||
Chargement des documents...
|
Chargement...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
@@ -320,88 +205,27 @@
|
|||||||
@files-added="handleFilesAdded"
|
@files-added="handleFilesAdded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="componentDocuments.length" class="space-y-2">
|
<DocumentListInline
|
||||||
<div
|
:documents="componentDocuments"
|
||||||
v-for="document in componentDocuments"
|
:can-delete="isEditMode"
|
||||||
:key="document.id"
|
:delete-disabled="uploadingDocuments"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
empty-text="Aucun document lié à ce composant."
|
||||||
>
|
@preview="openPreview"
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@delete="removeDocument"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Component Pieces -->
|
<!-- Component Pieces (real MachinePieceLinks) -->
|
||||||
<div v-if="component.pieces && component.pieces.length > 0" class="space-y-2">
|
<div v-if="linkedPieces.length > 0" class="space-y-2">
|
||||||
<h4 class="font-semibold text-gray-700">
|
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||||
Pièces du composant
|
Pièces du composant
|
||||||
</h4>
|
</p>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="space-y-2">
|
||||||
<PieceItem
|
<PieceItem
|
||||||
v-for="piece in component.pieces"
|
v-for="piece in linkedPieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
:piece="piece"
|
:piece="piece"
|
||||||
:is-edit-mode="isEditMode && !piece.skeletonOnly"
|
:is-edit-mode="isEditMode"
|
||||||
|
|
||||||
@update="updatePiece"
|
@update="updatePiece"
|
||||||
@edit="editPiece"
|
@edit="editPiece"
|
||||||
@custom-field-update="updatePieceCustomField"
|
@custom-field-update="updatePieceCustomField"
|
||||||
@@ -409,17 +233,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Structure pieces (read-only, from composant definition) -->
|
||||||
|
<div v-if="structurePieces.length > 0" class="space-y-2">
|
||||||
|
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||||
|
Pièces incluses par défaut
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<PieceItem
|
||||||
|
v-for="piece in structurePieces"
|
||||||
|
:key="piece.id"
|
||||||
|
:piece="piece"
|
||||||
|
:is-edit-mode="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sub Components -->
|
<!-- Sub Components -->
|
||||||
<div v-if="childComponents.length > 0" class="space-y-3">
|
<div v-if="childComponents.length > 0" class="space-y-2">
|
||||||
<h4 class="font-semibold text-gray-700">
|
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||||
Sous-composants
|
Sous-composants
|
||||||
</h4>
|
</p>
|
||||||
<div class="space-y-3 pl-4 border-l-2 border-gray-200">
|
<div class="space-y-2 pl-4 border-l-2 border-base-200">
|
||||||
<ComponentItem
|
<ComponentItem
|
||||||
v-for="subComponent in childComponents"
|
v-for="subComponent in childComponents"
|
||||||
:key="subComponent.id"
|
:key="subComponent.id"
|
||||||
:component="subComponent"
|
:component="subComponent"
|
||||||
:is-edit-mode="isEditMode && !subComponent.skeletonOnly"
|
:is-edit-mode="isEditMode"
|
||||||
:collapse-all="collapseAll"
|
:collapse-all="collapseAll"
|
||||||
:toggle-token="toggleToken"
|
:toggle-token="toggleToken"
|
||||||
@update="$emit('update', $event)"
|
@update="$emit('update', $event)"
|
||||||
@@ -450,19 +289,9 @@ import {
|
|||||||
formatSize,
|
formatSize,
|
||||||
shouldInlinePdf,
|
shouldInlinePdf,
|
||||||
documentPreviewSrc,
|
documentPreviewSrc,
|
||||||
documentThumbnailClass,
|
|
||||||
documentIcon,
|
documentIcon,
|
||||||
downloadDocument,
|
downloadDocument,
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
import {
|
|
||||||
resolveFieldKey,
|
|
||||||
resolveFieldName,
|
|
||||||
resolveFieldType,
|
|
||||||
resolveFieldOptions,
|
|
||||||
resolveFieldRequired,
|
|
||||||
resolveFieldReadOnly,
|
|
||||||
formatFieldDisplayValue,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
|
||||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||||
@@ -470,11 +299,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
component: { type: Object, required: true },
|
component: { type: Object, required: true },
|
||||||
isEditMode: { type: Boolean, default: false },
|
isEditMode: { type: Boolean, default: false },
|
||||||
|
showDelete: { type: Boolean, default: false },
|
||||||
collapseAll: { type: Boolean, default: true },
|
collapseAll: { type: Boolean, default: true },
|
||||||
toggleToken: { type: Number, default: 0 },
|
toggleToken: { type: Number, default: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update'])
|
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||||
|
|
||||||
// --- Shared composables ---
|
// --- Shared composables ---
|
||||||
const {
|
const {
|
||||||
@@ -526,6 +356,14 @@ const childComponents = computed(() => {
|
|||||||
return Array.isArray(list) ? list : []
|
return Array.isArray(list) ? list : []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Pieces split: real links vs structure definitions ---
|
||||||
|
const allPieces = computed(() => {
|
||||||
|
const list = props.component.pieces
|
||||||
|
return Array.isArray(list) ? list : []
|
||||||
|
})
|
||||||
|
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
|
||||||
|
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
|
||||||
|
|
||||||
// --- Constructeurs ---
|
// --- Constructeurs ---
|
||||||
const { constructeurs } = useConstructeurs()
|
const { constructeurs } = useConstructeurs()
|
||||||
|
|
||||||
|
|||||||
@@ -134,76 +134,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed } from 'vue';
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||||
import { useApi } from '~/composables/useApi';
|
import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers';
|
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentOption,
|
||||||
ComponentModelProduct,
|
PieceOption,
|
||||||
ComponentModelStructureNode,
|
ProductOption,
|
||||||
} from '~/shared/types/inventory';
|
} from '~/composables/useStructureAssignmentFetch';
|
||||||
|
|
||||||
interface ComponentOption {
|
export type {
|
||||||
id: string;
|
StructureAssignmentNode,
|
||||||
name?: string | null;
|
StructurePieceAssignment,
|
||||||
reference?: string | null;
|
StructureProductAssignment,
|
||||||
typeComposantId?: string | null;
|
} from '~/composables/useStructureAssignmentFetch';
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
assignment: StructureAssignmentNode;
|
assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
|
||||||
pieces: PieceOption[] | null;
|
pieces: PieceOption[] | null;
|
||||||
products: ProductOption[] | null;
|
products: ProductOption[] | null;
|
||||||
components: ComponentOption[] | 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',
|
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||||
);
|
);
|
||||||
|
|
||||||
const { get } = useApi();
|
const {
|
||||||
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
|
pieceLoadingByPath,
|
||||||
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
|
productLoadingByPath,
|
||||||
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
|
componentLoadingByPath,
|
||||||
const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
componentOptions,
|
||||||
const productLoadingByPath = ref<Record<string, boolean>>({});
|
componentOptionLabel,
|
||||||
const componentLoadingByPath = ref<Record<string, boolean>>({});
|
componentOptionDescription,
|
||||||
|
fetchComponentOptions,
|
||||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
getPieceOptions,
|
||||||
target[key] = value;
|
pieceOptionLabel,
|
||||||
};
|
pieceOptionDescription,
|
||||||
|
fetchPieceOptions,
|
||||||
const componentOptions = computed(() => {
|
describePieceRequirement,
|
||||||
if (isRoot.value) {
|
getProductOptions,
|
||||||
return [];
|
productOptionLabel,
|
||||||
}
|
productOptionDescription,
|
||||||
const cached = componentOptionsByPath.value[props.assignment.path];
|
fetchProductOptions,
|
||||||
if (cached) {
|
describeProductRequirement,
|
||||||
return cached;
|
} = useStructureAssignmentFetch({
|
||||||
}
|
assignment: props.assignment,
|
||||||
const definition = props.assignment.definition || {};
|
pieces: props.pieces,
|
||||||
const requiredTypeId =
|
products: props.products,
|
||||||
definition.typeComposantId || definition.modelId || null;
|
components: props.components,
|
||||||
const requiredFamilyCode = definition.familyCode || null;
|
isRoot: () => isRoot.value,
|
||||||
|
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
|
||||||
return (props.components || []).filter((component) => {
|
productTypeLabelMap: props.productTypeLabelMap ?? {},
|
||||||
if (!component || typeof component !== 'object') {
|
componentTypeLabelMap: props.componentTypeLabelMap ?? {},
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requiredTypeId) {
|
|
||||||
return component.typeComposantId === requiredTypeId;
|
|
||||||
}
|
|
||||||
if (requiredFamilyCode) {
|
|
||||||
return (
|
|
||||||
component.typeComposant?.code === requiredFamilyCode ||
|
|
||||||
component.typeComposantId === requiredFamilyCode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const componentOptionLabel = (component?: ComponentOption | null) => {
|
const normalizeSelectionValue = (value: unknown) => {
|
||||||
if (!component) {
|
if (value === null || value === undefined || value === '') {
|
||||||
return 'Composant sans nom';
|
|
||||||
}
|
|
||||||
return component.name || 'Composant sans nom';
|
|
||||||
};
|
|
||||||
|
|
||||||
const componentOptionDescription = (component?: ComponentOption | null) => {
|
|
||||||
if (!component) {
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
if (typeof value === 'string') {
|
||||||
const typeLabel =
|
return value;
|
||||||
component.typeComposant?.name || component.typeComposant?.code || null;
|
|
||||||
if (typeLabel) {
|
|
||||||
parts.push(typeLabel);
|
|
||||||
}
|
}
|
||||||
if (component.reference) {
|
if (typeof value === 'number') {
|
||||||
parts.push(`Ref. ${component.reference}`);
|
return String(value);
|
||||||
}
|
}
|
||||||
return parts.join(' • ');
|
return '';
|
||||||
};
|
|
||||||
|
|
||||||
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';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requirementLabel = computed(() => {
|
const requirementLabel = computed(() => {
|
||||||
@@ -584,139 +247,13 @@ const requirementLabel = computed(() => {
|
|||||||
const requirementDescription = computed(() => {
|
const requirementDescription = computed(() => {
|
||||||
const definition = props.assignment.definition || {};
|
const definition = props.assignment.definition || {};
|
||||||
const family =
|
const family =
|
||||||
definition.typeComposantLabel ||
|
definition.typeComposantLabel
|
||||||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
|
|| (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
|
||||||
definition.typeComposant?.name ||
|
|| definition.typeComposant?.name
|
||||||
definition.familyCode;
|
|| definition.familyCode;
|
||||||
if (family) {
|
if (family) {
|
||||||
return `Doit appartenir à la famille "${family}".`;
|
return `Doit appartenir à la famille "${family}".`;
|
||||||
}
|
}
|
||||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="customFields && customFields.length > 0" class="space-y-4">
|
<div v-if="customFields && customFields.length > 0" class="space-y-4">
|
||||||
<h4 class="font-semibold text-gray-700 mb-3">
|
<h4 class="font-semibold text-base-content/80 mb-3">
|
||||||
Champs personnalisés
|
Champs personnalisés
|
||||||
</h4>
|
</h4>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="font-bold text-xl truncate">
|
<h3 class="font-bold text-xl truncate">
|
||||||
Prévisualisation
|
Prévisualisation
|
||||||
|
<span v-if="navTotal > 1" class="text-base font-normal text-base-content/50">
|
||||||
|
{{ activeIndex + 1 }} / {{ navTotal }}
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-500 truncate">
|
<p class="text-sm text-base-content/50 truncate">
|
||||||
{{ document?.name || document?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
|
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
|
||||||
@@ -20,15 +23,35 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</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">
|
<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'">
|
<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>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'pdf'">
|
<template v-else-if="previewType === 'pdf'">
|
||||||
<iframe
|
<iframe
|
||||||
:src="document?.path"
|
:src="documentSrc"
|
||||||
class="w-full h-full bg-white"
|
class="w-full h-full bg-white"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
title="Aperçu PDF"
|
title="Aperçu PDF"
|
||||||
@@ -36,16 +59,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'audio'">
|
<template v-else-if="previewType === 'audio'">
|
||||||
<audio :src="document?.path" controls class="w-full" />
|
<audio :src="documentSrc" controls class="w-full" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'video'">
|
<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>
|
||||||
|
|
||||||
<template v-else-if="previewType === 'text'">
|
<template v-else-if="previewType === 'text'">
|
||||||
<div class="w-full h-full overflow-auto">
|
<div class="w-full h-full overflow-auto">
|
||||||
<div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-gray-500">
|
<div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-base-content/50">
|
||||||
<span class="loading loading-spinner loading-md mr-2" />
|
<span class="loading loading-spinner loading-md mr-2" />
|
||||||
Chargement du document...
|
Chargement du document...
|
||||||
</div>
|
</div>
|
||||||
@@ -59,7 +82,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text-sm text-gray-500 text-center px-6">
|
<div class="text-sm text-base-content/50 text-center px-6">
|
||||||
Prévisualisation non disponible pour ce type de document.
|
Prévisualisation non disponible pour ce type de document.
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -80,31 +103,110 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import { getPreviewType, describeDocument } from '~/utils/documentPreview'
|
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
document: {
|
document: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null,
|
||||||
},
|
},
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
|
documents: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
const previewType = computed(() => getPreviewType(props.document))
|
// --- Carousel navigation ---
|
||||||
const documentDescription = computed(() => describeDocument(props.document))
|
|
||||||
|
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 textContent = ref('')
|
||||||
const textLoading = ref(false)
|
const textLoading = ref(false)
|
||||||
const textError = ref('')
|
const textError = ref('')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.document,
|
activeDoc,
|
||||||
async (doc) => {
|
async (doc) => {
|
||||||
textContent.value = ''
|
textContent.value = ''
|
||||||
textError.value = ''
|
textError.value = ''
|
||||||
@@ -115,22 +217,17 @@ watch(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
textLoading.value = true
|
textLoading.value = true
|
||||||
const path = doc.path || ''
|
const url = doc.fileUrl || doc.path || ''
|
||||||
if (path.startsWith('data:')) {
|
if (!url) {
|
||||||
const base64Part = path.split(',')[1] || ''
|
textError.value = 'Aucune URL de document disponible.'
|
||||||
if (!base64Part) {
|
return
|
||||||
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 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) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement du texte:', error)
|
console.error('Erreur lors du chargement du texte:', error)
|
||||||
textError.value = error.message || 'Impossible de lire ce document.'
|
textError.value = error.message || 'Impossible de lire ce document.'
|
||||||
@@ -138,7 +235,7 @@ watch(
|
|||||||
textLoading.value = false
|
textLoading.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -146,11 +243,8 @@ const close = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const download = () => {
|
const download = () => {
|
||||||
if (!props.document?.path) { return }
|
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
|
||||||
const link = document.createElement('a')
|
if (!url) { return }
|
||||||
link.href = props.document.path
|
window.open(url, '_blank')
|
||||||
link.download = props.document.filename || props.document.name || 'document'
|
|
||||||
link.target = '_blank'
|
|
||||||
link.click()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ type GenericDocument = {
|
|||||||
filename?: string | null;
|
filename?: string | null;
|
||||||
mimeType?: string | null;
|
mimeType?: string | null;
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
|
fileUrl?: string | null;
|
||||||
|
downloadUrl?: string | null;
|
||||||
size?: number | null;
|
size?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ const normalizedDocument = computed(() => props.document ?? null);
|
|||||||
|
|
||||||
const canRenderImage = computed(() => {
|
const canRenderImage = computed(() => {
|
||||||
const doc = normalizedDocument.value;
|
const doc = normalizedDocument.value;
|
||||||
return !!(doc && isImageDocument(doc) && doc.path);
|
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
|
||||||
});
|
});
|
||||||
|
|
||||||
const canRenderPdf = computed(() => {
|
const canRenderPdf = computed(() => {
|
||||||
@@ -73,13 +75,14 @@ const appendPdfViewerParams = (src: string) => {
|
|||||||
|
|
||||||
const previewSrc = computed(() => {
|
const previewSrc = computed(() => {
|
||||||
const doc = normalizedDocument.value;
|
const doc = normalizedDocument.value;
|
||||||
if (!doc || !doc.path) {
|
const url = doc?.fileUrl || doc?.path;
|
||||||
|
if (!doc || !url) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (isPdfDocument(doc)) {
|
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'));
|
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<h3 class="font-semibold">
|
<h3 class="font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-base-content/50">
|
||||||
{{ subtitle }}
|
{{ subtitle }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
|
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
|
||||||
Sélectionner des fichiers
|
Sélectionner des fichiers
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-gray-500">ou glisser-déposer ici</span>
|
<span class="text-xs text-base-content/50">ou glisser-déposer ici</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ file.name }}</span>
|
<span class="font-medium">{{ file.name }}</span>
|
||||||
<span class="text-xs text-gray-500">{{ formatSize(file.size) }} • {{ file.type || 'Type inconnu' }}</span>
|
<span class="text-xs text-base-content/50">{{ formatSize(file.size) }} • {{ file.type || 'Type inconnu' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">
|
||||||
@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedFiles = computed(() => internalFiles.value)
|
const selectedFiles = internalFiles
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
<div class="flex flex-wrap items-center gap-2 text-sm text-base-content/60">
|
||||||
<span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span>
|
<span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span>
|
||||||
<span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span>
|
<span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span>
|
||||||
<span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span>
|
<span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span>
|
||||||
<span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-gray-500">
|
<span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-base-content/50">
|
||||||
Structure vide
|
Structure vide
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<section :class="sectionClasses">
|
<section :class="sectionClasses">
|
||||||
<div :class="contentClasses">
|
<div :class="contentClasses">
|
||||||
<div :class="['space-y-4', maxWidthClass]">
|
<div :class="['space-y-3', maxWidthClass]">
|
||||||
<component :is="headingTag" v-if="title" class="text-4xl font-bold">
|
<component :is="headingTag" v-if="title" class="text-4xl font-bold tracking-tight">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</component>
|
</component>
|
||||||
<p v-if="subtitle" class="text-sm opacity-90">
|
<p v-if="subtitle" class="text-sm opacity-80 leading-relaxed">
|
||||||
{{ subtitle }}
|
{{ subtitle }}
|
||||||
</p>
|
</p>
|
||||||
<slot />
|
<slot />
|
||||||
@@ -58,9 +58,9 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const sectionClasses = computed(() => {
|
const sectionClasses = computed(() => {
|
||||||
const classes = ['hero', 'bg-gradient-to-r', props.gradientFrom, props.gradientTo, props.minHeight]
|
const classes = ['hero', 'bg-gradient-to-br', props.gradientFrom, props.gradientTo, props.minHeight]
|
||||||
if (props.rounded) {
|
if (props.rounded) {
|
||||||
classes.push('rounded-lg')
|
classes.push('rounded-xl', 'overflow-hidden')
|
||||||
}
|
}
|
||||||
return classes
|
return classes
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,63 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border border-gray-200 rounded-lg p-4">
|
<div class="space-y-4">
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="pieceDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||||
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" />
|
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||||
<input
|
<button
|
||||||
v-if="isEditMode"
|
type="button"
|
||||||
:id="`piece-name-${piece.id}`"
|
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
||||||
v-model="pieceData.name"
|
:class="{ 'rotate-90': !isCollapsed }"
|
||||||
type="text"
|
:aria-expanded="!isCollapsed"
|
||||||
class="font-semibold text-lg input input-sm input-bordered"
|
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
||||||
@blur="updatePiece"
|
@click="toggleCollapse"
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
|
|
||||||
>
|
>
|
||||||
{{ 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>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
<button
|
||||||
<span
|
v-if="showDelete"
|
||||||
v-if="piece.skeletonOnly"
|
type="button"
|
||||||
class="badge badge-warning badge-sm"
|
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||||
>
|
title="Supprimer cette pièce"
|
||||||
Défini dans le catalogue
|
@click="$emit('delete')"
|
||||||
</span>
|
>
|
||||||
<span
|
Supprimer
|
||||||
v-if="piece.typeMachinePieceRequirement"
|
</button>
|
||||||
class="badge badge-outline badge-sm"
|
|
||||||
>
|
|
||||||
Groupe :
|
|
||||||
{{
|
|
||||||
piece.typeMachinePieceRequirement.label ||
|
|
||||||
piece.typeMachinePieceRequirement.typePiece?.name ||
|
|
||||||
"Non défini"
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="piece.parentComponentName"
|
|
||||||
class="badge badge-ghost badge-sm"
|
|
||||||
>
|
|
||||||
Rattachée à {{ piece.parentComponentName }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="displayProductName"
|
|
||||||
class="badge badge-info badge-sm"
|
|
||||||
>
|
|
||||||
Produit : {{ displayProductName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="isEditMode" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-sm">Quantité</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="pieceData.quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input input-bordered input-sm md:input-md w-24"
|
||||||
|
@blur="updatePiece"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="displayQuantity > 1">
|
||||||
|
<span class="font-medium">Quantité:</span>
|
||||||
|
<span class="ml-2">{{ displayQuantity }}</span>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">Référence:</span>
|
<span class="font-medium">Référence:</span>
|
||||||
<input
|
<input
|
||||||
@@ -86,7 +114,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="formatConstructeurContact(constructeur)"
|
v-if="formatConstructeurContact(constructeur)"
|
||||||
class="text-xs text-gray-500"
|
class="text-xs text-base-content/50"
|
||||||
>
|
>
|
||||||
{{ formatConstructeurContact(constructeur) }}
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -169,65 +197,10 @@
|
|||||||
<span class="font-semibold">{{ info.label }} :</span>
|
<span class="font-semibold">{{ info.label }} :</span>
|
||||||
<span class="ml-1">{{ info.value }}</span>
|
<span class="ml-1">{{ info.value }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div
|
<ProductDocumentsInline
|
||||||
v-if="productDocuments.length"
|
:documents="productDocuments"
|
||||||
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
@preview="openPreview"
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">
|
<span v-else class="font-medium">
|
||||||
Non défini
|
Non défini
|
||||||
@@ -235,149 +208,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés de la pièce -->
|
<!-- Champs personnalisés de la pièce -->
|
||||||
<div
|
<CustomFieldDisplay
|
||||||
v-if="displayedCustomFields.length"
|
:fields="displayedCustomFields"
|
||||||
class="mt-4 pt-4 border-t border-gray-200"
|
:is-edit-mode="isEditMode"
|
||||||
>
|
@field-input="handleCustomFieldInput"
|
||||||
<h5 class="text-sm font-medium text-gray-700 mb-3">
|
@field-blur="handleCustomFieldBlur"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Mode édition -->
|
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
|
||||||
<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="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h5 class="text-sm font-medium text-gray-700">Documents</h5>
|
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
|
||||||
<span
|
<span
|
||||||
v-if="isEditMode && selectedFiles.length"
|
v-if="isEditMode && selectedFiles.length"
|
||||||
class="badge badge-outline"
|
class="badge badge-outline"
|
||||||
@@ -389,7 +232,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-gray-500">
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
||||||
Chargement des documents...
|
Chargement des documents...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -401,95 +244,26 @@
|
|||||||
@files-added="handleFilesAdded"
|
@files-added="handleFilesAdded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="pieceDocuments.length" class="space-y-2">
|
<DocumentListInline
|
||||||
<div
|
:documents="pieceDocuments"
|
||||||
v-for="document in pieceDocuments"
|
:can-delete="isEditMode"
|
||||||
:key="document.id"
|
:delete-disabled="uploadingDocuments"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
empty-text="Aucun document lié à cette pièce."
|
||||||
>
|
@preview="openPreview"
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@delete="removeDocument"
|
||||||
<div
|
/>
|
||||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
</div>
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, onMounted, watch, computed } from 'vue'
|
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
||||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||||
import ProductSelect from '~/components/ProductSelect.vue'
|
import ProductSelect from '~/components/ProductSelect.vue'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucidePackage from '~icons/lucide/package'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import {
|
import {
|
||||||
@@ -498,22 +272,8 @@ import {
|
|||||||
uniqueConstructeurIds,
|
uniqueConstructeurIds,
|
||||||
} from '~/shared/constructeurUtils'
|
} from '~/shared/constructeurUtils'
|
||||||
import {
|
import {
|
||||||
formatSize,
|
|
||||||
shouldInlinePdf,
|
|
||||||
documentPreviewSrc,
|
|
||||||
documentThumbnailClass,
|
|
||||||
documentIcon,
|
|
||||||
downloadDocument,
|
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
|
||||||
import {
|
|
||||||
resolveFieldKey,
|
|
||||||
resolveFieldId,
|
resolveFieldId,
|
||||||
resolveFieldName,
|
|
||||||
resolveFieldType,
|
|
||||||
resolveFieldOptions,
|
|
||||||
resolveFieldRequired,
|
|
||||||
resolveFieldReadOnly,
|
resolveFieldReadOnly,
|
||||||
formatFieldDisplayValue,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
} from '~/shared/utils/entityCustomFieldLogic'
|
||||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||||
@@ -522,9 +282,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
piece: { type: Object, required: true },
|
piece: { type: Object, required: true },
|
||||||
isEditMode: { type: Boolean, default: false },
|
isEditMode: { type: Boolean, default: false },
|
||||||
|
showDelete: { type: Boolean, default: false },
|
||||||
|
collapseAll: { type: Boolean, default: true },
|
||||||
|
toggleToken: { type: Number, default: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
|
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||||
|
|
||||||
// --- Local reactive data for editing ---
|
// --- Local reactive data for editing ---
|
||||||
const pieceData = reactive({
|
const pieceData = reactive({
|
||||||
@@ -532,6 +295,11 @@ const pieceData = reactive({
|
|||||||
reference: props.piece.reference || '',
|
reference: props.piece.reference || '',
|
||||||
prix: props.piece.prix || '',
|
prix: props.piece.prix || '',
|
||||||
productId: props.piece.product?.id || props.piece.productId || null,
|
productId: props.piece.product?.id || props.piece.productId || null,
|
||||||
|
quantity: props.piece.quantity ?? 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayQuantity = computed(() => {
|
||||||
|
return pieceData.quantity ?? 1
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Products ---
|
// --- Products ---
|
||||||
@@ -575,6 +343,23 @@ const {
|
|||||||
updateCustomField,
|
updateCustomField,
|
||||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
} = 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 ---
|
// --- Constructeurs ---
|
||||||
const { constructeurs } = useConstructeurs()
|
const { constructeurs } = useConstructeurs()
|
||||||
|
|
||||||
@@ -648,16 +433,16 @@ const handleProductChange = async (value) => {
|
|||||||
updatePiece()
|
updatePiece()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Custom field local helpers ---
|
// --- Custom field event handlers ---
|
||||||
const setCustomFieldValue = (fieldValueId, value, field) => {
|
const handleCustomFieldInput = (field, value) => {
|
||||||
if (resolveFieldReadOnly(field)) return
|
if (resolveFieldReadOnly(field)) return
|
||||||
if (field && typeof field === 'object') field.value = value
|
const fieldValueId = resolveFieldId(field)
|
||||||
if (!fieldValueId) return
|
if (!fieldValueId) return
|
||||||
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
||||||
if (fieldValue) fieldValue.value = value
|
if (fieldValue) fieldValue.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateCustomFieldValue = async (_fieldValueId, field) => {
|
const handleCustomFieldBlur = async (field) => {
|
||||||
await updateCustomField(field)
|
await updateCustomField(field)
|
||||||
const cfId = field?.customFieldId || field?.customField?.id || null
|
const cfId = field?.customFieldId || field?.customField?.id || null
|
||||||
if (cfId || field?.customFieldValueId) {
|
if (cfId || field?.customFieldValueId) {
|
||||||
@@ -675,13 +460,14 @@ const updatePiece = () => {
|
|||||||
let parsedPrice = null
|
let parsedPrice = null
|
||||||
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
|
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
|
||||||
const numeric = Number(prixValue)
|
const numeric = Number(prixValue)
|
||||||
if (!Number.isNaN(numeric)) parsedPrice = numeric
|
if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
|
||||||
}
|
}
|
||||||
const product = selectedProduct.value ? { ...selectedProduct.value } : null
|
const product = selectedProduct.value ? { ...selectedProduct.value } : null
|
||||||
emit('update', {
|
emit('update', {
|
||||||
...props.piece,
|
...props.piece,
|
||||||
...pieceData,
|
...pieceData,
|
||||||
prix: parsedPrice,
|
prix: parsedPrice,
|
||||||
|
quantity: pieceData.quantity ?? 1,
|
||||||
productId: pieceData.productId || null,
|
productId: pieceData.productId || null,
|
||||||
product,
|
product,
|
||||||
constructeurIds: pieceConstructeurIds.value,
|
constructeurIds: pieceConstructeurIds.value,
|
||||||
@@ -721,11 +507,12 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.piece.name, props.piece.reference, props.piece.prix],
|
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
|
||||||
() => {
|
() => {
|
||||||
pieceData.name = props.piece.name || ''
|
pieceData.name = props.piece.name || ''
|
||||||
pieceData.reference = props.piece.reference || ''
|
pieceData.reference = props.piece.reference || ''
|
||||||
pieceData.prix = props.piece.prix || ''
|
pieceData.prix = props.piece.prix || ''
|
||||||
|
pieceData.quantity = props.piece.quantity ?? 1
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -733,6 +520,7 @@ onMounted(() => {
|
|||||||
pieceData.name = props.piece.name || ''
|
pieceData.name = props.piece.name || ''
|
||||||
pieceData.reference = props.piece.reference || ''
|
pieceData.reference = props.piece.reference || ''
|
||||||
pieceData.prix = props.piece.prix || ''
|
pieceData.prix = props.piece.prix || ''
|
||||||
|
pieceData.quantity = props.piece.quantity ?? 1
|
||||||
loadProducts().catch(() => {})
|
loadProducts().catch(() => {})
|
||||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
||||||
if (!props.piece.documents?.length) refreshDocuments()
|
if (!props.piece.documents?.length) refreshDocuments()
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<header class="flex items-center justify-between">
|
<header>
|
||||||
<div>
|
<h3 class="text-sm font-semibold">
|
||||||
<h3 class="text-sm font-semibold">
|
Produits inclus par défaut
|
||||||
Produits inclus par défaut
|
</h3>
|
||||||
</h3>
|
<p class="text-xs text-base-content/70">
|
||||||
<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.
|
||||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
</p>
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
<p v-if="!products.length" class="text-xs text-gray-500">
|
<p v-if="!products.length" class="text-xs text-base-content/50">
|
||||||
Aucun produit défini.
|
Aucun produit défini.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -71,20 +65,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
||||||
|
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<header class="flex items-center justify-between">
|
<h3 class="text-sm font-semibold">
|
||||||
<h3 class="text-sm font-semibold">
|
Champs personnalisés
|
||||||
Champs personnalisés
|
</h3>
|
||||||
</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>
|
|
||||||
|
|
||||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
<p v-if="!fields.length" class="text-xs text-base-content/50">
|
||||||
Aucun champ personnalisé n'a encore été défini.
|
Aucun champ personnalisé n'a encore été défini.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -101,106 +93,94 @@
|
|||||||
@drop.prevent="onDrop(index)"
|
@drop.prevent="onDrop(index)"
|
||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||||
disabled
|
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" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
import type {
|
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||||
PieceModelCustomField,
|
import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
|
||||||
PieceModelCustomFieldType,
|
|
||||||
PieceModelProduct,
|
|
||||||
PieceModelStructure,
|
|
||||||
PieceModelStructureEditorField,
|
|
||||||
} from '~/shared/types/inventory'
|
|
||||||
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
|
||||||
|
|
||||||
defineOptions({ name: 'PieceModelStructureEditor' })
|
defineOptions({ name: 'PieceModelStructureEditor' })
|
||||||
|
|
||||||
type EditorField = PieceModelStructureEditorField & { uid: string }
|
|
||||||
type EditorProduct = {
|
|
||||||
uid: string
|
|
||||||
typeProductId: string
|
|
||||||
typeProductLabel: string
|
|
||||||
familyCode: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: PieceModelStructure | null
|
modelValue?: PieceModelStructure | null
|
||||||
restrictedMode?: boolean
|
restrictedMode?: boolean
|
||||||
@@ -210,373 +190,23 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: PieceModelStructure): void
|
(event: 'update:modelValue', value: PieceModelStructure): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const {
|
||||||
|
fields,
|
||||||
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
|
products,
|
||||||
Array.isArray(value) ? value : []
|
productTypeOptions,
|
||||||
|
restrictedMode,
|
||||||
const normalizeLineEndings = (value: string): string =>
|
isFieldLocked,
|
||||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
isProductLocked,
|
||||||
|
formatProductTypeOption,
|
||||||
const safeClone = <T,>(value: T, fallback: T): T => {
|
handleProductTypeSelect,
|
||||||
try {
|
addProduct,
|
||||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
removeProduct,
|
||||||
} catch {
|
addField,
|
||||||
return JSON.parse(JSON.stringify(fallback)) as T
|
removeField,
|
||||||
}
|
reorderClass,
|
||||||
}
|
onDragStart,
|
||||||
|
onDragEnter,
|
||||||
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
|
onDrop,
|
||||||
if (!structure || typeof structure !== 'object') {
|
onDragEnd,
|
||||||
return {}
|
} = usePieceStructureEditorLogic({ props, emit })
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
</script>
|
</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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<template v-if="isRoot">
|
<template v-if="isRoot">
|
||||||
<p class="text-[11px] text-gray-500">
|
<p class="text-[11px] text-base-content/50">
|
||||||
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
|
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
{{ formatComponentTypeOption(type) }}
|
{{ formatComponentTypeOption(type) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-[11px] text-gray-500">
|
<p class="text-[11px] text-base-content/50">
|
||||||
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="!isRoot" class="form-control mt-2">
|
<div v-if="!isRoot" class="form-control mt-2">
|
||||||
@@ -70,16 +70,10 @@
|
|||||||
|
|
||||||
<div class="px-4 py-4 space-y-5">
|
<div class="px-4 py-4 space-y-5">
|
||||||
<section v-if="isRoot" class="space-y-3">
|
<section v-if="isRoot" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">
|
||||||
<h4 :class="headingClass">
|
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
||||||
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
</h4>
|
||||||
</h4>
|
<p v-if="!(node.customFields?.length)" class="text-xs text-base-content/50">
|
||||||
<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">
|
|
||||||
Aucun champ n'a encore été défini.
|
Aucun champ n'a encore été défini.
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
@@ -155,19 +149,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section v-if="isRoot" class="space-y-3">
|
<section v-if="isRoot" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">
|
||||||
<h4 :class="headingClass">
|
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
</h4>
|
||||||
</h4>
|
<p v-if="!(node.products?.length)" class="text-xs text-base-content/50">
|
||||||
<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">
|
|
||||||
Aucun produit défini.
|
Aucun produit défini.
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
@@ -228,19 +220,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section v-if="isRoot" class="space-y-3">
|
<section v-if="isRoot" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">
|
||||||
<h4 :class="headingClass">
|
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
</h4>
|
||||||
</h4>
|
<p v-if="!(node.pieces?.length)" class="text-xs text-base-content/50">
|
||||||
<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">
|
|
||||||
Aucune pièce définie.
|
Aucune pièce définie.
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
@@ -286,10 +276,22 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-[11px] text-gray-500">
|
<p class="mt-1 text-[11px] text-base-content/50">
|
||||||
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Quantité</span></label>
|
||||||
|
<input
|
||||||
|
v-model.number="piece.quantity"
|
||||||
|
type="number"
|
||||||
|
:min="1"
|
||||||
|
step="1"
|
||||||
|
placeholder="Qté"
|
||||||
|
class="input input-bordered input-sm md:input-md w-20"
|
||||||
|
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
<button 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" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
@@ -302,25 +304,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
|
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">Sous-composants</h4>
|
||||||
<h4 :class="headingClass">Sous-composants</h4>
|
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-base-content/50">
|
||||||
<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">
|
|
||||||
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
|
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
|
||||||
</p>
|
</p>
|
||||||
<p v-if="!hasSubcomponents" class="text-xs text-gray-500">
|
<p v-if="!hasSubcomponents" class="text-xs text-base-content/50">
|
||||||
Aucun sous-composant défini.
|
Aucun sous-composant défini.
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
@@ -357,6 +352,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,26 +368,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
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' })
|
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<{
|
const props = withDefaults(defineProps<{
|
||||||
node: EditableStructureNode
|
node: EditableStructureNode
|
||||||
depth?: number
|
depth?: number
|
||||||
@@ -413,754 +404,60 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits(['remove'])
|
const emit = defineEmits(['remove'])
|
||||||
|
|
||||||
const initialCustomFieldIndices = ref<Set<number>>(new Set())
|
const {
|
||||||
const initialPieceIndices = ref<Set<number>>(new Set())
|
isCustomFieldLocked,
|
||||||
const initialProductIndices = ref<Set<number>>(new Set())
|
isPieceLocked,
|
||||||
const initialSubcomponentIndices = ref<Set<number>>(new Set())
|
isProductLocked,
|
||||||
|
isSubcomponentLocked,
|
||||||
const initializeLockedIndices = () => {
|
isLocked,
|
||||||
if (props.restrictedMode) {
|
restrictedMode,
|
||||||
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
|
componentTypes,
|
||||||
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
|
pieceTypes,
|
||||||
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
|
productTypes,
|
||||||
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(
|
|
||||||
canManageSubcomponents,
|
canManageSubcomponents,
|
||||||
(allowed) => {
|
childAllowSubcomponents,
|
||||||
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
|
hasSubcomponents,
|
||||||
props.node.subcomponents.splice(0, props.node.subcomponents.length)
|
containerClass,
|
||||||
}
|
headingClass,
|
||||||
},
|
lockedTypeDisplay,
|
||||||
{ immediate: true }
|
getComponentTypeLabel,
|
||||||
)
|
getPieceTypeLabel,
|
||||||
|
formatComponentTypeOption,
|
||||||
watch(componentTypes, () => {
|
formatPieceTypeOption,
|
||||||
syncComponentType(props.node)
|
formatProductTypeOption,
|
||||||
}, { deep: true, immediate: true })
|
handleComponentTypeSelect,
|
||||||
|
handlePieceTypeSelect,
|
||||||
watch(
|
handleProductTypeSelect,
|
||||||
() => props.node.typeComposantId,
|
addCustomField,
|
||||||
() => {
|
removeCustomField,
|
||||||
syncComponentType(props.node)
|
addPiece,
|
||||||
},
|
removePiece,
|
||||||
)
|
addProduct,
|
||||||
|
removeProduct,
|
||||||
watch(pieceTypes, () => {
|
addSubComponent,
|
||||||
syncPieceLabels(props.node?.pieces)
|
removeSubComponent,
|
||||||
}, { deep: true, immediate: true })
|
onCustomFieldDragStart,
|
||||||
|
onCustomFieldDragEnter,
|
||||||
watch(
|
onCustomFieldDrop,
|
||||||
() => props.node.pieces,
|
onCustomFieldDragEnd,
|
||||||
(value) => {
|
customFieldReorderClass,
|
||||||
syncPieceLabels(value)
|
onPieceDragStart,
|
||||||
},
|
onPieceDragEnter,
|
||||||
{ deep: true }
|
onPieceDragOver,
|
||||||
)
|
onPieceDrop,
|
||||||
|
onPieceDragEnd,
|
||||||
watch(productTypes, () => {
|
pieceReorderClass,
|
||||||
syncProductLabels(props.node?.products)
|
onProductDragStart,
|
||||||
}, { deep: true, immediate: true })
|
onProductDragEnter,
|
||||||
|
onProductDragOver,
|
||||||
watch(
|
onProductDrop,
|
||||||
() => props.node.products,
|
onProductDragEnd,
|
||||||
(value) => {
|
productReorderClass,
|
||||||
syncProductLabels(value)
|
onSubcomponentDragStart,
|
||||||
},
|
onSubcomponentDragEnter,
|
||||||
{ deep: true }
|
onSubcomponentDragOver,
|
||||||
)
|
onSubcomponentDrop,
|
||||||
|
onSubcomponentDragEnd,
|
||||||
watch(
|
subcomponentReorderClass,
|
||||||
() => props.node.customFields,
|
} = useStructureNodeLogic(props)
|
||||||
(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 }
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<IconLucideX
|
<IconLucideCircleX
|
||||||
v-else-if="toast.type === 'error'"
|
v-else-if="toast.type === 'error'"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x'
|
||||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||||
import IconLucideInfo from '~icons/lucide/info'
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-base-content/50">
|
||||||
{{ labels.description }}
|
{{ labels.description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
|
<div v-if="requirements.length === 0" class="text-sm text-base-content/50 bg-base-200/60 rounded-md p-4">
|
||||||
{{ labels.emptyState }}
|
{{ labels.emptyState }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
@keydown.enter.prevent="selectHighlighted"
|
@keydown.enter.prevent="selectHighlighted"
|
||||||
@input="handleInput"
|
@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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="toggleButtonClasses"
|
:class="toggleButtonClasses"
|
||||||
@@ -32,11 +41,11 @@
|
|||||||
v-if="openDropdown"
|
v-if="openDropdown"
|
||||||
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
||||||
>
|
>
|
||||||
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-gray-500">
|
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-base-content/50">
|
||||||
<span class="loading loading-spinner loading-xs" />
|
<span class="loading loading-spinner loading-xs" />
|
||||||
Recherche en cours…
|
Recherche en cours…
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-gray-500">
|
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
|
||||||
{{ emptyText }}
|
{{ emptyText }}
|
||||||
</div>
|
</div>
|
||||||
<ul v-else class="flex flex-col">
|
<ul v-else class="flex flex-col">
|
||||||
@@ -60,7 +69,7 @@
|
|||||||
{{ resolveLabel(option) }}
|
{{ resolveLabel(option) }}
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="resolveDescription(option)" class="text-xs text-gray-500">
|
<span v-if="resolveDescription(option)" class="text-xs text-base-content/50">
|
||||||
<slot name="option-description" :option="option">
|
<slot name="option-description" :option="option">
|
||||||
{{ resolveDescription(option) }}
|
{{ resolveDescription(option) }}
|
||||||
</slot>
|
</slot>
|
||||||
@@ -77,6 +86,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -111,6 +121,10 @@ const props = defineProps({
|
|||||||
type: [String, Function],
|
type: [String, Function],
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
|
clearable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'md',
|
default: 'md',
|
||||||
@@ -155,7 +169,8 @@ const displayedOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const inputClasses = 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 === 'xs') base.push('input-xs')
|
||||||
if (props.size === 'sm') base.push('input-sm')
|
if (props.size === 'sm') base.push('input-sm')
|
||||||
if (props.size === 'lg') base.push('input-lg')
|
if (props.size === 'lg') base.push('input-lg')
|
||||||
@@ -269,9 +284,17 @@ function handleInput () {
|
|||||||
emit('search', searchTerm.value)
|
emit('search', searchTerm.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelection () {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
searchTerm.value = ''
|
||||||
|
openDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function closeDropdown () {
|
function closeDropdown () {
|
||||||
openDropdown.value = false
|
openDropdown.value = false
|
||||||
if (selectedOption.value) {
|
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
} else if (selectedOption.value) {
|
||||||
searchTerm.value = resolveLabel(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"
|
@blur="onBlur"
|
||||||
@focus="(event) => emit('focus', event)"
|
@focus="(event) => emit('focus', event)"
|
||||||
/>
|
/>
|
||||||
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
|
<p v-if="help" :id="helpId" class="mt-2 text-xs text-base-content/50">
|
||||||
{{ help }}
|
{{ help }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
<div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<!-- Mobile hamburger menu -->
|
<!-- Mobile hamburger menu -->
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
<div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
|
||||||
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
|
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
|
class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
|
||||||
>
|
>
|
||||||
<li class="pt-1 pb-2 lg:hidden">
|
<li class="pt-1 pb-2 lg:hidden">
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-2 rounded-md px-2 py-1 transition-colors text-base-content hover:bg-primary/10 hover:text-primary"
|
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
|
||||||
|
@click="toggleDarkMode"
|
||||||
|
>
|
||||||
|
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
|
||||||
|
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="pt-1 pb-2 lg:hidden">
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
|
||||||
@click="$emit('open-settings')"
|
@click="$emit('open-settings')"
|
||||||
>
|
>
|
||||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||||
@@ -24,9 +34,10 @@
|
|||||||
<li v-for="link in simpleLinks" :key="link.to">
|
<li v-for="link in simpleLinks" :key="link.to">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
class="rounded-md px-2 py-1 transition-colors"
|
class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
|
||||||
:class="linkClass(link)"
|
:class="linkClass(link)"
|
||||||
>
|
>
|
||||||
|
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -39,16 +50,19 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
|
||||||
:class="groupClass(group)"
|
:class="groupClass(group)"
|
||||||
:aria-expanded="openDropdown === group.id + '-mobile'"
|
:aria-expanded="openDropdown === group.id + '-mobile'"
|
||||||
@click="toggleDropdown(group.id + '-mobile')"
|
@click="toggleDropdown(group.id + '-mobile')"
|
||||||
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
||||||
@keydown.space.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
|
<IconLucideChevronRight
|
||||||
class="h-4 w-4 transition-transform"
|
class="h-3.5 w-3.5 transition-transform duration-200"
|
||||||
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -56,12 +70,12 @@
|
|||||||
<Transition name="nav-dropdown-mobile">
|
<Transition name="nav-dropdown-mobile">
|
||||||
<ul
|
<ul
|
||||||
v-if="openDropdown === group.id + '-mobile'"
|
v-if="openDropdown === group.id + '-mobile'"
|
||||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
|
||||||
>
|
>
|
||||||
<li v-for="child in group.children" :key="child.to">
|
<li v-for="child in group.children" :key="child.to">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="child.to"
|
:to="child.to"
|
||||||
class="rounded-md px-2 py-1 transition-colors block"
|
class="rounded-md px-3 py-1.5 transition-colors block text-sm"
|
||||||
:class="childLinkClass(child)"
|
:class="childLinkClass(child)"
|
||||||
>
|
>
|
||||||
{{ child.label }}
|
{{ child.label }}
|
||||||
@@ -77,32 +91,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex items-center space-x-3">
|
<NuxtLink to="/" class="flex items-center gap-2.5 group">
|
||||||
<div class="avatar">
|
<div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
|
||||||
<div class="w-14">
|
<img
|
||||||
<img
|
:src="logoSrc"
|
||||||
:src="logoSrc"
|
alt="Logo Malio"
|
||||||
alt="Logo Malio"
|
class="h-full w-full object-contain"
|
||||||
class="h-full w-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
<span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
|
||||||
Inventory
|
Inventory
|
||||||
</NuxtLink>
|
</span>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop navbar -->
|
<!-- Desktop navbar -->
|
||||||
<div class="navbar-center hidden lg:flex">
|
<div class="navbar-center hidden lg:flex">
|
||||||
<ul class="menu menu-horizontal px-1">
|
<ul class="menu menu-horizontal gap-0.5 px-1">
|
||||||
<!-- Desktop: simple links -->
|
<!-- Desktop: simple links -->
|
||||||
<li v-for="link in simpleLinks" :key="link.to">
|
<li v-for="link in simpleLinks" :key="link.to">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
class="transition-colors px-3 py-2 rounded-md"
|
class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
|
||||||
:class="linkClass(link)"
|
:class="linkClass(link)"
|
||||||
>
|
>
|
||||||
|
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -119,29 +132,30 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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)"
|
:class="groupClass(group)"
|
||||||
:aria-expanded="openDropdown === group.id + '-desktop'"
|
:aria-expanded="openDropdown === group.id + '-desktop'"
|
||||||
@click="toggleDropdown(group.id + '-desktop')"
|
@click="toggleDropdown(group.id + '-desktop')"
|
||||||
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
||||||
@keydown.space.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 }}
|
{{ group.label }}
|
||||||
<IconLucideChevronRight
|
<IconLucideChevronDown
|
||||||
class="h-4 w-4 transition-transform"
|
class="h-3.5 w-3.5 transition-transform duration-200"
|
||||||
:class="openDropdown === group.id + '-desktop' ? 'rotate-90' : ''"
|
:class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<Transition name="nav-dropdown-desktop">
|
<Transition name="nav-dropdown-desktop">
|
||||||
<ul
|
<ul
|
||||||
v-if="openDropdown === group.id + '-desktop'"
|
v-if="openDropdown === group.id + '-desktop'"
|
||||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
|
||||||
>
|
>
|
||||||
<li v-for="child in group.children" :key="child.to">
|
<li v-for="child in group.children" :key="child.to">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="child.to"
|
:to="child.to"
|
||||||
class="block rounded-md px-2 py-1 transition-colors"
|
class="block rounded-lg px-3 py-2 transition-all text-sm"
|
||||||
:class="childLinkClass(child)"
|
:class="childLinkClass(child)"
|
||||||
>
|
>
|
||||||
{{ child.label }}
|
{{ child.label }}
|
||||||
@@ -158,13 +172,21 @@
|
|||||||
|
|
||||||
<!-- Navbar end -->
|
<!-- Navbar end -->
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-circle hidden lg:inline-flex"
|
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
|
||||||
|
:title="isDark ? 'Mode clair' : 'Mode sombre'"
|
||||||
|
@click="toggleDarkMode"
|
||||||
|
>
|
||||||
|
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
|
||||||
title="Paramètres d'affichage"
|
title="Paramètres d'affichage"
|
||||||
@click="$emit('open-settings')"
|
@click="$emit('open-settings')"
|
||||||
>
|
>
|
||||||
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
|
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
@@ -172,7 +194,7 @@
|
|||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
class="btn btn-ghost btn-circle avatar placeholder indicator"
|
class="indicator cursor-pointer"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="unresolvedCount > 0"
|
v-if="unresolvedCount > 0"
|
||||||
@@ -181,47 +203,49 @@
|
|||||||
{{ unresolvedCount }}
|
{{ unresolvedCount }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span
|
<span class="text-xs font-semibold">
|
||||||
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
|
|
||||||
>
|
|
||||||
{{ activeProfileInitials }}
|
{{ activeProfileInitials }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64"
|
class="menu dropdown-content mt-3 p-2 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
|
||||||
>
|
>
|
||||||
<li class="px-2 py-1 text-sm text-base-content/70">
|
<li class="px-3 py-2">
|
||||||
Connecté en tant que<br />
|
<div class="flex flex-col gap-1 pointer-events-none">
|
||||||
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
<span class="text-xs text-base-content/50">Connecté en tant que</span>
|
||||||
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
<span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
|
||||||
|
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<div class="divider my-0.5 px-2" />
|
||||||
<li v-if="isAdmin">
|
<li v-if="isAdmin">
|
||||||
<NuxtLink to="/admin" class="justify-between">
|
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
|
||||||
Administration
|
Administration
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/comments" class="justify-between">
|
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
|
||||||
Commentaires
|
Commentaires
|
||||||
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||||
{{ unresolvedCount }}
|
{{ unresolvedCount }}
|
||||||
</span>
|
</span>
|
||||||
<IconLucideChevronRight v-else class="w-4 h-4" aria-hidden="true" />
|
<IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<div class="divider my-0.5 px-2" />
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-error justify-between"
|
class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
|
||||||
@click="$emit('logout')"
|
@click="$emit('logout')"
|
||||||
>
|
>
|
||||||
Déconnexion
|
Déconnexion
|
||||||
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" />
|
<IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -233,7 +257,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute } from '#imports'
|
||||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||||
import { usePermissions } from '~/composables/usePermissions'
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
@@ -242,7 +266,17 @@ import { useComments } from '~/composables/useComments'
|
|||||||
import IconLucideMenu from '~icons/lucide/menu'
|
import IconLucideMenu from '~icons/lucide/menu'
|
||||||
import IconLucideSettings from '~icons/lucide/settings'
|
import IconLucideSettings from '~icons/lucide/settings'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||||
|
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||||
|
import 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'
|
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -253,25 +287,37 @@ defineEmits<{
|
|||||||
interface NavLink {
|
interface NavLink {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
|
icon?: Component
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
icon?: Component
|
||||||
activePaths: string[]
|
activePaths: string[]
|
||||||
children: NavLink[]
|
children: NavLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const simpleLinks: NavLink[] = [
|
const simpleLinks: NavLink[] = [
|
||||||
{ to: '/', label: 'Vue d\'ensemble' },
|
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||||
{ to: '/machines', label: 'Parc Machines' },
|
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||||
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const navGroups: NavGroup[] = [
|
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',
|
id: 'pieces',
|
||||||
label: 'Pièces',
|
label: 'Pièces',
|
||||||
|
icon: IconLucidePuzzle,
|
||||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||||
@@ -281,24 +327,17 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: 'products',
|
id: 'products',
|
||||||
label: 'Produits',
|
label: 'Produits',
|
||||||
|
icon: IconLucidePackage,
|
||||||
activePaths: ['/product-category', '/product-catalog'],
|
activePaths: ['/product-category', '/product-catalog'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
{ 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',
|
id: 'resources',
|
||||||
label: 'Ressources liées',
|
label: 'Ressources liées',
|
||||||
|
icon: IconLucideLink,
|
||||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/sites', label: 'Sites' },
|
{ to: '/sites', label: 'Sites' },
|
||||||
@@ -315,6 +354,7 @@ const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = use
|
|||||||
const { activeProfile } = useProfileSession()
|
const { activeProfile } = useProfileSession()
|
||||||
const { isAdmin, canEdit } = usePermissions()
|
const { isAdmin, canEdit } = usePermissions()
|
||||||
const { fetchUnresolvedCount } = useComments()
|
const { fetchUnresolvedCount } = useComments()
|
||||||
|
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
||||||
|
|
||||||
const unresolvedCount = ref(0)
|
const unresolvedCount = ref(0)
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
@@ -325,6 +365,7 @@ const refreshUnresolvedCount = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
initDarkMode()
|
||||||
refreshUnresolvedCount()
|
refreshUnresolvedCount()
|
||||||
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||||
})
|
})
|
||||||
@@ -347,19 +388,19 @@ const isGroupActive = (group: NavGroup) => {
|
|||||||
const linkClass = (link: NavLink) => {
|
const linkClass = (link: NavLink) => {
|
||||||
return isActive(link.to)
|
return isActive(link.to)
|
||||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupClass = (group: NavGroup) => {
|
const groupClass = (group: NavGroup) => {
|
||||||
return isGroupActive(group)
|
return isGroupActive(group)
|
||||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
||||||
}
|
}
|
||||||
|
|
||||||
const childLinkClass = (child: NavLink) => {
|
const childLinkClass = (child: NavLink) => {
|
||||||
return isActive(child.to)
|
return isActive(child.to)
|
||||||
? 'bg-primary/10 text-primary font-semibold'
|
? 'bg-primary/10 text-primary font-semibold'
|
||||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
const roleLabel = computed(() => {
|
||||||
@@ -400,12 +441,12 @@ const activeProfileInitials = computed(() => {
|
|||||||
.nav-dropdown-desktop-enter-from,
|
.nav-dropdown-desktop-enter-from,
|
||||||
.nav-dropdown-desktop-leave-to {
|
.nav-dropdown-desktop-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(0.25rem);
|
transform: translateY(4px) scale(0.98);
|
||||||
}
|
}
|
||||||
.nav-dropdown-desktop-enter-to,
|
.nav-dropdown-desktop-enter-to,
|
||||||
.nav-dropdown-desktop-leave-from {
|
.nav-dropdown-desktop-leave-from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown-mobile-enter-active,
|
.nav-dropdown-mobile-enter-active,
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Composants</h2>
|
<h2 class="card-title">Composants</h2>
|
||||||
@@ -20,15 +20,32 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComponentHierarchy
|
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
||||||
:components="components"
|
Aucun composant associé à cette machine.
|
||||||
:is-edit-mode="isEditMode"
|
</div>
|
||||||
:collapse-all="collapsed"
|
|
||||||
:toggle-token="collapseToggleToken"
|
<div v-else class="space-y-2">
|
||||||
@update="$emit('update-component', $event)"
|
<div v-for="component in components" :key="component.id">
|
||||||
@edit-piece="$emit('edit-piece', $event)"
|
<ComponentHierarchy
|
||||||
@custom-field-update="$emit('custom-field-update', $event)"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,5 +66,7 @@ defineEmits<{
|
|||||||
'update-component': [component: any]
|
'update-component': [component: any]
|
||||||
'edit-piece': [piece: any]
|
'edit-piece': [piece: any]
|
||||||
'custom-field-update': [fieldUpdate: any]
|
'custom-field-update': [fieldUpdate: any]
|
||||||
|
'add-component': []
|
||||||
|
'remove-component': [linkId: string]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</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">
|
<h1 class="text-3xl font-bold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h1>
|
</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>
|
||||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||||
<button
|
<button
|
||||||
@@ -43,7 +24,7 @@
|
|||||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="isDetailsView && !isEditMode"
|
v-if="!isEditMode"
|
||||||
@click="$emit('open-print')"
|
@click="$emit('open-print')"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline btn-secondary"
|
class="btn btn-outline btn-secondary"
|
||||||
@@ -60,18 +41,12 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
|
|||||||
import IconLucideEye from '~icons/lucide/eye'
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
import IconLucidePrinter from '~icons/lucide/printer'
|
import IconLucidePrinter from '~icons/lucide/printer'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
isDetailsView: boolean
|
|
||||||
isSkeletonView: boolean
|
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
hasSkeletonRequirements: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'change-view': [view: 'details' | 'skeleton']
|
|
||||||
'toggle-edit': []
|
'toggle-edit': []
|
||||||
'open-print': []
|
'open-print': []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg mt-6">
|
<div class="card bg-base-100 shadow-sm mt-6">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
:class="documentThumbnailClass(doc)"
|
:class="documentThumbnailClass(doc)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="isImageDocument(doc) && doc.path"
|
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
|
||||||
:src="doc.path"
|
:src="doc.fileUrl || doc.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${doc.name}`"
|
:alt="`Aperçu de ${doc.name}`"
|
||||||
>
|
>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
@click="$emit('remove', doc.id)"
|
@click="$emit('remove', doc.id)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">Informations de la machine</h2>
|
<h2 class="card-title tracking-tight">Informations de la machine</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -20,6 +20,29 @@
|
|||||||
{{ machineName }}
|
{{ machineName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Site</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-if="isEditMode"
|
||||||
|
:value="machineSiteId"
|
||||||
|
class="select select-bordered"
|
||||||
|
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un site</option>
|
||||||
|
<option
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
:value="site.id"
|
||||||
|
>
|
||||||
|
{{ site.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-else class="input input-bordered bg-base-200">
|
||||||
|
{{ machineSiteName || 'Non défini' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="isEditMode || machineReference" class="form-control">
|
<div v-if="isEditMode || machineReference" class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Référence</span>
|
<span class="label-text">Référence</span>
|
||||||
@@ -49,30 +72,30 @@
|
|||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
@update:modelValue="$emit('update:constructeur-ids', $event)"
|
@update:modelValue="$emit('update:constructeur-ids', $event)"
|
||||||
/>
|
/>
|
||||||
<div v-else class="input input-bordered bg-base-200">
|
<div v-else class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||||
<div v-if="machineConstructeursDisplay.length" class="space-y-1">
|
<div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2">
|
||||||
<div
|
<span
|
||||||
v-for="constructeur in machineConstructeursDisplay"
|
v-for="constructeur in machineConstructeursDisplay"
|
||||||
:key="constructeur.id"
|
:key="constructeur.id"
|
||||||
class="flex flex-col"
|
class="badge badge-ghost gap-1"
|
||||||
>
|
>
|
||||||
<span class="font-medium">{{ constructeur.name }}</span>
|
{{ constructeur.name }}
|
||||||
<span
|
<span
|
||||||
v-if="formatConstructeurContactSummary(constructeur)"
|
v-if="formatConstructeurContactSummary(constructeur)"
|
||||||
class="text-xs text-gray-500"
|
class="text-xs opacity-60"
|
||||||
>
|
>
|
||||||
{{ formatConstructeurContactSummary(constructeur) }}
|
· {{ formatConstructeurContactSummary(constructeur) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">Non défini</span>
|
<span v-else class="text-base-content/50">Non défini</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés -->
|
<!-- Champs personnalisés -->
|
||||||
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-gray-200">
|
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-base-200">
|
||||||
<h4 class="font-semibold text-gray-700 mb-3">Champs personnalisés de la machine</h4>
|
<h4 class="font-semibold text-base-content/80 mb-3">Champs personnalisés de la machine</h4>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="field in visibleCustomFields"
|
v-for="field in visibleCustomFields"
|
||||||
@@ -151,34 +174,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||||
import {
|
import {
|
||||||
formatConstructeurContact as formatConstructeurContactSummary,
|
formatConstructeurContact as formatConstructeurContactSummary,
|
||||||
} from '~/shared/constructeurUtils'
|
} from '~/shared/constructeurUtils'
|
||||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||||
|
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
machineName: string
|
machineName: string
|
||||||
machineReference: string
|
machineReference: string
|
||||||
|
machineSiteId: string
|
||||||
|
machineSiteName: string
|
||||||
|
sites: any[]
|
||||||
machineConstructeurIds: string[]
|
machineConstructeurIds: string[]
|
||||||
machineConstructeursDisplay: any[]
|
machineConstructeursDisplay: any[]
|
||||||
hasMachineConstructeur: boolean
|
hasMachineConstructeur: boolean
|
||||||
visibleCustomFields: any[]
|
visibleCustomFields: any[]
|
||||||
getMachineFieldId: (fieldName: string) => string
|
getMachineFieldId: (fieldName: string) => string
|
||||||
|
machineId: string
|
||||||
|
machineCustomFieldDefs: any[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:machine-name': [value: string]
|
'update:machine-name': [value: string]
|
||||||
'update:machine-reference': [value: string]
|
'update:machine-reference': [value: string]
|
||||||
|
'update:machine-site-id': [value: string]
|
||||||
'update:constructeur-ids': [ids: unknown]
|
'update:constructeur-ids': [ids: unknown]
|
||||||
'blur-field': []
|
'blur-field': []
|
||||||
'set-custom-field-value': [field: any, value: unknown]
|
'set-custom-field-value': [field: any, value: unknown]
|
||||||
'update-custom-field': [field: any]
|
'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>
|
</script>
|
||||||
|
|||||||
@@ -1,34 +1,71 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Pièces de la machine</h2>
|
<h2 class="card-title">Pièces de la machine</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
@click="$emit('toggle-collapse')"
|
||||||
|
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
||||||
|
>
|
||||||
|
<IconLucideChevronRight
|
||||||
|
class="w-5 h-5 transition-transform"
|
||||||
|
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
||||||
<PieceItem
|
Aucune pièce associée à cette machine.
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
pieces: any[]
|
pieces: any[]
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
|
collapsed: boolean
|
||||||
|
collapseToggleToken: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
|
'toggle-collapse': []
|
||||||
'update-piece': [piece: any]
|
'update-piece': [piece: any]
|
||||||
'edit-piece': [piece: any]
|
'edit-piece': [piece: any]
|
||||||
'custom-field-update': [fieldUpdate: any]
|
'add-piece': []
|
||||||
|
'remove-piece': [linkId: string]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
|
<DocumentPreviewModal
|
||||||
|
:document="previewDocument"
|
||||||
|
:visible="previewVisible"
|
||||||
|
:documents="previewDocumentList"
|
||||||
|
@close="closePreview"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title">Produits associés</h2>
|
<h2 class="card-title">Produits associés</h2>
|
||||||
<p class="text-xs text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge badge-outline" v-if="products.length">
|
<span class="badge badge-outline" v-if="products.length">
|
||||||
@@ -17,15 +24,26 @@
|
|||||||
<div
|
<div
|
||||||
v-for="product in products"
|
v-for="product in products"
|
||||||
:key="product.id || product.name"
|
:key="product.id || product.name"
|
||||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-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">
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
<p class="font-semibold text-base-content">
|
<p class="font-semibold text-base-content">
|
||||||
{{ product.name }}
|
{{ product.name }}
|
||||||
</p>
|
</p>
|
||||||
<span class="badge badge-ghost badge-sm">
|
<div class="flex items-center gap-2">
|
||||||
{{ product.groupLabel }}
|
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||||
</span>
|
{{ product.groupLabel }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="isEditMode"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
title="Supprimer ce produit"
|
||||||
|
@click="$emit('remove-product', (product.linkId || product.id) as string)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="product.reference" class="text-xs text-base-content/70">
|
<p v-if="product.reference" class="text-xs text-base-content/70">
|
||||||
<span class="font-medium">Référence :</span>
|
<span class="font-medium">Référence :</span>
|
||||||
@@ -39,24 +57,119 @@
|
|||||||
<span class="font-medium">Prix indicatif :</span>
|
<span class="font-medium">Prix indicatif :</span>
|
||||||
<span class="ml-1">{{ product.priceLabel }}</span>
|
<span class="ml-1">{{ product.priceLabel }}</span>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-gray-500">
|
<p v-else class="text-xs text-gray-500">
|
||||||
Aucun produit n'a été associé directement à cette machine.
|
Aucun produit n'a été associé directement à cette machine.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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<{
|
defineProps<{
|
||||||
products: Array<{
|
products: Array<{
|
||||||
id?: string | null
|
id?: string | null
|
||||||
|
linkId?: string | null
|
||||||
name?: string
|
name?: string
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
supplierLabel?: string | null
|
supplierLabel?: string | null
|
||||||
priceLabel?: string | null
|
priceLabel?: string | null
|
||||||
groupLabel?: string
|
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>
|
</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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ModelTypesToolbar
|
<nav
|
||||||
:category="selectedCategory"
|
v-if="allowCategorySwitch"
|
||||||
:search="searchInput"
|
class="tabs tabs-boxed inline-flex"
|
||||||
:sort="sort"
|
role="tablist"
|
||||||
:dir="dir"
|
aria-label="Catégories"
|
||||||
:loading="loading"
|
>
|
||||||
:show-category-tabs="allowCategorySwitch"
|
<button
|
||||||
:can-edit="canEdit"
|
v-for="option in categories"
|
||||||
@update:category="onCategoryChange"
|
:key="option.value"
|
||||||
@update:search="onSearchInput"
|
type="button"
|
||||||
@update:sort="onSortChange"
|
class="tab"
|
||||||
@update:dir="onDirChange"
|
:class="{ 'tab-active': option.value === selectedCategory }"
|
||||||
@create="openCreatePage"
|
role="tab"
|
||||||
/>
|
:aria-selected="option.value === selectedCategory"
|
||||||
|
@click="onCategoryChange(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<ModelTypesTable
|
<DataTable
|
||||||
:items="items"
|
:columns="columns"
|
||||||
|
:rows="items"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:total="total"
|
:sort="currentSort"
|
||||||
:limit="limit"
|
:pagination="paginationState"
|
||||||
:offset="offset"
|
:show-per-page="true"
|
||||||
:category="selectedCategory"
|
row-key="id"
|
||||||
:can-edit="canEdit"
|
empty-message="Aucune catégorie trouvée."
|
||||||
@related="openRelatedModal"
|
no-results-message="Aucune catégorie ne correspond à votre recherche."
|
||||||
@edit="openEditPage"
|
@sort="handleSort"
|
||||||
@delete="confirmDelete"
|
@update:current-page="handlePageChange"
|
||||||
@convert="openConversionModal"
|
@update:per-page="handlePerPageChange"
|
||||||
@update:offset="onOffsetChange"
|
>
|
||||||
/>
|
<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"
|
:open="conversionModalOpen"
|
||||||
:model-type="conversionTarget"
|
:model-type="conversionTarget"
|
||||||
@close="closeConversionModal"
|
@close="closeConversionModal"
|
||||||
@converted="onConverted"
|
@converted="onConverted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
<RelatedItemsModal
|
||||||
<div class="modal-box max-w-3xl">
|
:open="relatedModalOpen"
|
||||||
<h3 class="text-lg font-bold text-base-content">
|
:model-type="relatedType"
|
||||||
{{ relatedModalTitle }}
|
@close="relatedModalOpen = false"
|
||||||
</h3>
|
@open-edit="openRelatedEdit"
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from '#imports'
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||||
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import { useApi } from "~/composables/useApi";
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
import { useUrlState } from "~/composables/useUrlState";
|
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
type ModelCategory,
|
type ModelCategory,
|
||||||
type ModelType,
|
type ModelType,
|
||||||
type ModelTypeListResponse,
|
type ModelTypeListResponse,
|
||||||
} from "~/services/modelTypes";
|
} from '~/services/modelTypes'
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from '~/composables/useToast'
|
||||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
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 =
|
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.";
|
= '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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
category: ModelCategory;
|
category: ModelCategory
|
||||||
heading: string;
|
heading: string
|
||||||
description?: string;
|
description?: string
|
||||||
allowCategorySwitch?: boolean;
|
allowCategorySwitch?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
allowCategorySwitch: false,
|
allowCategorySwitch: false,
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
const selectedCategory = ref<ModelCategory>(props.category);
|
const selectedCategory = ref<ModelCategory>(props.category)
|
||||||
const searchInput = ref("");
|
const searchInput = ref('')
|
||||||
|
|
||||||
// State synced with URL query params (preserved on back/forward navigation)
|
// State synced with URL query params
|
||||||
const urlState = useUrlState({
|
const urlState = useUrlState({
|
||||||
q: { default: '' },
|
q: { default: '' },
|
||||||
sort: { default: 'name' },
|
sort: { default: 'name' },
|
||||||
@@ -148,81 +162,125 @@ const urlState = useUrlState({
|
|||||||
offset: { default: 0, type: 'number' },
|
offset: { default: 0, type: 'number' },
|
||||||
}, {
|
}, {
|
||||||
onRestore: () => {
|
onRestore: () => {
|
||||||
searchInput.value = urlState.q.value;
|
searchInput.value = urlState.q.value
|
||||||
refresh();
|
doRefresh()
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
const searchTerm = urlState.q;
|
const searchTerm = urlState.q
|
||||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
|
const sort = urlState.sort as Ref<'name' | 'createdAt'>
|
||||||
const dir = urlState.dir as Ref<'asc' | 'desc'>;
|
const dir = urlState.dir as Ref<'asc' | 'desc'>
|
||||||
const limit = urlState.limit;
|
const limit = urlState.limit
|
||||||
const offset = urlState.offset;
|
const offset = urlState.offset
|
||||||
|
|
||||||
// Initialize searchInput from URL (for direct navigation with ?q=...)
|
// Initialize searchInput from URL
|
||||||
searchInput.value = searchTerm.value;
|
searchInput.value = searchTerm.value
|
||||||
|
|
||||||
const items = ref<ModelType[]>([]);
|
const items = ref<ModelType[]>([])
|
||||||
const total = ref(0);
|
const total = ref(0)
|
||||||
const loading = ref(false);
|
const loading = ref(false)
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let activeController: AbortController | null = null;
|
let activeController: AbortController | null = null
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast()
|
||||||
const { get } = useApi();
|
const { canEdit } = usePermissions()
|
||||||
const { canEdit } = usePermissions();
|
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading)
|
||||||
const descriptionText = computed(
|
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
|
||||||
() => props.description ?? DEFAULT_DESCRIPTION
|
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
|
||||||
);
|
|
||||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
|
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({ title: headingText.value }))
|
||||||
title: headingText.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown) => {
|
const columns = [
|
||||||
if (error && typeof error === "object") {
|
{ 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 {
|
const maybeFetchError = error as {
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>
|
||||||
statusMessage?: string;
|
statusMessage?: string
|
||||||
message?: string;
|
message?: string
|
||||||
};
|
}
|
||||||
if (maybeFetchError.data) {
|
if (maybeFetchError.data) {
|
||||||
const data = maybeFetchError.data;
|
const data = maybeFetchError.data
|
||||||
if (typeof data.message === "string") {
|
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||||
return data.message;
|
else if (typeof data.detail === 'string') raw = data.detail
|
||||||
}
|
else if (typeof data.message === 'string') raw = data.message
|
||||||
if (Array.isArray(data.message) && data.message.length > 0) {
|
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
|
||||||
return data.message[0];
|
else if (typeof data.error === 'string') raw = data.error
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.statusMessage === "string") {
|
|
||||||
return maybeFetchError.statusMessage;
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.message === "string") {
|
|
||||||
return maybeFetchError.message;
|
|
||||||
}
|
}
|
||||||
|
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 ({
|
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
|
||||||
resetOffset = false,
|
if (resetOffset) offset.value = 0
|
||||||
}: { resetOffset?: boolean } = {}) => {
|
|
||||||
if (resetOffset) {
|
|
||||||
offset.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeController) {
|
if (activeController) activeController.abort()
|
||||||
activeController.abort();
|
const controller = new AbortController()
|
||||||
}
|
activeController = controller
|
||||||
|
loading.value = true
|
||||||
const controller = new AbortController();
|
|
||||||
activeController = controller;
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: ModelTypeListResponse = await listModelTypes(
|
const response: ModelTypeListResponse = await listModelTypes(
|
||||||
@@ -234,312 +292,147 @@ const refresh = async ({
|
|||||||
limit: limit.value,
|
limit: limit.value,
|
||||||
offset: offset.value,
|
offset: offset.value,
|
||||||
},
|
},
|
||||||
{ signal: controller.signal }
|
{ signal: controller.signal },
|
||||||
);
|
)
|
||||||
|
items.value = response.items
|
||||||
items.value = response.items;
|
total.value = response.total
|
||||||
total.value = response.total;
|
offset.value = response.offset
|
||||||
offset.value = response.offset;
|
limit.value = response.limit
|
||||||
limit.value = response.limit;
|
}
|
||||||
} catch (error: unknown) {
|
catch (error: unknown) {
|
||||||
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
|
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||||
return;
|
showError(extractErrorMessage(error))
|
||||||
}
|
}
|
||||||
showError(extractErrorMessage(error));
|
finally {
|
||||||
} finally {
|
|
||||||
if (activeController === controller) {
|
if (activeController === controller) {
|
||||||
loading.value = false;
|
loading.value = false
|
||||||
activeController = null;
|
activeController = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.category,
|
() => props.category,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value !== selectedCategory.value) {
|
if (value !== selectedCategory.value) {
|
||||||
selectedCategory.value = value;
|
selectedCategory.value = value
|
||||||
refresh({ resetOffset: true });
|
doRefresh({ resetOffset: true })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
const onSearchInput = (value: string) => {
|
|
||||||
searchInput.value = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCategoryChange = (value: ModelCategory) => {
|
const onCategoryChange = (value: ModelCategory) => {
|
||||||
if (!allowCategorySwitch.value) {
|
if (!props.allowCategorySwitch) return
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedCategory.value !== value) {
|
if (selectedCategory.value !== value) {
|
||||||
selectedCategory.value = value;
|
selectedCategory.value = value
|
||||||
refresh({ resetOffset: true });
|
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) => {
|
const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||||
if (category === "COMPONENT") {
|
if (category === 'COMPONENT') return '/component-category'
|
||||||
return "/component-category";
|
if (category === 'PIECE') return '/piece-category'
|
||||||
}
|
return '/product-category'
|
||||||
if (category === "PIECE") {
|
}
|
||||||
return "/piece-category";
|
|
||||||
}
|
|
||||||
return "/product-category";
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreatePage = () => {
|
const openCreatePage = () => {
|
||||||
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
||||||
router.push(`${basePath}/new`).catch(() => {
|
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 openEditPage = (item: ModelType) => {
|
||||||
const category = item.category ?? selectedCategory.value;
|
const category = item.category ?? selectedCategory.value
|
||||||
const basePath = resolveCategoryBasePath(category);
|
const basePath = resolveCategoryBasePath(category)
|
||||||
router.push(`${basePath}/${item.id}/edit`).catch(() => {
|
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 { confirm } = useConfirm()
|
||||||
|
|
||||||
const confirmDelete = async (item: ModelType) => {
|
const confirmDelete = async (item: ModelType) => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||||
});
|
})
|
||||||
if (!confirmed) {
|
if (!confirmed) return
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteModelType(item.id);
|
await deleteModelType(item.id)
|
||||||
invalidateEntityTypeCache(item.category);
|
invalidateEntityTypeCache(item.category)
|
||||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
showSuccess(`Type « ${item.name} » supprimé avec succès.`)
|
||||||
|
|
||||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
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 doRefresh()
|
||||||
await refresh();
|
|
||||||
} catch (error) {
|
|
||||||
showError(extractErrorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
catch (error) {
|
||||||
|
showError(extractErrorMessage(error))
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
return `Éléments liés à « ${current.name} »`;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const relatedModalSubtitle = computed(() => {
|
const relatedModalOpen = ref(false)
|
||||||
const current = relatedType.value;
|
const relatedType = ref<ModelType | null>(null)
|
||||||
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 resolveRelatedEditBasePath = (category: ModelCategory) => {
|
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||||
if (category === "COMPONENT") {
|
if (category === 'COMPONENT') return '/component'
|
||||||
return "/component";
|
if (category === 'PIECE') return '/pieces'
|
||||||
}
|
return '/product'
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRelatedModal = (item: ModelType) => {
|
const openRelatedModal = (item: ModelType) => {
|
||||||
relatedType.value = item;
|
relatedType.value = item
|
||||||
relatedModalOpen.value = true;
|
relatedModalOpen.value = true
|
||||||
void loadRelatedItems(item);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
const openRelatedEdit = (entry: { id: string }) => {
|
||||||
const current = relatedType.value;
|
const current = relatedType.value
|
||||||
if (!current) {
|
if (!current) return
|
||||||
return;
|
const basePath = resolveRelatedEditBasePath(current.category)
|
||||||
}
|
relatedModalOpen.value = false
|
||||||
const basePath = resolveRelatedEditBasePath(current.category);
|
|
||||||
relatedModalOpen.value = false;
|
|
||||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
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 = () => {
|
const conversionModalOpen = ref(false)
|
||||||
relatedModalOpen.value = false;
|
const conversionTarget = ref<ModelType | null>(null)
|
||||||
};
|
|
||||||
|
|
||||||
const conversionModalOpen = ref(false);
|
|
||||||
const conversionTarget = ref<ModelType | null>(null);
|
|
||||||
|
|
||||||
const openConversionModal = (item: ModelType) => {
|
const openConversionModal = (item: ModelType) => {
|
||||||
conversionTarget.value = item;
|
conversionTarget.value = item
|
||||||
conversionModalOpen.value = true;
|
conversionModalOpen.value = true
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeConversionModal = () => {
|
const closeConversionModal = () => {
|
||||||
conversionModalOpen.value = false;
|
conversionModalOpen.value = false
|
||||||
};
|
}
|
||||||
|
|
||||||
const onConverted = () => {
|
const onConverted = () => {
|
||||||
conversionModalOpen.value = false;
|
conversionModalOpen.value = false
|
||||||
invalidateEntityTypeCache("PIECE");
|
invalidateEntityTypeCache('PIECE')
|
||||||
invalidateEntityTypeCache("COMPONENT");
|
invalidateEntityTypeCache('COMPONENT')
|
||||||
showSuccess("Catégorie convertie avec succès.");
|
showSuccess('Catégorie convertie avec succès.')
|
||||||
refresh();
|
doRefresh()
|
||||||
};
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
}
|
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
searchTerm.value = value.trim();
|
searchTerm.value = value.trim()
|
||||||
refresh({ resetOffset: true });
|
doRefresh({ resetOffset: true })
|
||||||
}, 300);
|
}, 300)
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refresh();
|
doRefresh()
|
||||||
});
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
clearTimeout(debounceTimer);
|
if (activeController) activeController.abort()
|
||||||
}
|
})
|
||||||
if (activeController) {
|
|
||||||
activeController.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
|
<div
|
||||||
|
class="card site-card shadow-md hover:shadow-xl transition-shadow overflow-hidden"
|
||||||
|
:style="{
|
||||||
|
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
|
||||||
|
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="card-title text-lg">
|
<h3 class="card-title text-lg text-base-content">
|
||||||
{{ site.name }}
|
{{ site.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="badge badge-primary badge-sm">
|
<div
|
||||||
|
class="badge font-bold"
|
||||||
|
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
||||||
|
:class="!site.color ? 'badge-primary' : ''"
|
||||||
|
>
|
||||||
{{ machineCount }} machines
|
{{ machineCount }} machines
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 text-sm">
|
<div class="space-y-3 text-sm">
|
||||||
<div class="flex items-center gap-2 text-gray-700">
|
<div class="flex items-center gap-2 text-base-content/80">
|
||||||
<IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" />
|
<IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
<span class="font-medium">{{ site.contactName }}</span>
|
<span class="font-medium">{{ site.contactName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-gray-600">
|
<div class="flex items-center gap-2 text-base-content/60">
|
||||||
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
|
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
|
||||||
<span>{{ formattedContactPhone }}</span>
|
<span>{{ formattedContactPhone }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start gap-2 text-gray-600">
|
<div class="flex items-start gap-2 text-base-content/60">
|
||||||
<IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" />
|
<IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
{{ site.contactAddress }}<br>
|
{{ site.contactAddress }}<br>
|
||||||
@@ -29,7 +39,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-gray-600">
|
<div class="flex items-center gap-2 text-base-content/60">
|
||||||
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
||||||
<span>{{ machineCount }} machine(s)</span>
|
<span>{{ machineCount }} machine(s)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,46 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Couleur</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="siteRef.color" class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
:value="siteRef.color"
|
||||||
|
type="color"
|
||||||
|
class="w-10 h-10 rounded cursor-pointer border border-base-300"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="(e: Event) => { siteRef.color = (e.target as HTMLInputElement).value }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="siteRef.color"
|
||||||
|
type="text"
|
||||||
|
placeholder="#000000"
|
||||||
|
class="input input-bordered input-sm flex-1"
|
||||||
|
:disabled="disabled"
|
||||||
|
maxlength="7"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="siteRef.color = ''"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm w-fit"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="siteRef.color = '#3b82f6'"
|
||||||
|
>
|
||||||
|
Choisir une couleur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
@@ -39,6 +79,7 @@ import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
|||||||
|
|
||||||
type SiteForm = {
|
type SiteForm = {
|
||||||
name: string
|
name: string
|
||||||
|
color: string
|
||||||
contactName: string
|
contactName: string
|
||||||
contactPhone: string
|
contactPhone: string
|
||||||
contactAddress: string
|
contactAddress: string
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="modal-box max-w-md">
|
<div class="modal-box max-w-md">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
|
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
|
||||||
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
<span v-if="siteName" class="block text-sm font-normal text-base-content/50">{{ siteName }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="emit('submit')">
|
<form class="space-y-4" @submit.prevent="emit('submit')">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@@ -20,6 +20,46 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Couleur</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="form.color" class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
:value="form.color"
|
||||||
|
type="color"
|
||||||
|
class="w-10 h-10 rounded cursor-pointer border border-base-300"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="form.color = $event.target.value"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.color"
|
||||||
|
type="text"
|
||||||
|
placeholder="#000000"
|
||||||
|
class="input input-bordered input-sm flex-1"
|
||||||
|
:disabled="disabled"
|
||||||
|
maxlength="7"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="form.color = ''"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm w-fit"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="form.color = '#3b82f6'"
|
||||||
|
>
|
||||||
|
Choisir une couleur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||||
@@ -28,7 +68,7 @@
|
|||||||
<h4 class="font-semibold text-sm">
|
<h4 class="font-semibold text-sm">
|
||||||
Documents liés
|
Documents liés
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-base-content/50">
|
||||||
Ajoutez des documents (PDF, images...) relatifs à ce site.
|
Ajoutez des documents (PDF, images...) relatifs à ce site.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,8 +97,8 @@
|
|||||||
<div class="flex items-center gap-3 text-sm">
|
<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">
|
<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
|
<img
|
||||||
v-if="isImageDocument(document) && document.path"
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
:src="document.path"
|
:src="document.fileUrl || document.path"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
:alt="`Aperçu de ${document.name}`"
|
:alt="`Aperçu de ${document.name}`"
|
||||||
>
|
>
|
||||||
@@ -74,7 +114,7 @@
|
|||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
{{ document.name }}
|
{{ document.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-base-content/50">
|
||||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +156,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, toRefs } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { isImageDocument } from '~/utils/documentPreview'
|
import { isImageDocument } from '~/utils/documentPreview'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||||
@@ -173,8 +213,6 @@ const emit = defineEmits([
|
|||||||
'update:selectedFiles'
|
'update:selectedFiles'
|
||||||
])
|
])
|
||||||
|
|
||||||
const form = toRefs(props.form)
|
|
||||||
|
|
||||||
const selectedFilesModel = computed({
|
const selectedFilesModel = computed({
|
||||||
get: () => props.selectedFiles,
|
get: () => props.selectedFiles,
|
||||||
set: value => emit('update:selectedFiles', value)
|
set: value => emit('update:selectedFiles', value)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -20,11 +21,10 @@ export function useApi() {
|
|||||||
|
|
||||||
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
||||||
const url = `${API_BASE_URL}${endpoint}`
|
const url = `${API_BASE_URL}${endpoint}`
|
||||||
|
const isFormData = options.body instanceof FormData
|
||||||
const defaultOptions: ApiCallOptions = {
|
const defaultOptions: ApiCallOptions = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter un timeout à la requête
|
// Ajouter un timeout à la requête
|
||||||
@@ -60,23 +60,26 @@ export function useApi() {
|
|||||||
} else {
|
} else {
|
||||||
const contentType = response.headers.get('content-type') || ''
|
const contentType = response.headers.get('content-type') || ''
|
||||||
let errorData: Record<string, unknown> = {}
|
let errorData: Record<string, unknown> = {}
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('json')) {
|
||||||
errorData = await response.json().catch(() => ({}))
|
errorData = await response.json().catch(() => ({}))
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text().catch(() => '')
|
const text = await response.text().catch(() => '')
|
||||||
errorData = text ? { message: text } : {}
|
errorData = text ? { message: text } : {}
|
||||||
}
|
}
|
||||||
const errorMessage = response.status === 403
|
const rawMessage = response.status === 403
|
||||||
? 'Permissions insuffisantes pour cette action.'
|
? '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)
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage, status: response.status }
|
return { success: false, error: errorMessage, status: response.status }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
const err = error as Error & { name?: string }
|
const err = error as Error & { name?: string }
|
||||||
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
|
const errorMessage = err.name === 'AbortError'
|
||||||
showError(`Erreur réseau: ${errorMessage}`)
|
? '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 }
|
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>> => {
|
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||||
return apiCall<T>(endpoint, { method: 'DELETE' })
|
return apiCall<T>(endpoint, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
@@ -123,6 +133,7 @@ export function useApi() {
|
|||||||
apiCall,
|
apiCall,
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
|
postFormData,
|
||||||
patch,
|
patch,
|
||||||
put,
|
put,
|
||||||
delete: del,
|
delete: del,
|
||||||
|
|||||||
@@ -68,15 +68,21 @@ export function useComments() {
|
|||||||
const fetchAllComments = async (options: {
|
const fetchAllComments = async (options: {
|
||||||
status?: string
|
status?: string
|
||||||
entityType?: string
|
entityType?: string
|
||||||
|
entityName?: string
|
||||||
page?: number
|
page?: number
|
||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
|
orderBy?: string
|
||||||
|
orderDir?: string
|
||||||
} = {}): Promise<CommentListResult> => {
|
} = {}): Promise<CommentListResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (options.status) params.set('status', options.status)
|
if (options.status) params.set('status', options.status)
|
||||||
if (options.entityType) params.set('entityType', options.entityType)
|
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('itemsPerPage', String(options.itemsPerPage || 30))
|
||||||
params.set('page', String(options.page || 1))
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
478
app/composables/useComponentEdit.ts
Normal file
478
app/composables/useComponentEdit.ts
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
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 type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
|
||||||
|
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, patch } = 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 saveSlotQuantity = async (entry: SelectionEntry) => {
|
||||||
|
const slotId = entry.slotId
|
||||||
|
const quantity = typeof entry._definition?.quantity === 'number'
|
||||||
|
? Math.max(1, entry._definition.quantity)
|
||||||
|
: null
|
||||||
|
if (!slotId || quantity === null) return
|
||||||
|
try {
|
||||||
|
await patch(`/composant-piece-slots/${slotId}`, { quantity })
|
||||||
|
toast.showSuccess('Quantité mise à jour')
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitEdition = async () => {
|
||||||
|
if (!component.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
saveSlotQuantity,
|
||||||
|
resolvePieceLabel,
|
||||||
|
resolveProductLabel,
|
||||||
|
resolveSubcomponentLabel,
|
||||||
|
formatStructurePreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export interface Composant {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
|
description?: string | null
|
||||||
typeComposantId?: string | null
|
typeComposantId?: string | null
|
||||||
typeComposant?: { id: string; name?: string } | null
|
typeComposant?: { id: string; name?: string } | null
|
||||||
productId?: string | null
|
productId?: string | null
|
||||||
@@ -40,6 +41,7 @@ interface LoadComposantsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
typeName?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +108,11 @@ export function useComposants() {
|
|||||||
itemsPerPage = 30,
|
itemsPerPage = 30,
|
||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
|
typeName,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1) {
|
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -134,6 +137,10 @@ export function useComposants() {
|
|||||||
params.set('name', search.trim())
|
params.set('name', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeName && typeName.trim()) {
|
||||||
|
params.set('typeComposant.name', typeName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/composants?${params.toString()}`)
|
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 { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { normalizeRelationIds } from '~/shared/apiRelations'
|
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
@@ -10,12 +9,21 @@ export interface Document {
|
|||||||
filename: string
|
filename: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
size: number
|
size: number
|
||||||
path: string
|
fileUrl: string
|
||||||
|
downloadUrl: string
|
||||||
|
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||||
|
path?: string
|
||||||
|
createdAt?: string
|
||||||
siteId?: string
|
siteId?: string
|
||||||
machineId?: string
|
machineId?: string
|
||||||
composantId?: string
|
composantId?: string
|
||||||
productId?: string
|
productId?: string
|
||||||
pieceId?: 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 {
|
export interface UploadContext {
|
||||||
@@ -32,19 +40,30 @@ export interface DocumentResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const documents = ref<Document[]>([])
|
interface LoadDocumentsOptions {
|
||||||
const loading = ref(false)
|
search?: string
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
orderBy?: string
|
||||||
|
orderDir?: 'asc' | 'desc'
|
||||||
|
attachmentFilter?: string
|
||||||
|
force?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const fileToBase64 = (file: File): Promise<string> =>
|
const documents = ref<Document[]>([])
|
||||||
new Promise((resolve, reject) => {
|
const total = ref(0)
|
||||||
const reader = new FileReader()
|
const loading = ref(false)
|
||||||
reader.onload = () => resolve(reader.result as string)
|
const loaded = ref(false)
|
||||||
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
|
|
||||||
reader.readAsDataURL(file)
|
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() {
|
export function useDocuments() {
|
||||||
const { get, post, delete: del } = useApi()
|
const { get, postFormData, delete: del } = useApi()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
const loadFromEndpoint = async (
|
const loadFromEndpoint = async (
|
||||||
@@ -76,10 +95,61 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocuments = async (
|
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
|
||||||
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
const {
|
||||||
): Promise<DocumentResult> => {
|
search = '',
|
||||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
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 (
|
const loadDocumentsBySite = async (
|
||||||
@@ -145,18 +215,17 @@ export function useDocuments() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
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({
|
if (context.siteId) formData.append('siteId', context.siteId)
|
||||||
name: file.name,
|
if (context.machineId) formData.append('machineId', context.machineId)
|
||||||
filename: file.name,
|
if (context.composantId) formData.append('composantId', context.composantId)
|
||||||
mimeType: file.type || 'application/octet-stream',
|
if (context.productId) formData.append('productId', context.productId)
|
||||||
size: file.size,
|
if (context.pieceId) formData.append('pieceId', context.pieceId)
|
||||||
path: dataUrl,
|
|
||||||
...context,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await post('/documents', payload)
|
const result = await postFormData('/documents', formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
created.push(result.data as Document)
|
created.push(result.data as Document)
|
||||||
showSuccess(`Document "${file.name}" ajouté`)
|
showSuccess(`Document "${file.name}" ajouté`)
|
||||||
@@ -213,7 +282,9 @@ export function useDocuments() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadDocuments,
|
loadDocuments,
|
||||||
loadDocumentsBySite,
|
loadDocumentsBySite,
|
||||||
loadDocumentsByMachine,
|
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
|
// CRUD operations
|
||||||
const refreshDocuments = async () => {
|
const refreshDocuments = async () => {
|
||||||
const e = entity()
|
const e = entity()
|
||||||
if (!e?.id) return
|
if (!e?.id || e._structurePiece) return
|
||||||
loadingDocuments.value = true
|
loadingDocuments.value = true
|
||||||
try {
|
try {
|
||||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import {
|
import {
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
createModelType,
|
createModelType,
|
||||||
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: state.types.value }
|
return { success: true, data: state.types.value }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { message?: string }
|
const err = error as Error & { message?: string }
|
||||||
const message = err?.message || 'Erreur inconnue'
|
const message = humanizeError(err?.message)
|
||||||
showError(`Impossible de charger les types de ${label}: ${message}`)
|
showError(`Impossible de charger les types de ${label}.`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la création du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
|
|||||||
@@ -1,46 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Machine creation page – orchestration composable.
|
* Machine creation page – orchestration composable.
|
||||||
*
|
*
|
||||||
* Consolidates entity lookup maps, option filters, label helpers,
|
* Simplified: no more TypeMachine / skeleton system.
|
||||||
* template wrappers, and the finalization logic that were previously
|
* Supports direct creation or cloning from an existing machine.
|
||||||
* inlined in pages/machines/new.vue.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
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 { useToast } from '~/composables/useToast'
|
||||||
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import {
|
|
||||||
useMachineCreatePreview,
|
|
||||||
validateRequirementSelections as _validateRequirementSelections,
|
|
||||||
resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel,
|
|
||||||
resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel,
|
|
||||||
} from '~/composables/useMachineCreatePreview'
|
|
||||||
import {
|
|
||||||
getComponentMachineAssignments,
|
|
||||||
getPieceMachineAssignments,
|
|
||||||
getPieceComponentAssignments,
|
|
||||||
formatAssignmentList,
|
|
||||||
} from '~/shared/utils/assignmentUtils'
|
|
||||||
|
|
||||||
export function useMachineCreatePage() {
|
export function useMachineCreatePage() {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Composable calls
|
// Composable calls
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
|
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
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()
|
const toast = useToast()
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -48,355 +25,84 @@ export function useMachineCreatePage() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
const newMachine = reactive({
|
const newMachine = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
siteId: '',
|
siteId: '',
|
||||||
typeMachineId: '',
|
|
||||||
reference: '',
|
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
|
// Machine creation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const finalizeMachineCreation = async () => {
|
const finalizeMachineCreation = async () => {
|
||||||
if (submitting.value) return
|
if (submitting.value) return
|
||||||
const type = selectedMachineType.value
|
|
||||||
if (!type) {
|
if (!newMachine.name?.trim()) {
|
||||||
toast.showError('Merci de sélectionner un type de machine')
|
toast.showError('Merci de renseigner un nom pour la machine')
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!canCreateMachine.value) {
|
|
||||||
toast.showError('Compléter les informations obligatoires avant de créer la machine')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const baseMachineData = {
|
let result: any
|
||||||
name: newMachine.name,
|
|
||||||
siteId: newMachine.siteId,
|
if (newMachine.cloneFromMachineId) {
|
||||||
reference: newMachine.reference,
|
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
||||||
typeMachineId: type.id,
|
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) {
|
if (result.success) {
|
||||||
const machineId = result.data?.id
|
const machineId = result.data?.id
|
||||||
if (hasRequirements && machineId) {
|
|| (result.data?.machine as any)?.id
|
||||||
const skeletonResult: any = await reconfigureSkeleton(machineId, {
|
|| null
|
||||||
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(() => {})
|
|
||||||
}
|
|
||||||
newMachine.name = ''
|
newMachine.name = ''
|
||||||
newMachine.siteId = ''
|
newMachine.siteId = ''
|
||||||
newMachine.typeMachineId = ''
|
|
||||||
newMachine.reference = ''
|
newMachine.reference = ''
|
||||||
clearRequirementSelections()
|
newMachine.cloneFromMachineId = ''
|
||||||
await navigateTo('/machines')
|
|
||||||
|
if (machineId) {
|
||||||
|
await navigateTo(`/machine/${machineId}`)
|
||||||
|
} else {
|
||||||
|
await navigateTo('/machines')
|
||||||
|
}
|
||||||
} else if (result.error) {
|
} 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) {
|
} 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 {
|
} finally {
|
||||||
submitting.value = false
|
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 () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
loading.value = true
|
||||||
loadSites(),
|
try {
|
||||||
loadMachineTypes(),
|
await Promise.all([
|
||||||
loadComposants({ itemsPerPage: 200, force: true }),
|
loadSites(),
|
||||||
loadPieces({ itemsPerPage: 200, force: true }),
|
loadMachines(),
|
||||||
loadProducts({ itemsPerPage: 200, force: true }),
|
])
|
||||||
])
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -405,59 +111,11 @@ export function useMachineCreatePage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
submitting,
|
|
||||||
newMachine,
|
newMachine,
|
||||||
sites,
|
sites,
|
||||||
machineTypes,
|
machines,
|
||||||
machineTypesLoading,
|
submitting,
|
||||||
composantsLoading,
|
loading,
|
||||||
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,
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
finalizeMachineCreation,
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
233
app/composables/useMachineDetailUpdates.ts
Normal file
233
app/composables/useMachineDetailUpdates.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* 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 prixStr =
|
||||||
|
updatedComponent.prix !== null &&
|
||||||
|
updatedComponent.prix !== undefined &&
|
||||||
|
String(updatedComponent.prix).trim() !== ''
|
||||||
|
? String(updatedComponent.prix)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const result: any = await updateComposantApi(updatedComponent.id as string, {
|
||||||
|
name: updatedComponent.name,
|
||||||
|
reference: updatedComponent.reference,
|
||||||
|
constructeurIds: cIds,
|
||||||
|
prix: prixStr,
|
||||||
|
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 prixStr =
|
||||||
|
updatedPiece.prix !== null &&
|
||||||
|
updatedPiece.prix !== undefined &&
|
||||||
|
String(updatedPiece.prix).trim() !== ''
|
||||||
|
? String(updatedPiece.prix)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const result: any = await updatePieceApi(updatedPiece.id as string, {
|
||||||
|
name: updatedPiece.name,
|
||||||
|
reference: updatedPiece.reference || null,
|
||||||
|
constructeurIds: cIds,
|
||||||
|
prix: prixStr,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update slot quantity if this is a composant structure piece
|
||||||
|
const slotId = updatedPiece.slotId as string | null
|
||||||
|
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
|
||||||
|
if (slotId && quantity !== null) {
|
||||||
|
await apiPatch(`/composant-piece-slots/${slotId}`, { quantity })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 machinePieceLinkId = normalizePieceLinkId(link)
|
||||||
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
||||||
|
|
||||||
@@ -170,11 +168,8 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
||||||
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || 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 : [],
|
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
|
||||||
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
|
typePiece: appliedPiece.typePiece || null,
|
||||||
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || null,
|
||||||
typeMachinePieceRequirement: requirement,
|
|
||||||
typeMachinePieceRequirementId: requirement?.id || null,
|
|
||||||
requirementId: requirement?.id || null,
|
|
||||||
overrides,
|
overrides,
|
||||||
originalPiece,
|
originalPiece,
|
||||||
machinePieceLink: link,
|
machinePieceLink: link,
|
||||||
@@ -186,11 +181,9 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
||||||
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
||||||
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
||||||
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
|
||||||
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
|
||||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||||
customFields: appliedPiece.customFields || [],
|
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)
|
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 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 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 machineComponentLinkId = normalizeComponentLinkId(link)
|
||||||
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
||||||
|
|
||||||
@@ -224,10 +215,39 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
|
|
||||||
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
|
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
|
||||||
|
|
||||||
const pieces = Array.isArray(link.pieceLinks)
|
const linkedPieces = Array.isArray(link.pieceLinks)
|
||||||
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
|
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
// If no linked pieces exist, build read-only entries from the composant's structure
|
||||||
|
const structurePieceDefs = (!linkedPieces.length && appliedComponent.structure && typeof appliedComponent.structure === 'object')
|
||||||
|
? (Array.isArray((appliedComponent.structure as AnyRecord).pieces) ? (appliedComponent.structure as AnyRecord).pieces as AnyRecord[] : [])
|
||||||
|
: []
|
||||||
|
const structurePieces = structurePieceDefs.map((def, index) => {
|
||||||
|
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
|
||||||
|
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
|
||||||
|
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
|
||||||
|
return {
|
||||||
|
...(resolved || {}),
|
||||||
|
id: resolved?.id || `structure-piece-${composantId}-${index}`,
|
||||||
|
pieceId: resolved?.id || null,
|
||||||
|
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
|
||||||
|
reference: resolved?.reference || definition.reference || def.reference || null,
|
||||||
|
prix: resolved?.prix ?? null,
|
||||||
|
constructeurs: resolved?.constructeurs || [],
|
||||||
|
documents: [],
|
||||||
|
quantity,
|
||||||
|
slotId: def.slotId || definition.slotId || null,
|
||||||
|
typePieceId: resolved?.typePieceId || definition.typePieceId || def.typePieceId || null,
|
||||||
|
typePiece: resolved?.typePiece || null,
|
||||||
|
parentComponentLinkId: machineComponentLinkId,
|
||||||
|
parentComponentName: componentName,
|
||||||
|
_structurePiece: true,
|
||||||
|
}
|
||||||
|
}) as AnyRecord[]
|
||||||
|
|
||||||
|
const pieces = linkedPieces.length ? linkedPieces : structurePieces
|
||||||
|
|
||||||
const subComponents = Array.isArray(link.childLinks)
|
const subComponents = Array.isArray(link.childLinks)
|
||||||
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
|
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
|
||||||
: []
|
: []
|
||||||
@@ -245,11 +265,8 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
||||||
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || 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 : [],
|
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
|
||||||
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
|
typeComposant: appliedComponent.typeComposant || null,
|
||||||
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || null,
|
||||||
typeMachineComponentRequirement: requirement,
|
|
||||||
typeMachineComponentRequirementId: requirement?.id || null,
|
|
||||||
requirementId: requirement?.id || null,
|
|
||||||
overrides: compOverrides,
|
overrides: compOverrides,
|
||||||
machineComponentLinkOverrides: compOverrides,
|
machineComponentLinkOverrides: compOverrides,
|
||||||
definitionOverrides: compOverrides,
|
definitionOverrides: compOverrides,
|
||||||
@@ -259,16 +276,12 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
componentLinkId: machineComponentLinkId,
|
componentLinkId: machineComponentLinkId,
|
||||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
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 || {},
|
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||||
customFields: appliedComponent.customFields || [],
|
customFields: appliedComponent.customFields || [],
|
||||||
pieces,
|
pieces,
|
||||||
subComponents,
|
subComponents,
|
||||||
subcomponents: subComponents,
|
subcomponents: subComponents,
|
||||||
sousComposants: 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)
|
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
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
siteId?: string | null
|
siteId?: string | null
|
||||||
typeMachineId?: string | null
|
|
||||||
componentLinks?: unknown[]
|
componentLinks?: unknown[]
|
||||||
pieceLinks?: unknown[]
|
pieceLinks?: unknown[]
|
||||||
[key: string]: 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']) ??
|
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
|
||||||
resolveLinkCollection(container, ['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> => {
|
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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) {
|
if (!machineId) {
|
||||||
return { success: false, error: 'Identifiant de machine manquant' }
|
return { success: false, error: 'Identifiant de machine manquant' }
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/machines/${machineId}/skeleton`, payload)
|
const result = await patch(`/machines/${machineId}/structure`, payload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -180,7 +163,29 @@ export function useMachines() {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} 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 }
|
return { success: false, error: (error as Error).message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -241,10 +246,6 @@ export function useMachines() {
|
|||||||
return machines.value.filter((machine) => machine.siteId === siteId)
|
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 getMachines = (): Machine[] => machines.value
|
||||||
const isLoading = (): boolean => loading.value
|
const isLoading = (): boolean => loading.value
|
||||||
|
|
||||||
@@ -253,13 +254,12 @@ export function useMachines() {
|
|||||||
loading,
|
loading,
|
||||||
loadMachines,
|
loadMachines,
|
||||||
createMachine,
|
createMachine,
|
||||||
createMachineFromType,
|
|
||||||
updateMachine: updateMachineData,
|
updateMachine: updateMachineData,
|
||||||
reconfigureSkeleton,
|
updateStructure,
|
||||||
|
cloneMachine,
|
||||||
deleteMachine,
|
deleteMachine,
|
||||||
getMachineById,
|
getMachineById,
|
||||||
getMachinesBySite,
|
getMachinesBySite,
|
||||||
getMachinesByType,
|
|
||||||
getMachines,
|
getMachines,
|
||||||
isLoading,
|
isLoading,
|
||||||
addMissingCustomFields,
|
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
|
id: string
|
||||||
name: string
|
name: string
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
|
description?: string | null
|
||||||
typePieceId?: string | null
|
typePieceId?: string | null
|
||||||
typePiece?: { id: string; name?: string } | null
|
typePiece?: { id: string; name?: string } | null
|
||||||
productId?: string | null
|
productId?: string | null
|
||||||
@@ -41,6 +42,7 @@ interface LoadPiecesOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
typeName?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,10 +118,11 @@ export function usePieces() {
|
|||||||
itemsPerPage = 30,
|
itemsPerPage = 30,
|
||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
|
typeName,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1) {
|
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -144,6 +147,10 @@ export function usePieces() {
|
|||||||
params.set('name', search.trim())
|
params.set('name', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeName && typeName.trim()) {
|
||||||
|
params.set('typePiece.name', typeName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/pieces?${params.toString()}`)
|
const result = await get(`/pieces?${params.toString()}`)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
@@ -39,6 +40,7 @@ interface LoadProductsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
typeName?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +117,11 @@ export function useProducts() {
|
|||||||
itemsPerPage = 30,
|
itemsPerPage = 30,
|
||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
|
typeName,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1) {
|
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -143,6 +146,10 @@ export function useProducts() {
|
|||||||
params.set('name', search.trim())
|
params.set('name', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeName && typeName.trim()) {
|
||||||
|
params.set('typeProduct.name', typeName.trim())
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/products?${params.toString()}`)
|
const result = await get(`/products?${params.toString()}`)
|
||||||
@@ -168,9 +175,9 @@ export function useProducts() {
|
|||||||
return result as ProductListResult
|
return result as ProductListResult
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors du chargement des produits:', 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
|
error.value = message
|
||||||
showError(`Impossible de charger les produits: ${message}`)
|
showError(`Impossible de charger les produits.`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -197,9 +204,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la création du produit:', 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
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de créer le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -223,9 +230,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la mise à jour du produit:', 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
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de mettre à jour le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -248,9 +255,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la suppression du produit:', 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
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de supprimer le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { navigateTo, useRoute } from '#imports'
|
import { navigateTo, useRoute } from '#imports'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
@@ -9,6 +10,7 @@ import { canPreviewDocument } from '~/utils/documentPreview'
|
|||||||
|
|
||||||
type SiteForm = {
|
type SiteForm = {
|
||||||
name: string
|
name: string
|
||||||
|
color: string
|
||||||
contactName: string
|
contactName: string
|
||||||
contactPhone: string
|
contactPhone: string
|
||||||
contactAddress: string
|
contactAddress: string
|
||||||
@@ -23,11 +25,14 @@ type SiteDocument = {
|
|||||||
mimeType?: string
|
mimeType?: string
|
||||||
size?: number
|
size?: number
|
||||||
path?: string
|
path?: string
|
||||||
|
fileUrl?: string
|
||||||
|
downloadUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteWithDocuments = {
|
type SiteWithDocuments = {
|
||||||
id: string
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
|
color?: string
|
||||||
contactName?: string
|
contactName?: string
|
||||||
contactPhone?: string
|
contactPhone?: string
|
||||||
contactAddress?: string
|
contactAddress?: string
|
||||||
@@ -51,6 +56,7 @@ export function useSiteManagement() {
|
|||||||
|
|
||||||
const newSite = reactive<SiteForm>({
|
const newSite = reactive<SiteForm>({
|
||||||
name: '',
|
name: '',
|
||||||
|
color: '',
|
||||||
contactName: '',
|
contactName: '',
|
||||||
contactPhone: '',
|
contactPhone: '',
|
||||||
contactAddress: '',
|
contactAddress: '',
|
||||||
@@ -60,6 +66,7 @@ export function useSiteManagement() {
|
|||||||
|
|
||||||
const editSiteForm = reactive<SiteForm>({
|
const editSiteForm = reactive<SiteForm>({
|
||||||
name: '',
|
name: '',
|
||||||
|
color: '',
|
||||||
contactName: '',
|
contactName: '',
|
||||||
contactPhone: '',
|
contactPhone: '',
|
||||||
contactAddress: '',
|
contactAddress: '',
|
||||||
@@ -78,6 +85,7 @@ export function useSiteManagement() {
|
|||||||
|
|
||||||
const resetNewSite = () => {
|
const resetNewSite = () => {
|
||||||
newSite.name = ''
|
newSite.name = ''
|
||||||
|
newSite.color = ''
|
||||||
newSite.contactName = ''
|
newSite.contactName = ''
|
||||||
newSite.contactPhone = ''
|
newSite.contactPhone = ''
|
||||||
newSite.contactAddress = ''
|
newSite.contactAddress = ''
|
||||||
@@ -98,6 +106,7 @@ export function useSiteManagement() {
|
|||||||
const handleCreateSite = async () => {
|
const handleCreateSite = async () => {
|
||||||
const result = await createSite({
|
const result = await createSite({
|
||||||
name: newSite.name,
|
name: newSite.name,
|
||||||
|
color: newSite.color,
|
||||||
contactName: newSite.contactName,
|
contactName: newSite.contactName,
|
||||||
contactPhone: newSite.contactPhone,
|
contactPhone: newSite.contactPhone,
|
||||||
contactAddress: newSite.contactAddress,
|
contactAddress: newSite.contactAddress,
|
||||||
@@ -113,6 +122,7 @@ export function useSiteManagement() {
|
|||||||
const editSite = (site: SiteWithDocuments) => {
|
const editSite = (site: SiteWithDocuments) => {
|
||||||
siteBeingEdited.value = site
|
siteBeingEdited.value = site
|
||||||
editSiteForm.name = site.name || ''
|
editSiteForm.name = site.name || ''
|
||||||
|
editSiteForm.color = site.color || ''
|
||||||
editSiteForm.contactName = site.contactName || ''
|
editSiteForm.contactName = site.contactName || ''
|
||||||
editSiteForm.contactPhone = site.contactPhone || ''
|
editSiteForm.contactPhone = site.contactPhone || ''
|
||||||
editSiteForm.contactAddress = site.contactAddress || ''
|
editSiteForm.contactAddress = site.contactAddress || ''
|
||||||
@@ -145,6 +155,7 @@ export function useSiteManagement() {
|
|||||||
|
|
||||||
const baseUpdate = {
|
const baseUpdate = {
|
||||||
name: editSiteForm.name,
|
name: editSiteForm.name,
|
||||||
|
color: editSiteForm.color,
|
||||||
contactName: editSiteForm.contactName,
|
contactName: editSiteForm.contactName,
|
||||||
contactPhone: editSiteForm.contactPhone,
|
contactPhone: editSiteForm.contactPhone,
|
||||||
contactAddress: editSiteForm.contactAddress,
|
contactAddress: editSiteForm.contactAddress,
|
||||||
@@ -209,17 +220,23 @@ export function useSiteManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const downloadDocument = (doc: SiteDocument) => {
|
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')
|
const link = document.createElement('a')
|
||||||
link.href = doc.path
|
link.href = url
|
||||||
link.download = doc.filename || doc.name || 'document'
|
link.download = doc.filename || doc.name || 'document'
|
||||||
link.click()
|
link.click()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(doc.path, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPreview = (doc: SiteDocument) => {
|
const openPreview = (doc: SiteDocument) => {
|
||||||
@@ -266,10 +283,10 @@ export function useSiteManagement() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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 {
|
export interface Site {
|
||||||
id: string
|
id: string
|
||||||
name?: string
|
name?: string
|
||||||
|
color?: string
|
||||||
contactName?: string
|
contactName?: string
|
||||||
contactPhone?: string
|
contactPhone?: string
|
||||||
contactAddress?: string
|
contactAddress?: string
|
||||||
|
|||||||
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
|
const MAX_TOASTS = 3
|
||||||
let nextId = 1
|
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() {
|
export function useToast() {
|
||||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
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 id = nextId++
|
||||||
const toast: Toast = {
|
const toast: Toast = {
|
||||||
id,
|
id,
|
||||||
@@ -65,6 +76,7 @@ export function useToast() {
|
|||||||
|
|
||||||
const clearAll = (): void => {
|
const clearAll = (): void => {
|
||||||
toasts.value = []
|
toasts.value = []
|
||||||
|
recentMessages.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,8 +9,24 @@
|
|||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<DataTable
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
: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">
|
<div class="flex items-center gap-2">
|
||||||
<label
|
<label
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
@@ -22,7 +38,7 @@
|
|||||||
id="activity-entity-type"
|
id="activity-entity-type"
|
||||||
v-model="entityTypeFilter"
|
v-model="entityTypeFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="">Tous</option>
|
<option value="">Tous</option>
|
||||||
<option value="piece">Pièce</option>
|
<option value="piece">Pièce</option>
|
||||||
@@ -42,7 +58,7 @@
|
|||||||
id="activity-action"
|
id="activity-action"
|
||||||
v-model="actionFilter"
|
v-model="actionFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="">Toutes</option>
|
<option value="">Toutes</option>
|
||||||
<option value="create">Création</option>
|
<option value="create">Création</option>
|
||||||
@@ -50,157 +66,111 @@
|
|||||||
<option value="delete">Suppression</option>
|
<option value="delete">Suppression</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<template #cell-createdAt="{ row }">
|
||||||
<label
|
<span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
</template>
|
||||||
for="activity-per-page"
|
|
||||||
|
<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
|
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||||
</label>
|
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||||
<select
|
<span>→</span>
|
||||||
id="activity-per-page"
|
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||||
v-model.number="itemsPerPage"
|
</div>
|
||||||
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>
|
||||||
</div>
|
</template>
|
||||||
|
</DataTable>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useActivityLog } from '~/composables/useActivityLog'
|
||||||
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import {
|
import {
|
||||||
historyActionLabel,
|
historyActionLabel,
|
||||||
formatHistoryDate,
|
formatHistoryDate,
|
||||||
historyDiffEntries,
|
historyDiffEntries,
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
} from '~/shared/utils/historyDisplayUtils'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
|
||||||
|
|
||||||
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
||||||
|
|
||||||
const currentPage = ref(1)
|
const table = useDataTable(
|
||||||
const itemsPerPage = ref(50)
|
{ fetchData: fetchLog },
|
||||||
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
|
{
|
||||||
|
defaultSort: 'createdAt',
|
||||||
|
defaultDirection: 'desc',
|
||||||
|
defaultPerPage: 50,
|
||||||
|
persistToUrl: true,
|
||||||
|
extraParams: {
|
||||||
|
entityType: { default: '' },
|
||||||
|
action: { default: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const entityTypeFilter = ref('')
|
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||||
const actionFilter = ref('')
|
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>())
|
const expandedIds = reactive(new Set<string>())
|
||||||
|
|
||||||
@@ -209,28 +179,18 @@ const toggleExpanded = (id: string) => {
|
|||||||
else expandedIds.add(id)
|
else expandedIds.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDiff = (entry: ActivityLogEntry) =>
|
const canExpandRow = (row: any) =>
|
||||||
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
|
row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
|
||||||
|
|
||||||
const fetchLog = () => {
|
function fetchLog() {
|
||||||
loadActivityLog({
|
loadActivityLog({
|
||||||
page: currentPage.value,
|
page: table.currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
entityType: entityTypeFilter.value || undefined,
|
entityType: entityTypeFilter.value || undefined,
|
||||||
action: actionFilter.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> = {
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
piece: 'Pièce',
|
piece: 'Pièce',
|
||||||
product: 'Produit',
|
product: 'Produit',
|
||||||
|
|||||||
@@ -9,82 +9,66 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center py-12">
|
<DataTable
|
||||||
<span class="loading loading-spinner loading-lg" />
|
:columns="columns"
|
||||||
</div>
|
: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">
|
<template #cell-email="{ row }">
|
||||||
<table class="table table-zebra w-full">
|
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
|
||||||
<thead>
|
</template>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-else class="text-center py-12 text-base-content/60">
|
<template #cell-role="{ row }">
|
||||||
Aucun profil.
|
<select
|
||||||
</div>
|
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 -->
|
<!-- Create Profile Dialog -->
|
||||||
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||||
@@ -112,15 +96,9 @@
|
|||||||
<div class="form-control mb-3">
|
<div class="form-control mb-3">
|
||||||
<label class="label"><span class="label-text">Role</span></label>
|
<label class="label"><span class="label-text">Role</span></label>
|
||||||
<select v-model="createForm.role" class="select select-bordered">
|
<select v-model="createForm.role" class="select select-bordered">
|
||||||
<option value="ROLE_ADMIN">
|
<option value="ROLE_ADMIN">Admin</option>
|
||||||
Admin
|
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||||
</option>
|
<option value="ROLE_VIEWER">Viewer</option>
|
||||||
<option value="ROLE_GESTIONNAIRE">
|
|
||||||
Gestionnaire
|
|
||||||
</option>
|
|
||||||
<option value="ROLE_VIEWER">
|
|
||||||
Viewer
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
@@ -173,11 +151,55 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useAdminProfiles } from '#imports'
|
import { useAdminProfiles } from '#imports'
|
||||||
|
|
||||||
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
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 showCreateDialog = ref(false)
|
||||||
const showPasswordDialog = ref(false)
|
const showPasswordDialog = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
@@ -209,7 +231,8 @@ const handleCreate = async () => {
|
|||||||
await createProfile(data)
|
await createProfile(data)
|
||||||
showCreateDialog.value = false
|
showCreateDialog.value = false
|
||||||
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
creating.value = false
|
creating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +253,8 @@ const handleSetPassword = async () => {
|
|||||||
try {
|
try {
|
||||||
await setPassword(passwordProfileId.value, newPassword.value)
|
await setPassword(passwordProfileId.value, newPassword.value)
|
||||||
showPasswordDialog.value = false
|
showPasswordDialog.value = false
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
settingPassword.value = false
|
settingPassword.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +263,8 @@ const handleDeactivate = async (profileId) => {
|
|||||||
await deactivateProfile(profileId)
|
await deactivateProfile(profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
fetchAll()
|
await fetchAll()
|
||||||
|
loaded.value = true
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -69,6 +69,69 @@ const badgeClass = (type: ChangeType) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const releases: Release[] = [
|
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',
|
version: 'v1.6.1',
|
||||||
date: '2026-02-12',
|
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: '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: '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: '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: '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: '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' },
|
{ 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">
|
<h1 class="text-3xl font-semibold text-base-content">
|
||||||
Commentaires
|
Commentaires
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-base-content/50">
|
||||||
Liste de tous les commentaires et tickets ouverts sur les fiches.
|
Liste de tous les commentaires et tickets ouverts sur les fiches.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<!-- Filtres -->
|
<DataTable
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
:columns="columns"
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
: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">
|
<div class="flex items-center gap-2">
|
||||||
<label
|
<label
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
@@ -25,17 +38,11 @@
|
|||||||
id="comment-status"
|
id="comment-status"
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="open">
|
<option value="open">Ouverts</option>
|
||||||
Ouverts
|
<option value="resolved">Résolus</option>
|
||||||
</option>
|
<option value="">Tous</option>
|
||||||
<option value="resolved">
|
|
||||||
Résolus
|
|
||||||
</option>
|
|
||||||
<option value="">
|
|
||||||
Tous
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,186 +57,84 @@
|
|||||||
id="comment-entity-type"
|
id="comment-entity-type"
|
||||||
v-model="entityTypeFilter"
|
v-model="entityTypeFilter"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
@change="handleFilterChange"
|
@change="table.handleFilterChange"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">Tous</option>
|
||||||
Tous
|
<option value="machine">Machine</option>
|
||||||
</option>
|
<option value="piece">Pièce</option>
|
||||||
<option value="machine">
|
<option value="composant">Composant</option>
|
||||||
Machine
|
<option value="product">Produit</option>
|
||||||
</option>
|
<option value="piece_category">Catégorie pièce</option>
|
||||||
<option value="piece">
|
<option value="component_category">Catégorie composant</option>
|
||||||
Pièce
|
<option value="product_category">Catégorie produit</option>
|
||||||
</option>
|
<option value="machine_skeleton">Squelette machine</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<template #cell-content="{ row }">
|
||||||
<label
|
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
</template>
|
||||||
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>
|
|
||||||
|
|
||||||
<p class="text-xs text-base-content/50 lg:text-right">
|
<template #cell-entityType="{ row }">
|
||||||
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
<span class="badge badge-outline badge-sm">
|
||||||
</p>
|
{{ entityTypeLabel(row.entityType) }}
|
||||||
</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 }}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
</template>
|
||||||
class="btn btn-sm"
|
|
||||||
:disabled="page >= totalPages"
|
<template #cell-entity="{ row }">
|
||||||
@click="goToPage(page + 1)"
|
<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>
|
</button>
|
||||||
</div>
|
<span v-else class="text-xs text-base-content/50">
|
||||||
</template>
|
{{ row.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useComments, type Comment } from '~/composables/useComments'
|
||||||
import { usePermissions } from '~/composables/usePermissions'
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
@@ -241,16 +146,43 @@ const {
|
|||||||
|
|
||||||
const comments = ref<Comment[]>([])
|
const comments = ref<Comment[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const loadingList = ref(true)
|
||||||
const itemsPerPage = ref(20)
|
|
||||||
const statusFilter = ref('open')
|
|
||||||
const entityTypeFilter = ref('')
|
|
||||||
const loadingList = ref(false)
|
|
||||||
|
|
||||||
const totalPages = computed(() =>
|
const table = useDataTable(
|
||||||
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
{ 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> = {
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
machine: 'Machine',
|
machine: 'Machine',
|
||||||
piece: 'Pièce',
|
piece: 'Pièce',
|
||||||
@@ -277,13 +209,16 @@ const formatCommentDate = (dateStr: string): string => {
|
|||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadComments = async () => {
|
async function loadComments() {
|
||||||
loadingList.value = true
|
loadingList.value = true
|
||||||
const result = await fetchAllComments({
|
const result = await fetchAllComments({
|
||||||
status: statusFilter.value || undefined,
|
status: statusFilter.value || undefined,
|
||||||
entityType: entityTypeFilter.value || undefined,
|
entityType: entityTypeFilter.value || undefined,
|
||||||
page: page.value,
|
entityName: table.columnFilters.value.entity || undefined,
|
||||||
itemsPerPage: itemsPerPage.value,
|
page: table.currentPage.value,
|
||||||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
|
orderBy: table.sortField.value,
|
||||||
|
orderDir: table.sortDirection.value,
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
comments.value = result.data ?? []
|
comments.value = result.data ?? []
|
||||||
@@ -292,16 +227,6 @@ const loadComments = async () => {
|
|||||||
loadingList.value = false
|
loadingList.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFilterChange = () => {
|
|
||||||
page.value = 1
|
|
||||||
loadComments()
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToPage = (p: number) => {
|
|
||||||
page.value = p
|
|
||||||
loadComments()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResolve = async (commentId: string) => {
|
const handleResolve = async (commentId: string) => {
|
||||||
const result = await resolveComment(commentId)
|
const result = await resolveComment(commentId)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container mx-auto px-6 py-10 space-y-8">
|
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||||
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Catalogue des composants</h1>
|
<h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-base-content/50 mt-1">
|
||||||
Consultez et gérez tous les composants existants.
|
Consultez et gérez tous les composants existants.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,356 +11,194 @@
|
|||||||
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
|
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
|
||||||
Ajouter un composant
|
Ajouter un composant
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/component-category" class="btn btn-outline btn-sm md:btn-md">
|
<NuxtLink to="/component-category" class="btn btn-ghost btn-sm md:btn-md">
|
||||||
Gérer les catégories
|
Gérer les catégories
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-1">
|
||||||
<h2 class="text-xl font-semibold text-base-content">Composants créés</h2>
|
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/50">
|
||||||
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<DataTable
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
: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">
|
<label class="w-full sm:w-72">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
<input
|
<input
|
||||||
v-model="searchTerm"
|
v-model="table.searchTerm.value"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
placeholder="Nom ou référence…"
|
placeholder="Nom ou référence…"
|
||||||
@input="debouncedSearch"
|
@input="table.debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
</template>
|
||||||
<label
|
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
<template #cell-preview="{ row }">
|
||||||
for="component-catalog-sort"
|
<DocumentThumbnail
|
||||||
>
|
:document="resolvePrimaryDocument(row.component)"
|
||||||
Trier par
|
:alt="resolvePreviewAlt(row.component)"
|
||||||
</label>
|
/>
|
||||||
<select
|
</template>
|
||||||
id="component-catalog-sort"
|
|
||||||
v-model="sortField"
|
<template #cell-name="{ row }">
|
||||||
class="select select-bordered select-sm"
|
{{ row.component.name || 'Composant sans nom' }}
|
||||||
@change="handleSortChange"
|
</template>
|
||||||
>
|
|
||||||
<option value="name">Nom</option>
|
<template #cell-reference="{ row }">
|
||||||
<option value="createdAt">Date de création</option>
|
{{ row.component.reference || '—' }}
|
||||||
</select>
|
</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>
|
||||||
<div class="flex items-center gap-2">
|
<span v-else>—</span>
|
||||||
<label
|
</template>
|
||||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
|
||||||
for="component-catalog-dir"
|
<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
|
Modifier
|
||||||
</label>
|
</NuxtLink>
|
||||||
<select
|
<button
|
||||||
id="component-catalog-dir"
|
v-if="canEdit"
|
||||||
v-model="sortDirection"
|
type="button"
|
||||||
class="select select-bordered select-sm"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
@change="handleSortChange"
|
:disabled="loadingComposants"
|
||||||
|
@click="handleDeleteComponent(row.component)"
|
||||||
>
|
>
|
||||||
<option value="asc">Ascendant</option>
|
Supprimer
|
||||||
<option value="desc">Descendant</option>
|
</button>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
</template>
|
||||||
<label
|
</DataTable>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { showError } = useToast()
|
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
||||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
|
||||||
|
|
||||||
// State synced with URL query params (preserved on back/forward navigation)
|
const table = useDataTable(
|
||||||
const {
|
{ fetchData: fetchComposants },
|
||||||
page: currentPage,
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||||
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 composantsTotal = computed(() => total.value)
|
const columns = [
|
||||||
const composantsOnPage = computed(() => composants.value.length)
|
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
{ 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
|
const composantsOnPage = computed(() => componentRows.value.length)
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
const paginationState = table.pagination(total, composantsOnPage)
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
// Enrich composants with full type data
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchComposants()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrichir les composants avec les types de composants complets
|
|
||||||
const composantsList = computed(() => {
|
const composantsList = computed(() => {
|
||||||
return (composants.value || []).map((composant) => {
|
return (composants.value || []).map((composant) => {
|
||||||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||||
return {
|
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||||||
...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({
|
await loadComposants({
|
||||||
search: searchTerm.value,
|
search: table.searchTerm.value,
|
||||||
page: currentPage.value,
|
page: table.currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: table.sortField.value,
|
||||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
force: true
|
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 resolveComponentType = (component: Record<string, any>) => {
|
||||||
const type = component?.typeComposant
|
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||||
if (type?.name) {
|
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||||
return type.name
|
|
||||||
}
|
|
||||||
if (component?.typeComposantLabel) {
|
|
||||||
return component.typeComposantLabel
|
|
||||||
}
|
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveDeleteGuard = (component: Record<string, any>) => {
|
const { confirm } = useConfirm()
|
||||||
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 handleDeleteComponent = async (component: Record<string, any>) => {
|
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 componentName = component?.name || 'ce composant'
|
||||||
const confirmLines = [
|
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
|
||||||
`Voulez-vous vraiment supprimer ${componentName} ?`,
|
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
|
||||||
]
|
if (!confirmed) return
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteComposant(component.id)
|
await deleteComposant(component.id)
|
||||||
// Reload current page after deletion
|
|
||||||
fetchComposants()
|
fetchComposants()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||||
fetchComposants(),
|
|
||||||
loadComponentTypes()
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
</script>
|
</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